From c83da26305f72e4ee7921a0bceea6ee70163cb50 Mon Sep 17 00:00:00 2001 From: git_admin Date: Sun, 3 May 2026 18:54:38 +0000 Subject: [PATCH] Tower: upload cetmix_tower_server 18.0.2.0.0 (was 18.0.2.0.0, via marketplace) --- addons/cetmix_tower_server/README.rst | 143 + addons/cetmix_tower_server/__init__.py | 4 + addons/cetmix_tower_server/__manifest__.py | 110 + .../data/cx_tower_jet_state.xml | 68 + .../data/ir_config_parameter.xml | 20 + addons/cetmix_tower_server/data/ir_cron.xml | 35 + .../cetmix_tower_server/data/neutralize.sql | 3 + addons/cetmix_tower_server/demo/demo_data.xml | 1427 +++ addons/cetmix_tower_server/demo/demo_jets.xml | 2172 +++++ .../i18n/cetmix_tower_server.pot | 7861 +++++++++++++++++ addons/cetmix_tower_server/i18n/de.po | 3462 ++++++++ addons/cetmix_tower_server/i18n/fi.po | 3449 ++++++++ addons/cetmix_tower_server/i18n/hr.po | 3591 ++++++++ addons/cetmix_tower_server/i18n/it.po | 5305 +++++++++++ .../migrations/18.0.3.0.0/pre-migration.py | 17 + addons/cetmix_tower_server/models/__init__.py | 52 + .../models/cetmix_tower.py | 313 + .../cetmix_tower_server/models/constants.py | 152 + .../models/cx_tower_access_mixin.py | 37 + .../models/cx_tower_access_role_mixin.py | 99 + .../models/cx_tower_command.py | 657 ++ .../models/cx_tower_command_log.py | 400 + .../cx_tower_custom_variable_value_mixin.py | 54 + .../models/cx_tower_file.py | 783 ++ .../models/cx_tower_file_template.py | 247 + .../models/cx_tower_jet.py | 1703 ++++ .../models/cx_tower_jet_action.py | 100 + .../models/cx_tower_jet_dependency.py | 63 + .../models/cx_tower_jet_request.py | 260 + .../models/cx_tower_jet_state.py | 90 + .../models/cx_tower_jet_template.py | 1446 +++ .../cx_tower_jet_template_dependency.py | 168 + .../models/cx_tower_jet_template_install.py | 474 + .../cx_tower_jet_template_install_line.py | 41 + .../models/cx_tower_jet_waypoint.py | 789 ++ .../models/cx_tower_jet_waypoint_template.py | 70 + .../models/cx_tower_key.py | 413 + .../models/cx_tower_key_mixin.py | 70 + .../models/cx_tower_key_value.py | 112 + .../models/cx_tower_metadata_mixin.py | 45 + .../cetmix_tower_server/models/cx_tower_os.py | 17 + .../models/cx_tower_plan.py | 423 + .../models/cx_tower_plan_line.py | 315 + .../models/cx_tower_plan_line_action.py | 101 + .../models/cx_tower_plan_log.py | 531 ++ .../models/cx_tower_reference_mixin.py | 480 + .../models/cx_tower_scheduled_task.py | 442 + .../models/cx_tower_scheduled_task_cv.py | 18 + .../models/cx_tower_server.py | 2472 ++++++ .../models/cx_tower_server_log.py | 238 + .../models/cx_tower_server_template.py | 653 ++ .../models/cx_tower_shortcut.py | 97 + .../models/cx_tower_tag.py | 91 + .../models/cx_tower_tag_mixin.py | 116 + .../models/cx_tower_template_mixin.py | 217 + .../models/cx_tower_variable.py | 900 ++ .../models/cx_tower_variable_mixin.py | 82 + .../models/cx_tower_variable_option.py | 117 + .../models/cx_tower_variable_value.py | 584 ++ .../models/cx_tower_vault.py | 52 + .../models/cx_tower_vault_mixin.py | 416 + .../models/ir_actions_server.py | 24 + .../models/res_config_settings.py | 79 + .../cetmix_tower_server/models/res_partner.py | 47 + .../cetmix_tower_server/models/res_users.py | 34 + addons/cetmix_tower_server/models/tools.py | 75 + addons/cetmix_tower_server/pyproject.toml | 3 + .../cetmix_tower_server/readme/CONFIGURE.md | 1 + .../cetmix_tower_server/readme/DESCRIPTION.md | 4 + addons/cetmix_tower_server/readme/HISTORY.md | 49 + addons/cetmix_tower_server/readme/USAGE.md | 1 + .../readme/diagrams/jets.puml | 77 + .../readme/newsfragments/.gitkeep | 0 .../security/cetmix_tower_server_groups.xml | 38 + .../cx_tower_command_log_security.xml | 33 + .../security/cx_tower_command_security.xml | 84 + .../security/cx_tower_file_security.xml | 52 + .../cx_tower_file_template_security.xml | 50 + .../security/cx_tower_jet_action_security.xml | 73 + .../cx_tower_jet_dependency_security.xml | 50 + .../security/cx_tower_jet_security.xml | 91 + ...tower_jet_template_dependency_security.xml | 51 + ...wer_jet_template_install_line_security.xml | 32 + ...cx_tower_jet_template_install_security.xml | 32 + .../cx_tower_jet_template_security.xml | 85 + .../cx_tower_jet_waypoint_security.xml | 68 + ...x_tower_jet_waypoint_template_security.xml | 68 + .../security/cx_tower_key_security.xml | 99 + .../security/cx_tower_key_value_security.xml | 92 + .../cx_tower_plan_line_action_security.xml | 90 + .../security/cx_tower_plan_line_security.xml | 90 + .../security/cx_tower_plan_log_security.xml | 38 + .../security/cx_tower_plan_security.xml | 90 + .../cx_tower_scheduled_task_cv_security.xml | 74 + .../cx_tower_scheduled_task_security.xml | 78 + .../security/cx_tower_server_log_security.xml | 204 + .../security/cx_tower_server_security.xml | 74 + .../cx_tower_server_template_security.xml | 54 + .../cx_tower_server_wizard_access_rules.xml | 99 + .../security/cx_tower_shortcut_security.xml | 40 + .../security/cx_tower_tag_security.xml | 32 + .../cx_tower_variable_option_security.xml | 52 + .../security/cx_tower_variable_security.xml | 52 + .../cx_tower_variable_value_security.xml | 102 + .../security/ir.model.access.csv | 100 + addons/cetmix_tower_server/ssh/__init__.py | 1 + addons/cetmix_tower_server/ssh/ssh.py | 379 + .../static/demo/img/backup.png | Bin 0 -> 20360 bytes .../static/demo/img/clean.png | Bin 0 -> 7062 bytes .../static/demo/img/docker.png | Bin 0 -> 8898 bytes .../static/demo/img/kubernetes.png | Bin 0 -> 7242 bytes .../static/demo/img/mariadb.png | Bin 0 -> 7903 bytes .../static/demo/img/monitoring.png | Bin 0 -> 4770 bytes .../static/demo/img/nginx.png | Bin 0 -> 2770 bytes .../static/demo/img/odoo.png | Bin 0 -> 6877 bytes .../static/demo/img/owncloud.png | Bin 0 -> 3428 bytes .../static/demo/img/postgres.png | Bin 0 -> 13241 bytes .../static/demo/img/proxmox.png | Bin 0 -> 11483 bytes .../static/demo/img/test.png | Bin 0 -> 6070 bytes .../static/demo/img/tower.png | Bin 0 -> 13947 bytes .../static/demo/img/traefik.png | Bin 0 -> 12397 bytes .../static/demo/img/woocommerce.png | Bin 0 -> 16448 bytes .../static/demo/img/wordpress.png | Bin 0 -> 19748 bytes .../static/description/banner.png | Bin 0 -> 88030 bytes .../static/description/icon.png | Bin 0 -> 22128 bytes .../server_from_template_auto_action.png | Bin 0 -> 77495 bytes .../description/images/server_log_tab.png | Bin 0 -> 94350 bytes .../description/images/server_log_usage_1.png | Bin 0 -> 57281 bytes .../description/images/server_log_usage_2.png | Bin 0 -> 51286 bytes .../description/images/user_profile.png | Bin 0 -> 119757 bytes .../static/description/index.html | 514 ++ .../ace_variables/ace_variables.esm.js | 31 + .../ace_variables/ace_variables.scss | 44 + .../ace_variables/ace_variables.xml | 17 + .../ace_variables/autocomplete_popup.esm.js | 296 + .../ace_variables/autocomplete_popup.scss | 192 + .../ace_variables/autocomplete_popup.xml | 76 + .../ace_variables/code_editor_tower.esm.js | 514 ++ .../ace_variables/code_editor_tower.xml | 16 + .../server_status/server_status_field.esm.js | 34 + .../server_status/server_status_field.scss | 33 + .../static/src/utils/server_utils.esm.js | 17 + addons/cetmix_tower_server/tests/__init__.py | 42 + addons/cetmix_tower_server/tests/common.py | 515 ++ .../cetmix_tower_server/tests/common_jets.py | 732 ++ .../tests/test_cetmix_tower.py | 244 + .../cetmix_tower_server/tests/test_command.py | 1964 ++++ .../tests/test_command_log.py | 282 + .../tests/test_command_wizard.py | 572 ++ addons/cetmix_tower_server/tests/test_file.py | 482 + .../tests/test_file_template.py | 234 + addons/cetmix_tower_server/tests/test_jet.py | 1750 ++++ .../tests/test_jet_access.py | 442 + .../tests/test_jet_action_access.py | 647 ++ .../tests/test_jet_create_wizard.py | 81 + .../tests/test_jet_dependency_access.py | 420 + .../tests/test_jet_state.py | 522 ++ .../tests/test_jet_template.py | 3226 +++++++ .../tests/test_jet_template_access.py | 551 ++ .../test_jet_template_dependency_access.py | 195 + .../tests/test_jet_template_install.py | 1777 ++++ .../tests/test_jet_template_install_access.py | 387 + .../test_jet_template_install_line_access.py | 492 ++ .../tests/test_jet_waypoint.py | 1995 +++++ .../tests/test_jet_waypoint_access.py | 970 ++ .../test_jet_waypoint_template_access.py | 504 ++ addons/cetmix_tower_server/tests/test_key.py | 919 ++ .../tests/test_partner_server_btn.py | 58 + addons/cetmix_tower_server/tests/test_plan.py | 2899 ++++++ .../tests/test_plan_line.py | 540 ++ .../tests/test_plan_line_action.py | 255 + .../tests/test_plan_log.py | 274 + .../tests/test_reference_mixin.py | 310 + .../tests/test_scheduled_task.py | 893 ++ .../cetmix_tower_server/tests/test_server.py | 890 ++ .../tests/test_server_jet_action_command.py | 231 + .../tests/test_server_log.py | 657 ++ .../tests/test_server_template.py | 1073 +++ .../tests/test_shortcut.py | 244 + addons/cetmix_tower_server/tests/test_tag.py | 91 + .../tests/test_tag_mixin.py | 167 + .../cetmix_tower_server/tests/test_tools.py | 38 + .../test_update_related_variable_names.py | 204 + .../tests/test_variable.py | 1189 +++ .../tests/test_variable_option.py | 285 + .../tests/test_variable_value.py | 952 ++ .../tests/test_vault_mixin.py | 534 ++ .../views/cx_tower_command_log_view.xml | 211 + .../views/cx_tower_command_view.xml | 342 + .../views/cx_tower_file_template_view.xml | 163 + .../views/cx_tower_file_view.xml | 307 + .../views/cx_tower_jet_action_view.xml | 41 + .../views/cx_tower_jet_request_view.xml | 91 + .../views/cx_tower_jet_state_view.xml | 87 + .../cx_tower_jet_template_install_view.xml | 176 + .../views/cx_tower_jet_template_view.xml | 583 ++ .../views/cx_tower_jet_view.xml | 647 ++ .../cx_tower_jet_waypoint_template_view.xml | 110 + .../views/cx_tower_jet_waypoint_view.xml | 134 + .../views/cx_tower_key_view.xml | 151 + .../views/cx_tower_os_view.xml | 63 + .../views/cx_tower_plan_line_view.xml | 169 + .../cx_tower_plan_line_view_action_view.xml | 70 + .../views/cx_tower_plan_log_view.xml | 227 + .../views/cx_tower_plan_view.xml | 212 + .../views/cx_tower_scheduled_task_view.xml | 267 + .../views/cx_tower_server_log_view.xml | 66 + .../views/cx_tower_server_template_view.xml | 356 + .../views/cx_tower_server_view.xml | 604 ++ .../views/cx_tower_shortcut_view.xml | 113 + .../views/cx_tower_tag_view.xml | 63 + .../views/cx_tower_variable_value_view.xml | 84 + .../views/cx_tower_variable_view.xml | 263 + .../cetmix_tower_server/views/menuitems.xml | 263 + .../views/res_config_settings.xml | 113 + .../views/res_partner_view.xml | 82 + .../cetmix_tower_server/wizards/__init__.py | 9 + .../wizards/cx_tower_command_run_wizard.py | 564 ++ .../cx_tower_command_run_wizard_view.xml | 213 + .../wizards/cx_tower_jet_action_wizard.py | 59 + .../cx_tower_jet_action_wizard_view.xml | 44 + .../wizards/cx_tower_jet_clone_wizard.py | 140 + .../cx_tower_jet_clone_wizard_view.xml | 111 + .../wizards/cx_tower_jet_create_wizard.py | 206 + .../cx_tower_jet_create_wizard_view.xml | 132 + .../wizards/cx_tower_jet_state_wizard.py | 79 + .../cx_tower_jet_state_wizard_view.xml | 51 + .../cx_tower_jet_template_install_wizard.py | 72 + ...tower_jet_template_install_wizard_view.xml | 49 + .../wizards/cx_tower_plan_run_wizard.py | 178 + .../wizards/cx_tower_plan_run_wizard_view.xml | 112 + .../cx_tower_server_host_key_wizard.py | 30 + .../cx_tower_server_host_key_wizard_view.xml | 35 + .../cx_tower_server_template_create_wizard.py | 247 + ...wer_server_template_create_wizard_view.xml | 94 + 235 files changed, 89704 insertions(+) create mode 100644 addons/cetmix_tower_server/README.rst create mode 100644 addons/cetmix_tower_server/__init__.py create mode 100644 addons/cetmix_tower_server/__manifest__.py create mode 100644 addons/cetmix_tower_server/data/cx_tower_jet_state.xml create mode 100644 addons/cetmix_tower_server/data/ir_config_parameter.xml create mode 100644 addons/cetmix_tower_server/data/ir_cron.xml create mode 100644 addons/cetmix_tower_server/data/neutralize.sql create mode 100644 addons/cetmix_tower_server/demo/demo_data.xml create mode 100644 addons/cetmix_tower_server/demo/demo_jets.xml create mode 100644 addons/cetmix_tower_server/i18n/cetmix_tower_server.pot create mode 100644 addons/cetmix_tower_server/i18n/de.po create mode 100644 addons/cetmix_tower_server/i18n/fi.po create mode 100644 addons/cetmix_tower_server/i18n/hr.po create mode 100644 addons/cetmix_tower_server/i18n/it.po create mode 100644 addons/cetmix_tower_server/migrations/18.0.3.0.0/pre-migration.py create mode 100644 addons/cetmix_tower_server/models/__init__.py create mode 100644 addons/cetmix_tower_server/models/cetmix_tower.py create mode 100644 addons/cetmix_tower_server/models/constants.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_access_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_access_role_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_command.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_command_log.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_custom_variable_value_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_file.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_file_template.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_action.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_dependency.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_request.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_state.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_template.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_template_install.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_template_install_line.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_waypoint_template.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_key.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_key_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_key_value.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_metadata_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_os.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_plan.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_plan_line.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_plan_line_action.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_plan_log.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_reference_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_scheduled_task.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_scheduled_task_cv.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_server.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_server_log.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_server_template.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_shortcut.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_tag.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_tag_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_template_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_variable.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_variable_mixin.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_variable_option.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_variable_value.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_vault.py create mode 100644 addons/cetmix_tower_server/models/cx_tower_vault_mixin.py create mode 100644 addons/cetmix_tower_server/models/ir_actions_server.py create mode 100644 addons/cetmix_tower_server/models/res_config_settings.py create mode 100644 addons/cetmix_tower_server/models/res_partner.py create mode 100644 addons/cetmix_tower_server/models/res_users.py create mode 100644 addons/cetmix_tower_server/models/tools.py create mode 100644 addons/cetmix_tower_server/pyproject.toml create mode 100644 addons/cetmix_tower_server/readme/CONFIGURE.md create mode 100644 addons/cetmix_tower_server/readme/DESCRIPTION.md create mode 100644 addons/cetmix_tower_server/readme/HISTORY.md create mode 100644 addons/cetmix_tower_server/readme/USAGE.md create mode 100644 addons/cetmix_tower_server/readme/diagrams/jets.puml create mode 100644 addons/cetmix_tower_server/readme/newsfragments/.gitkeep create mode 100644 addons/cetmix_tower_server/security/cetmix_tower_server_groups.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_command_log_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_command_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_file_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_file_template_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_action_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_dependency_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_template_dependency_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_template_install_line_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_template_install_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_template_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_waypoint_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_jet_waypoint_template_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_key_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_key_value_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_plan_line_action_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_plan_line_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_plan_log_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_plan_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_scheduled_task_cv_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_scheduled_task_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_server_log_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_server_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_server_template_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_server_wizard_access_rules.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_shortcut_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_tag_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_variable_option_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_variable_security.xml create mode 100644 addons/cetmix_tower_server/security/cx_tower_variable_value_security.xml create mode 100644 addons/cetmix_tower_server/security/ir.model.access.csv create mode 100644 addons/cetmix_tower_server/ssh/__init__.py create mode 100644 addons/cetmix_tower_server/ssh/ssh.py create mode 100644 addons/cetmix_tower_server/static/demo/img/backup.png create mode 100644 addons/cetmix_tower_server/static/demo/img/clean.png create mode 100644 addons/cetmix_tower_server/static/demo/img/docker.png create mode 100644 addons/cetmix_tower_server/static/demo/img/kubernetes.png create mode 100644 addons/cetmix_tower_server/static/demo/img/mariadb.png create mode 100644 addons/cetmix_tower_server/static/demo/img/monitoring.png create mode 100644 addons/cetmix_tower_server/static/demo/img/nginx.png create mode 100644 addons/cetmix_tower_server/static/demo/img/odoo.png create mode 100644 addons/cetmix_tower_server/static/demo/img/owncloud.png create mode 100644 addons/cetmix_tower_server/static/demo/img/postgres.png create mode 100644 addons/cetmix_tower_server/static/demo/img/proxmox.png create mode 100644 addons/cetmix_tower_server/static/demo/img/test.png create mode 100644 addons/cetmix_tower_server/static/demo/img/tower.png create mode 100644 addons/cetmix_tower_server/static/demo/img/traefik.png create mode 100644 addons/cetmix_tower_server/static/demo/img/woocommerce.png create mode 100644 addons/cetmix_tower_server/static/demo/img/wordpress.png create mode 100644 addons/cetmix_tower_server/static/description/banner.png create mode 100644 addons/cetmix_tower_server/static/description/icon.png create mode 100644 addons/cetmix_tower_server/static/description/images/server_from_template_auto_action.png create mode 100644 addons/cetmix_tower_server/static/description/images/server_log_tab.png create mode 100644 addons/cetmix_tower_server/static/description/images/server_log_usage_1.png create mode 100644 addons/cetmix_tower_server/static/description/images/server_log_usage_2.png create mode 100644 addons/cetmix_tower_server/static/description/images/user_profile.png create mode 100644 addons/cetmix_tower_server/static/description/index.html create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.scss create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.xml create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.esm.js create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.scss create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.esm.js create mode 100644 addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.xml create mode 100644 addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js create mode 100644 addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss create mode 100644 addons/cetmix_tower_server/static/src/utils/server_utils.esm.js create mode 100644 addons/cetmix_tower_server/tests/__init__.py create mode 100644 addons/cetmix_tower_server/tests/common.py create mode 100644 addons/cetmix_tower_server/tests/common_jets.py create mode 100644 addons/cetmix_tower_server/tests/test_cetmix_tower.py create mode 100644 addons/cetmix_tower_server/tests/test_command.py create mode 100644 addons/cetmix_tower_server/tests/test_command_log.py create mode 100644 addons/cetmix_tower_server/tests/test_command_wizard.py create mode 100644 addons/cetmix_tower_server/tests/test_file.py create mode 100644 addons/cetmix_tower_server/tests/test_file_template.py create mode 100644 addons/cetmix_tower_server/tests/test_jet.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_action_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_create_wizard.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_dependency_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_state.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_template.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_template_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_template_install.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_template_install_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_waypoint.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_waypoint_access.py create mode 100644 addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py create mode 100644 addons/cetmix_tower_server/tests/test_key.py create mode 100644 addons/cetmix_tower_server/tests/test_partner_server_btn.py create mode 100644 addons/cetmix_tower_server/tests/test_plan.py create mode 100644 addons/cetmix_tower_server/tests/test_plan_line.py create mode 100644 addons/cetmix_tower_server/tests/test_plan_line_action.py create mode 100644 addons/cetmix_tower_server/tests/test_plan_log.py create mode 100644 addons/cetmix_tower_server/tests/test_reference_mixin.py create mode 100644 addons/cetmix_tower_server/tests/test_scheduled_task.py create mode 100644 addons/cetmix_tower_server/tests/test_server.py create mode 100644 addons/cetmix_tower_server/tests/test_server_jet_action_command.py create mode 100644 addons/cetmix_tower_server/tests/test_server_log.py create mode 100644 addons/cetmix_tower_server/tests/test_server_template.py create mode 100644 addons/cetmix_tower_server/tests/test_shortcut.py create mode 100644 addons/cetmix_tower_server/tests/test_tag.py create mode 100644 addons/cetmix_tower_server/tests/test_tag_mixin.py create mode 100644 addons/cetmix_tower_server/tests/test_tools.py create mode 100644 addons/cetmix_tower_server/tests/test_update_related_variable_names.py create mode 100644 addons/cetmix_tower_server/tests/test_variable.py create mode 100644 addons/cetmix_tower_server/tests/test_variable_option.py create mode 100644 addons/cetmix_tower_server/tests/test_variable_value.py create mode 100644 addons/cetmix_tower_server/tests/test_vault_mixin.py create mode 100644 addons/cetmix_tower_server/views/cx_tower_command_log_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_command_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_file_template_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_file_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_action_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_request_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_state_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_template_install_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_template_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_waypoint_template_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_jet_waypoint_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_key_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_os_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_plan_line_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_plan_line_view_action_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_plan_log_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_plan_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_scheduled_task_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_server_log_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_server_template_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_server_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_shortcut_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_tag_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_variable_value_view.xml create mode 100644 addons/cetmix_tower_server/views/cx_tower_variable_view.xml create mode 100644 addons/cetmix_tower_server/views/menuitems.xml create mode 100644 addons/cetmix_tower_server/views/res_config_settings.xml create mode 100644 addons/cetmix_tower_server/views/res_partner_view.xml create mode 100644 addons/cetmix_tower_server/wizards/__init__.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard_view.xml create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py create mode 100644 addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard_view.xml diff --git a/addons/cetmix_tower_server/README.rst b/addons/cetmix_tower_server/README.rst new file mode 100644 index 0000000..f26142e --- /dev/null +++ b/addons/cetmix_tower_server/README.rst @@ -0,0 +1,143 @@ +=================== +Cetmix Tower Server +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f057dfd35f33f40155780f5855b2d2628821cd120dad4dfcf7028943a6154b47 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github + :target: https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_server + :alt: cetmix/cetmix-tower + +|badge1| |badge2| |badge3| + +`Cetmix Tower `__ offers a streamlined +solution for managing remote servers and applications via SSH or API +calls directly from `Odoo `__. It is designed for +versatility across different operating systems and software +environments, providing a practical option for those looking to manage +servers without getting tied down by vendor or technology constraints. + +Please refer to the `official +documentation `__ for detailed information. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Please refer to the `official +documentation `__ for detailed configuration +instructions. + +Usage +===== + +Please refer to the `official +documentation `__ for detailed usage +instructions. + +Changelog +========= + +18.0.2.0.0 (2026-04-07) +----------------------- + +- Features: Jets! (4700) + +18.0.1.0.11 (2026-03-10) +------------------------ + +- Bugfixes: Last flight plan line post-run action was not triggered + correctly. (5120) + +18.0.1.0.10 (2026-03-10) +------------------------ + +- Features: Improve the 'File using template' command flow, fix the + flight plan line view layout. (5197) + +18.0.1.0.9 (2026-02-19) +----------------------- + +- Features: Blacklist filter for Python commands, value checker for + Vault. (5253) + +18.0.1.0.7 (2026-02-05) +----------------------- + +- Features: Scheduled tasks: allow to select specific days of week. + (5190) + +- Bugfixes: Ensure custom values can be updated even if not provided + initially. (5175) + +18.0.1.0.6 (2026-01-20) +----------------------- + +- Bugfixes: Make pre-defined messages and command help translatable + again. (5174) + +18.0.1.0.4 (2025-12-23) +----------------------- + +- Bugfixes: Handle malformed expressions in flight plan line conditions. + (5154) + +18.0.1.0.3 (2025-12-17) +----------------------- + +- Features: Parse empty or missing key values as 'None' instead of + leaving key reference as is. (5134) + +- Features: Improve search views, implement the search panel for + selected views. (5139) + +- Bugfixes: Custom values in flight plan are lost in a skipped command + and are not available after it. (5129) + +18.0.1.0.2 (2025-12-08) +----------------------- + +- Bugfixes: Make variables selectable in scheduled tasks (5105) +- Bugfixes: Save correct error message in log when SSH connection fails. + (5109) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Cetmix + +Maintainers +----------- + +This module is part of the `cetmix/cetmix-tower `_ project on GitHub. + +You are welcome to contribute. diff --git a/addons/cetmix_tower_server/__init__.py b/addons/cetmix_tower_server/__init__.py new file mode 100644 index 0000000..d8b99ed --- /dev/null +++ b/addons/cetmix_tower_server/__init__.py @@ -0,0 +1,4 @@ +# pylint: disable=E8103 + +from . import models +from . import wizards diff --git a/addons/cetmix_tower_server/__manifest__.py b/addons/cetmix_tower_server/__manifest__.py new file mode 100644 index 0000000..db65813 --- /dev/null +++ b/addons/cetmix_tower_server/__manifest__.py @@ -0,0 +1,110 @@ +# Copyright Cetmix OÜ 2022 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Cetmix Tower Server", + "summary": "Manage servers and applications from Odoo", + "version": "18.0.2.0.0", + "category": "Productivity", + "website": "https://tower.cetmix.com", + "live_test_url": "https://tower.cetmix.com/download", + "images": ["static/description/banner.png"], + "author": "Cetmix", + "license": "AGPL-3", + "application": False, + "installable": True, + "external_dependencies": { + "python": ["paramiko<4.0.0", "tldextract", "dnspython", "ansi2html"], + }, + "depends": [ + "mail", + "rpc_helper", + "web_notify", + "cx_web_refresh_from_backend", + ], + "data": [ + "security/cetmix_tower_server_groups.xml", + "security/ir.model.access.csv", + "security/cx_tower_server_security.xml", + "security/cx_tower_command_security.xml", + "security/cx_tower_plan_security.xml", + "security/cx_tower_plan_line_security.xml", + "security/cx_tower_plan_line_action_security.xml", + "security/cx_tower_plan_log_security.xml", + "security/cx_tower_server_log_security.xml", + "security/cx_tower_command_log_security.xml", + "security/cx_tower_server_template_security.xml", + "security/cx_tower_jet_template_security.xml", + "security/cx_tower_jet_template_dependency_security.xml", + "security/cx_tower_jet_template_install_security.xml", + "security/cx_tower_jet_template_install_line_security.xml", + "security/cx_tower_jet_waypoint_template_security.xml", + "security/cx_tower_jet_waypoint_security.xml", + "security/cx_tower_jet_dependency_security.xml", + "security/cx_tower_jet_security.xml", + "security/cx_tower_jet_action_security.xml", + "security/cx_tower_file_security.xml", + "security/cx_tower_file_template_security.xml", + "security/cx_tower_variable_security.xml", + "security/cx_tower_variable_value_security.xml", + "security/cx_tower_variable_option_security.xml", + "security/cx_tower_scheduled_task_security.xml", + "security/cx_tower_scheduled_task_cv_security.xml", + "security/cx_tower_key_security.xml", + "security/cx_tower_key_value_security.xml", + "security/cx_tower_tag_security.xml", + "security/cx_tower_shortcut_security.xml", + "security/cx_tower_server_wizard_access_rules.xml", + "data/ir_cron.xml", + "data/ir_config_parameter.xml", + "data/cx_tower_jet_state.xml", + "wizards/cx_tower_command_run_wizard_view.xml", + "wizards/cx_tower_plan_run_wizard_view.xml", + "wizards/cx_tower_server_template_create_wizard_view.xml", + "wizards/cx_tower_server_host_key_wizard_view.xml", + "wizards/cx_tower_jet_template_install_wizard_view.xml", + "wizards/cx_tower_jet_state_wizard_view.xml", + "wizards/cx_tower_jet_action_wizard_view.xml", + "wizards/cx_tower_jet_create_wizard_view.xml", + "wizards/cx_tower_jet_clone_wizard_view.xml", + "views/cx_tower_server_view.xml", + "views/cx_tower_os_view.xml", + "views/cx_tower_tag_view.xml", + "views/cx_tower_variable_view.xml", + "views/cx_tower_variable_value_view.xml", + "views/cx_tower_command_view.xml", + "views/cx_tower_plan_view.xml", + "views/cx_tower_plan_line_view.xml", + "views/cx_tower_plan_line_view_action_view.xml", + "views/cx_tower_command_log_view.xml", + "views/cx_tower_plan_log_view.xml", + "views/cx_tower_key_view.xml", + "views/cx_tower_file_view.xml", + "views/cx_tower_file_template_view.xml", + "views/cx_tower_server_log_view.xml", + "views/cx_tower_server_template_view.xml", + "views/cx_tower_shortcut_view.xml", + "views/cx_tower_scheduled_task_view.xml", + "views/res_partner_view.xml", + "views/res_config_settings.xml", + "views/cx_tower_jet_view.xml", + "views/cx_tower_jet_action_view.xml", + "views/cx_tower_jet_state_view.xml", + "views/cx_tower_jet_template_view.xml", + "views/cx_tower_jet_template_install_view.xml", + "views/cx_tower_jet_request_view.xml", + "views/cx_tower_jet_waypoint_template_view.xml", + "views/cx_tower_jet_waypoint_view.xml", + "views/menuitems.xml", + ], + "demo": [ + "demo/demo_data.xml", + "demo/demo_jets.xml", + ], + "assets": { + "web.assets_backend": [ + "cetmix_tower_server/static/src/components/**/*.xml", + "cetmix_tower_server/static/src/**/*.js", + "cetmix_tower_server/static/src/**/*.scss", + ], + }, +} diff --git a/addons/cetmix_tower_server/data/cx_tower_jet_state.xml b/addons/cetmix_tower_server/data/cx_tower_jet_state.xml new file mode 100644 index 0000000..fe8f1bf --- /dev/null +++ b/addons/cetmix_tower_server/data/cx_tower_jet_state.xml @@ -0,0 +1,68 @@ + + + Preparing + 1 + 4 + Jet is being prepared + + + Draft + 2 + 4 + Jet is in draft state + + + Building + 3 + 4 + Jet is being built + + + Starting + 4 + 3 + Jet is being started + + + Running + 5 + 10 + Jet is running + + + Stopping + 6 + 1 + Jet is being stopped + + + Stopped + 7 + 9 + Jet is stopped and ready to be started + + + Restarting + 8 + 6 + Jet is being restarted + + + Removing + 9 + 7 + Jet is being removed + + + Removed + 10 + 7 + Jet has been removed + + + Destroying + 11 + 8 + Jet is being destroyed + + diff --git a/addons/cetmix_tower_server/data/ir_config_parameter.xml b/addons/cetmix_tower_server/data/ir_config_parameter.xml new file mode 100644 index 0000000..4a0e985 --- /dev/null +++ b/addons/cetmix_tower_server/data/ir_config_parameter.xml @@ -0,0 +1,20 @@ + + + cetmix_tower_server.command_timeout + 900 + + + cetmix_tower_server.notification_type_success + sticky + + + cetmix_tower_server.notification_type_error + sticky + + diff --git a/addons/cetmix_tower_server/data/ir_cron.xml b/addons/cetmix_tower_server/data/ir_cron.xml new file mode 100644 index 0000000..64cbf8f --- /dev/null +++ b/addons/cetmix_tower_server/data/ir_cron.xml @@ -0,0 +1,35 @@ + + + + + Cetmix Tower: Check zombie commands + + code + model._check_zombie_commands() + + 15 + minutes + + + + + Cetmix Tower: Auto pull files from server + + code + model._run_auto_pull_files() + + 1 + days + + + + + Cetmix Tower: Run scheduled tasks + + code + model._run_scheduled_tasks() + + 5 + minutes + + diff --git a/addons/cetmix_tower_server/data/neutralize.sql b/addons/cetmix_tower_server/data/neutralize.sql new file mode 100644 index 0000000..e58ff8a --- /dev/null +++ b/addons/cetmix_tower_server/data/neutralize.sql @@ -0,0 +1,3 @@ +-- deactivate scheduled tasks +UPDATE cx_tower_scheduled_task + SET active = false; diff --git a/addons/cetmix_tower_server/demo/demo_data.xml b/addons/cetmix_tower_server/demo/demo_data.xml new file mode 100644 index 0000000..f0a0cbd --- /dev/null +++ b/addons/cetmix_tower_server/demo/demo_data.xml @@ -0,0 +1,1427 @@ + + + + + + + + + + + + + Debian 10 + 1 + + + + Debian 11 + 1 + + + + Debian 12 + 1 + + + Ubuntu 20.04 + 2 + + + + Ubuntu 22.04 + 2 + + + + Ubuntu 24.04 + 2 + + + + + Staging + 1 + + + Production + 2 + + + Custom + 3 + + + + + Demo Key 1 SSH + k + Such Much SSH + + + Demo Key 2 Secret + s + Wow! Such much secret! + + + + + Demo Server #1 + 1 + stopped + localhost + admin + password + p + demohostkey + + demo1.example.com + + + + + This server is used in unit tests. + No variables are defined. + + + + 2 + Demo Server #2 + running + localhost + admin + password + k + p + True + + + https://dogememe.example.com + + + + This server has variables configured + + + cx.tower.server + + + + + + + Demo File Template 1 + tower_demo_1.txt + {{ demo_path }} + Hello, world! + + + + Demo File Template 2 + {{ demo_branch }}_tower_demo_2.txt + /tmp + Hello, world! + + + + Demo File Template 3 + tower_demo_3.txt + /tmp + How to create a directory: cd {{ demo_path }} && mkdir {{ demo_dir }} + + + + Demo File Template 4 + server + server_demo_logs.txt + /var/log + + + + + + tower_demo_1.txt + tower + + + + + tower_demo_2.txt + tower + + + + + tower_demo_3.txt + tower + + + + + server_demo_logs.txt + server + + + + + tower_demo_without_template_{{ demo_branch }}.txt + tower + + {{ demo_path }} + Please, check url: {{ demo_url }} + + + server_demo.txt + server + + {{ demo_path }} + + + + + Demo Path + demo_path + + + Demo Directory + demo_dir + + + Demo Operating System + demo_os + + + Demo URL + demo_url + + + Demo Odoo Version + demo_odoo_version + o + + + Demo Language + demo_language + o + + + Demo Version + demo_version + + + Demo Organisation + demo_org + + + Demo Branch + demo_branch + ^[a-z0-9]+$ + Must be lowercase and contain only letters and numbers! + + + Command Error + command_error + s + + + + + + 14.0 + 14.0 + 10 + + + + 15.0 + 15.0 + 20 + + + + 16.0 + 16.0 + 30 + + + + 17.0 + 17.0 + 40 + + + + 18.0 + 18.0 + 50 + + + + + + English (US) + en_us + 10 + + + + Italian + it + 20 + + + + Spanish (Mexican) + es_mx + 30 + + + + German + de + 40 + + + + German (Switzerland) + de_ch + 50 + + + + + + + /home/{{ tower.server.username }}/tower/{{ demo_branch }} + + + + + /tmp/repo/{{ demo_branch }} + + + + + + + + + + + + + + + + + /opt/{{ tower.server.reference }}/cetmix-tower + + + + + /opt/{{ tower.server.reference }}/cetmix-tower + + + + + https://cetmix.com + + + + + staging + + + + + + + + + + + + + + + + prod + + + + Cetmix + + + + + + Demo Flight Plan #1 + Create directory and list its content + + 1 + + + + + + Demo Flight Plan #2 + Run another flight plan + + + + + Demo Flight Plan for User + Demo of a user-accessible flight plan + + 1 + + + + + Demo Flight Plan #4 + Update and upgrade packages + + + + + + Demo Flight Plan #5 + Check branch and download log file + + + + + + Test skip command error + 2 + e + 0 + +This plan is used to test the skip command error flow. +Expected result: +- Command 4 skipped +- Command 5 is run +- Plan finishes with error + + + + + + + Update packages + ssh_command + demo_update_upgrade + + apt-get update && apt-get upgrade -y + + + Update and upgrade packages on the host system + 1 + + + + + Create directory + ssh_command + + /home/{{ tower.server.username }} + mkdir -p {{ demo_dir }} + + Create a directory on the host system + 1 + + + + + List files in directory + ssh_command + /home/{{ tower.server.username }} + ls -l + 1 + + + List files in the directory + + + + + Upload file by template + file_using_template + /home/{{ tower.server.username }} + + + Upload a file to the host system + + + + + Run Python Code: Check Branch + python_code + +if {{ demo_branch }}: + result={"exit_code": 0, "message": "Branch is defined!"} +else: + result={"exit_code": -100, "message": "Branch is not defined!"} + + 1 + + Run Python Code: Check Branch + + + + + Download log file by template + file_using_template + /home/{{ tower.server.username }} + + + Download log file by template + + + + + Run Demo #1 Flight Plan + plan + + + + + + + Command -> Success + python_code + # Just return default values + 2 + + + + + Command -> Error + python_code + result = {"exit_code": -100, "message": "Error"} + 2 + + + + + Command -> After failed + python_code + # Update the server name +name = server.name + " --after-failed-- " +server.write({"name":name}) + 2 + + + + + Command -> The last one + python_code + # Update the server name +name = server.name + " --last-one-- " +server.write({"name":name}) + 2 + + + + + Install Odoo + python_code + #ok + 2 + + + + Uninstall Odoo + python_code + #ok + 2 + + + + Install WordPress + python_code + #ok + 2 + + + + Uninstall WordPress + python_code + #ok + 2 + + + + Install WooCommerce + python_code + #ok + 2 + + + + Uninstall WooCommerce + python_code + #ok + 2 + + + + + Ensure Jet is Running + python_code + # Ensure jet is in running state +result={"exit_code": 0, "message": "Jet is running"} + + 2 + + + + Clone Jet Same Server + python_code + # Clone jet on the same server +result={"exit_code": 0, "message": "Jet cloned successfully"} + + 2 + + + + Clone Jet Different Server + python_code + # Clone jet to a different server +result={"exit_code": 0, "message": "Jet cloned to different server successfully"} + + 2 + + + + + 5 + + + {{ demo_path }} + + + + + + 1 + == + 0 + + + + 2 + + > + + 0 + ec + 255 + + + + + + + production + + + + + + + + + + 20 + + + {{ tower.server.status }} == 'running' and {{ demo_odoo_version }} == "17.0" + + + + + + 1 + == + -1 + ec + 100 + + + + 2 + >= + 3 + n + + + + + 30 + + + + + + + 5 + + + + + + + 1 + + + {{ demo_path }} + + + + + 2 + + + {{ demo_path }} + + + + + 10 + + + + + + + 10 + + + + + + + 20 + + + + + + + 10 + + + + + 11 + + + + + 12 + + + + + 13 + + + not {{ command_error }} + + + + 14 + + + {{ command_error }} + + + + + + + 10 + != + 0 + n + 0 + + + + 10 + == + 0 + ec + -1 + + + + + + + 10 + 1 + + + + + Install Odoo + Installation flight plan for Odoo + 2 + + + + 10 + + + + + + Uninstall Odoo + Uninstallation flight plan for Odoo + 2 + + + + 10 + + + + + + Install WordPress + Installation flight plan for WordPress + 2 + + + + 10 + + + + + + Uninstall WordPress + Uninstallation flight plan for WordPress + 2 + + + + 10 + + + + + + Install WooCommerce + Installation flight plan for WooCommerce + 2 + + + + 10 + + + + + + Uninstall WooCommerce + Uninstallation flight plan for WooCommerce + 2 + + + + 10 + + + + + + + + Clone Odoo Same Server + Clone Odoo jet on the same server, ensuring it's running + 2 + + + + 10 + + + + + + 20 + + + + + + Clone Odoo Different Server + Clone Odoo jet to a different server, ensuring it's running + 2 + + + + 10 + + + + + + 20 + + + + + + + Clone WordPress Same Server + Clone WordPress jet on the same server, ensuring it's running + 2 + + + + 10 + + + + + + 20 + + + + + + Clone WordPress Different Server + Clone WordPress jet to a different server, ensuring it's running + 2 + + + + 10 + + + + + + 20 + + + + + + + Clone WooCommerce Same Server + Clone WooCommerce jet on the same server, ensuring it's running + 2 + + + + 10 + + + + + + 20 + + + + + + Clone WooCommerce Different Server + Clone WooCommerce jet to a different server, ensuring it's running + 2 + + + + 10 + + + + + + 20 + + + + + + + tower_demo_1.txt + server + + + + + tower_demo_2.txt + server + + + + + + + Log from file + + file + + + + Log from file + + file + + + + + + Demo Server Template #1 + 1 + admin + password + p + + + + + + Log from file + + file + + + + + + + + /opt/{{ tower.server.reference }}/cetmix-tower/{{ demo_branch }} + + + + + https://cetmix.com + + + + + 1 + + + + + + 1 + + + + + Command Log for Server #1 + + command + + + + Command Log for Server #2 + + command + + + + Command Log for Server Template #1 + + command + + + + + + Demo Flight Plan Execution Status + demo_flight_plan_status_unique + + + Demo Flight Plan Start Time Unique + demo_flight_plan_start_time_unique + + + Demo Flight Plan End Time Unique + demo_flight_plan_end_time_unique + + + + + completed + + + + initial_value + + + + final_value + + + + + + + completed + + + + + initial_value + + + + + final_value + + + + + + + completed + + + + + initial_value + + + + + final_value + + + + + Command Shortcut + command + + + 1 + Runs a command. Use as an example to create your own shortcuts. + + + Flight Plan Shortcut + plan + + + 2 + Runs a flight plan. Use as an example to create your own shortcuts. + + + Flight Plan Shortcut for Server Template + plan + + + 2 + Runs a flight plan for a server template. Use as an example to create your own shortcuts. + + + + + Scheduled Task Demo #1 + scheduled_task_demo_1 + False + 1 + + + command + + 1 + hours + + + + Scheduled Task Demo #2 + scheduled_task_demo_2 + False + 2 + + plan + + 2 + days + + + + + + Odoo Scheduled Task + scheduled_task_odoo + False + 10 + command + + 6 + hours + + + + + WordPress Scheduled Task + scheduled_task_wordpress + False + 11 + plan + + 12 + hours + + + + + WooCommerce Scheduled Task + scheduled_task_woocommerce + False + 12 + command + + 1 + days + + + diff --git a/addons/cetmix_tower_server/demo/demo_jets.xml b/addons/cetmix_tower_server/demo/demo_jets.xml new file mode 100644 index 0000000..17a2231 --- /dev/null +++ b/addons/cetmix_tower_server/demo/demo_jets.xml @@ -0,0 +1,2172 @@ + + + + + + + Test Jet Template Demo + test_jet_template_demo + + + + + + Tower Core Demo + tower_core_demo + + + + + + + Docker Demo + docker_demo + + + + + + + + Nginx Demo + nginx_demo + + + + + + Postgres Demo + postgres_demo + + + + + + MariaDB Demo + mariadb_demo + + + + + + + Odoo Demo + odoo_demo + + + + + + + + + + + + + + + WordPress Demo + wordpress_demo + + + + + + + + + + + + + + + WooCommerce with Odoo Demo + woocommerce_odoo_demo + + + + + + + + + + + + + + + + + + Build + odoo_build_demo + + 10 + + + + + + + + Snapshot + odoo_snapshot_demo + + 20 + + + + + + + + + Build + woocommerce_build_demo + + 10 + + + + + + + + Snapshot + woocommerce_snapshot_demo + + 20 + + + + + + + + + Monitoring Demo + monitoring_demo + + + + + + Backup Demo + backup_demo + + + + + + Clean Template Demo + clean_template_demo + + + + + + Kubernetes Demo + kubernetes_demo + + + + + + + Proxmox Demo + proxmox_demo + + + + + + + OwnCloud Demo + owncloud_demo + + + + + + + Traefik Demo + traefik_demo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Stop Action Demo + stop_action_demo + + + + + 10 + + + + + Start Action Demo + start_action_demo + + + + + 10 + + + + + Error Action Demo + error_action_demo + + + + + 20 + + + + + Recover Action Demo + recover_action_demo + + + + + 10 + + + + + Initialize Action Demo + initialize_action_demo + + + + + 5 + + + + + Action A to B Demo + action_a_to_b_demo + + + + + 10 + + + + + Action B to C Demo + action_b_to_c_demo + + + + + 10 + + + + + Action C to D Demo + action_c_to_d_demo + + + + + 10 + + + + + Action A to C (direct) Demo + action_a_to_c_demo + + + + + 10 + + + + + Create Action Demo + create_action_demo + + + + 1 + + + + + Destroy Action Demo + destroy_action_demo + + + + 1 + + + + + + + Create Tower Core Demo + create_tower_core_demo + + + + 1 + + + + Start Tower Core Demo + start_tower_core_demo + + + + + 10 + + + + Stop Tower Core Demo + stop_tower_core_demo + + + + + 10 + + + + Restart Tower Core Demo + restart_tower_core_demo + + + + + 15 + + + + + Create Docker Demo + create_docker_demo + + + + 1 + + + + Start Docker Demo + start_docker_demo + + + + + 10 + + + + Stop Docker Demo + stop_docker_demo + + + + + 10 + + + + Restart Docker Demo + restart_docker_demo + + + + + 15 + + + + + Create Nginx Demo + create_nginx_demo + + + + 1 + + + + Start Nginx Demo + start_nginx_demo + + + + + 10 + + + + Stop Nginx Demo + stop_nginx_demo + + + + + 10 + + + + Restart Nginx Demo + restart_nginx_demo + + + + + 15 + + + + + Create Postgres Demo + create_postgres_demo + + + + 1 + + + + Start Postgres Demo + start_postgres_demo + + + + + 10 + + + + Stop Postgres Demo + stop_postgres_demo + + + + + 10 + + + + Restart Postgres Demo + restart_postgres_demo + + + + + 15 + + + + + Create MariaDB Demo + create_mariadb_demo + + + + 1 + + + + Start MariaDB Demo + start_mariadb_demo + + + + + 10 + + + + Stop MariaDB Demo + stop_mariadb_demo + + + + + 10 + + + + Restart MariaDB Demo + restart_mariadb_demo + + + + + 15 + + + + + Create Odoo Demo + create_odoo_demo + + + + 1 + + + + Initialize Odoo Demo + initialize_odoo_demo + + + + + 5 + + + + Start Odoo Demo + start_odoo_demo + + + + + 10 + + + + Stop Odoo Demo + stop_odoo_demo + + + + + 10 + + + + Restart Odoo Demo + restart_odoo_demo + + + + + 15 + + + + + Create WordPress Demo + create_wordpress_demo + + + + 1 + + + + Start WordPress Demo + start_wordpress_demo + + + + + 10 + + + + Stop WordPress Demo + stop_wordpress_demo + + + + + 10 + + + + Restart WordPress Demo + restart_wordpress_demo + + + + + 15 + + + + + Create WooCommerce Demo + create_woocommerce_demo + + + + 1 + + + + Start WooCommerce Demo + start_woocommerce_demo + + + + + 10 + + + + Stop WooCommerce Demo + stop_woocommerce_demo + + + + + 10 + + + + Restart WooCommerce Demo + restart_woocommerce_demo + + + + + 15 + + + + + + + Tower Core Jet Demo + tower_core_jet_demo + + + + + + + + Docker Jet Demo + docker_jet_demo + + + + + + + + + Nginx Jet Demo + nginx_jet_demo + + + + + + + + Postgres Jet Demo + postgres_jet_demo + + + + + + + + MariaDB Jet Demo + mariadb_jet_demo + + + + + + + + + Test Jet Demo + test_jet_demo + + + + + + + + Odoo Jet Demo + odoo_jet_demo + + + + https://odoo.example.com + + + + + + + WordPress Jet Demo + wordpress_jet_demo + + + + https://wordpress.example.com + + + + + + WooCommerce Jet Demo + woocommerce_jet_demo + + + + https://woocommerce.example.com + + + + + + + + Odoo Jet Stopped #1 Demo + odoo_jet_stopped_1_demo + + + + https://odoo-stopped-1.example.com + + + + + + Odoo Jet Stopped #2 Demo + odoo_jet_stopped_2_demo + + + + https://odoo-stopped-2.example.com + + + + + + + WooCommerce Jet Stopped #1 Demo + woocommerce_jet_stopped_1_demo + + + + https://woocommerce-stopped-1.example.com + + + + + + + WooCommerce Jet Stopped #2 Demo + woocommerce_jet_stopped_2_demo + + + + https://woocommerce-stopped-2.example.com + + + + + + + Kubernetes Jet Demo + kubernetes_jet_demo + + + + https://kubernetes.example.com + + + + + + + + Proxmox Jet Demo + proxmox_jet_demo + + + + https://proxmox.example.com + + + + + + + + OwnCloud Jet Demo + owncloud_jet_demo + + + + https://owncloud.example.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Odoo Log from File Demo + + file + + + + + Odoo Command Log Demo + + command + + + + + + WordPress Log from File Demo + + file + + + + + WordPress Command Log Demo + + command + + + + + + WooCommerce Log from File Demo + + file + + + + + WooCommerce Command Log Demo + + command + + + + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + + + + + + install + done + + + + + + + 0 + done + + diff --git a/addons/cetmix_tower_server/i18n/cetmix_tower_server.pot b/addons/cetmix_tower_server/i18n/cetmix_tower_server.pot new file mode 100644 index 0000000..98078d9 --- /dev/null +++ b/addons/cetmix_tower_server/i18n/cetmix_tower_server.pot @@ -0,0 +1,7861 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__source +msgid "" +"\n" +" - Tower: file is pushed from Tower to server.\n" +" - Server: file is pulled from server to Tower.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_user +msgid "" +"\n" +" Basic actions for selected servers.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_manager +msgid "" +"\n" +" Create and modify selected servers.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_root +msgid "" +"\n" +" Full control over all servers.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/constants.py:0 +msgid "" +"\n" +"# Please refer to the 'Help' tab and documentation for more information.\n" +"#\n" +"# You can return command result in the 'result' variable which is a dictionary:\n" +"# result = {\"exit_code\": 0, \"message\": \"Some message\"}\n" +"# default value is {\"exit_code\": 0, \"message\": None}\n" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/constants.py:0 +msgid "" +"\n" +"

Help with Python expressions

\n" +"
\n" +"

\n" +" Each Python code command returns the result value which is a dictionary.\n" +"
There are two keys in the dictionary:\n" +"

    \n" +"
  • exit_code: Integer. Exit code of the command. \"0\" means success, any other value means failure. Default value is \"0\".
  • \n" +"
  • message: String. Message to be logged. Default value is \"None\".
  • \n" +"
\n" +"You can also access the custom_values dictionary that contains custom values provided to the command or flight plan.\n" +"Custom values can be modified, thus can be used to pass data between commands in a flight plan.\n" +"Please keep in mind that custom values are persistent only between commands in a flight plan and are not saved to the database.\n" +"
\n" +"Here is an example of a python code command:\n" +"\n" +"\n" +" server_name = server.name\n" +" build_name = custom_values.get(\"build_name\")\n" +" if build_name:\n" +" result = {\"exit_code\": 0, \"message\": \"Build name for \" + server_name + \" is \" + build_name}\n" +" else:\n" +" result = {\"exit_code\": 0, \"message\": \"No build name provided for \" + server_name}\n" +" custom_values[\"build_name\"] = \"New build name\"\n" +"\n" +"

\n" +"
\n" +"Please refer to the official documentation for more information and examples.\n" +"
\n" +"Various fields may use Python code or Python expressions. The\n" +" following variables can be used:

\n" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +msgid " - Empty values for variables: %(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +msgid " - Missing variables: %(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "%(jet)s: action failed (status %(status)s)" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_reference_mixin.py:0 +msgid "%(name)s (copy %(number)s)" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_reference_mixin.py:0 +msgid "%(name)s (copy)" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "%(timestamp)s
%(action)s completed on server '%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "%(timestamp)s
%(action)s failed on server '%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "%(timestamp)s
Available in the '%(name)s' state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command_log.py:0 +msgid "%(timestamp)s
Command '%(name)s' finished successfully" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command_log.py:0 +msgid "%(timestamp)s
Command '%(name)s' finished with error" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +msgid "%(timestamp)s
Flight Plan '%(name)s' finished successfully" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +msgid "%(timestamp)s
Flight Plan '%(name)s' finished with error" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "%(timestamp)s
Installing template on server '%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "" +"%(timestamp)s
Template is already installed or being installed on the " +"server '%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "%(timestamp)s
Uninstalling template on server '%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "...no jet is assigned yet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +msgid "...save record to see the final expression or click the line to edit" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "..no jet is assigned yet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "" +"0 if command finished successfully.\n" +"-100 general error,\n" +"-101 not found,\n" +"-201 another instance of this command is running,\n" +"-202 no runner found for the command action,\n" +"-203 Python code execution failed\n" +"-205 plan line condition check failed\n" +"503 if SSH connection error occurred\n" +"601 if queue job failed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_status +msgid "" +"0 if plan is finished successfully. \n" +"-301 if another instance of this flight plan is running, \n" +"-302 if plan is empty, \n" +"-303 if plan reference is missing, \n" +"-304 if plan line reference is missing, \n" +"-306 if plan is not compatible with server,\n" +"-308 if plan is stopped by user" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "AND" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "__original_jet__ The reference of the original jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "__original_server__ The reference of the original server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "" +"__requested_jet_state__ The reference of the requested state of" +" the new jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "" +"\n" +" &nbsp;" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command_log.py:0 +msgid "" +"

Error converting command response to HTML: %(error)s

" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Fr" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Mo" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Sa" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Su" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Th" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Tu" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "We" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "for" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_form +msgid "" +"Flight Plan\n" +" Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "" +"Files will be pulled from server to Tower automatically using cron " +"job." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "" +"Fill in required configuration variables on the “Configuration Values”" +" tab before you can run the command." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Pull files from server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Run scheduled tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Scheduled tasks will be run automatically using cron job." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "IPv4:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "IPv6:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Operating System:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Partner:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Servers:" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "A helper shortcut to env['cx.tower.command']" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "A helper shortcut to env['cx.tower.jet']" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "A helper shortcut to env['cx.tower.jet.waypoint']" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "A helper shortcut to env['cx.tower.plan']" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "A helper shortcut to env['cx.tower.server']" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_dependency.py:0 +msgid "A jet cannot depend on a jet with a different template!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_dependency.py:0 +msgid "A jet cannot depend on itself!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_template_dependency_unique_template_dependency +msgid "A template can only depend on another template once!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py:0 +msgid "A template cannot depend on itself!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_jet_template +msgid "" +"A variable value cannot be assigned multiple times to the same jet template!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_jet +msgid "A variable value cannot be assigned multiple times to the same jet!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_action +msgid "" +"A variable value cannot be assigned multiple times to the same plan line " +"action!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_template +msgid "" +"A variable value cannot be assigned multiple times to the same server " +"template!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Access" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__access_level +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Access Level" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level_warn_msg +msgid "Access Level Warn Msg" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +msgid "Access level is not defined for '%(option)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "Access level is not defined for '%(variable)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js:0 +msgid "Ace Tower Editor" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__action_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__action +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Action" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "" +"Action '%(action)s' is not available for jet '%(jet)s' in state '%(state)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__action_available_ids +msgid "Action Available" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_action.py:0 +msgid "Action can be triggered only for a single jet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Action failed for jet %(jet)s." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__jet_action_id +msgid "Action to trigger" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Action triggered for %(jet_references)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__jet_template_id +msgid "Action will be triggered for all dependent jets of this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Action with an initial state can be triggered only from that state." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "" +"Action without a final state do not change the state. Such actions can be " +"used to destroy a Jet." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "" +"Action without an initial state can be triggered from any state. Such " +"actions can be used to create a new Jet." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action_wizard__action_available_ids +msgid "Actions that are available for all selected jets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "" +"Actions trigger based on command result. If empty next command will be " +"executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__active +msgid "Active" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_ids +msgid "Activities" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "Activity State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_action +msgid "Add a new file" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_template_action +msgid "Add a new file template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.action_cx_cetmix_tower_partner +msgid "Add a new partner" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.action_cx_tower_server +msgid "Add a new server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.action_cx_tower_server_template +msgid "Add a new server template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Add notes here..." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__metadata +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__metadata_text +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__metadata +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__metadata_text +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_metadata_mixin__metadata +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_metadata_mixin__metadata_text +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__metadata +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__metadata_text +msgid "Additional metadata for this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "" +"Additional notes about the variable. \n" +"This field will be displayed in the variable form." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +msgid "All" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Allow Parallel Run" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "An error occurred: %(error)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "Another instance of the command is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__applicability +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__applicability +msgid "Applicability" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__applied_expression +msgid "Applied Expression" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Archived" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_wiz_view_form +msgid "Are you sure you want to install the template on the selected servers?" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_arrive_id +msgid "Arrive Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__arriving +msgid "Arriving" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +msgid "At least one day of week must be selected for the task '%s'." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__auto_sync +msgid "Auto Sync" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync_interval +msgid "Auto Sync Interval" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Auto-generated waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task_cv__value_char +msgid "" +"Automatically populated from selected option. Manual edits will be " +"overwritten when option changes." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_automation_root +msgid "Automation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__action_available_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Available Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__available_state_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Available States" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__action_available_ids +msgid "" +"Available actions for the jet. Click on the button to trigger the action." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__state_available_ids +msgid "" +"Available states for the jet. Click on the button to transition to the " +"state." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file +msgid "Binary Content" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_jet_bring_to_state +msgid "Bring to State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__can_fly_to +msgid "Can Fly To" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_state__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_dependency__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_shortcut__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_view_form +msgid "" +"Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_wiz_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Cancel" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "Cannot %(action)s %(f)s to/from server: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "" +"Cannot change 'global' status for '%(var)s' with value '%(val)s'.\n" +"Try to assigns it to a record instead." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +msgid "" +"Cannot change 'global' status for 'meme' with value 'Pepe'.\n" +"Try to assigns it to a record instead." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot change waypoint type for %(waypoint)s because it is not in the draft " +"state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Cannot create waypoint for jet %s because it is busy" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_tag.py:0 +msgid "" +"Cannot delete tag '%(tag_name)s' because it is used in related records." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot delete the waypoint %(waypoint)s because it is in the %(state)s state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot delete the waypoint %(waypoint)s because it is the current waypoint " +"of the jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot delete waypoint %(waypoint)s because it is currently designated as " +"the destination for jet %(jet)s." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "" +"Cannot download %(f)s from server: Binary content is not supported for " +"'Text' file type" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot fly to waypoint %(waypoint)s on jet %(jet)s because it is not in the " +"'ready' state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot fly to waypoint %(waypoint)s on jet %(jet)s because the previous " +"waypoint %(previous_waypoint)s is not in the 'ready' or 'current' state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot fly to waypoint %(waypoint)s on jet %(jet)s because there is another " +"waypoint %(other_waypoint)s in the 'arriving' or 'leaving' state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot prepare waypoint %(waypoint)s on jet %(jet)s because it is not in the" +" 'draft' state" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "" +"Cannot remove test file using command.\n" +" CODE: %(status)s. ERROR: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "" +"Cannot run command\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Cannot set is_destination to True for waypoint %(waypoint)s because it is in" +" the %(state)s state" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower +#: model:ir.ui.menu,name:cetmix_tower_server.menu_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_form_inherit_cetmix_tower +msgid "Cetmix Tower" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "" +"Cetmix Tower helper class shortcut" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command +msgid "Cetmix Tower Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file +msgid "Cetmix Tower File" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file_template +msgid "Cetmix Tower File Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan +msgid "Cetmix Tower Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line +msgid "Cetmix Tower Flight Plan Line" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line_action +msgid "Cetmix Tower Flight Plan Line Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_log +msgid "Cetmix Tower Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet +msgid "Cetmix Tower Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_action +msgid "Cetmix Tower Jet Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_dependency +msgid "Cetmix Tower Jet Dependency" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_request +msgid "Cetmix Tower Jet Request" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_state +msgid "Cetmix Tower Jet State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_template +msgid "Cetmix Tower Jet Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_template_dependency +msgid "Cetmix Tower Jet Template Dependency" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_waypoint +msgid "Cetmix Tower Jet Waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_waypoint_template +msgid "Cetmix Tower Jet Waypoint Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_mixin +msgid "Cetmix Tower Key/Secret Mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key +msgid "Cetmix Tower Key/Secret Storage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cetmix_tower +msgid "Cetmix Tower Odoo Automation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_os +msgid "Cetmix Tower Operating System" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_command_run_wizard_action +msgid "Cetmix Tower Run Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_plan_run_wizard_action +msgid "Cetmix Tower Run Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_value +msgid "Cetmix Tower Secret Value Storage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server +msgid "Cetmix Tower Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_log +msgid "Cetmix Tower Server Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template +msgid "Cetmix Tower Server Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_shortcut +msgid "Cetmix Tower Shortcut" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__cetmix_tower_show_jet_available_states +msgid "Cetmix Tower Show Jet Available States" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_tag +msgid "Cetmix Tower Tag" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_tag_mixin +msgid "Cetmix Tower Tag Mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable +msgid "Cetmix Tower Variable" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_option +msgid "Cetmix Tower Variable Options" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_value +msgid "Cetmix Tower Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_vault +msgid "Cetmix Tower Vault" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_vault_mixin +msgid "Cetmix Tower Vault Mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_mixin +msgid "Cetmix Tower access mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_role_mixin +msgid "Cetmix Tower access role mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_metadata_mixin +msgid "Cetmix Tower metadata mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_reference_mixin +msgid "Cetmix Tower reference mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_template_mixin +msgid "Cetmix Tower template rendering mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server_ir_actions_server +msgid "Cetmix Tower: Auto pull files from server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_check_zombie_commands_ir_actions_server +msgid "Cetmix Tower: Check zombie commands" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_run_scheduled_tasks_ir_actions_server +msgid "Cetmix Tower: Run scheduled tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "" +"Check the key before inserting in the server settings. Do not insert the key" +" if you have any doubts!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Child plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_jet_clone +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +msgid "Clone" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +msgid "Clone Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_clone_wizard +msgid "Clone jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__jet_cloned_from_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "Cloned from" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Cloning" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Cloning on the same server is not allowed for template '%(template)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "" +"Cloning to a different server is not allowed for template '%(template)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "Close" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Code On Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__template_code +msgid "Code of the associated file template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__color +msgid "Color" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__command_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__action__command +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_shortcut__action__command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_tree +msgid "Command" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +msgid "Command %(command_name)s is not compatible with the server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__command_ids_count +msgid "Command Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__command_domain +msgid "Command Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__command_help +msgid "Command Help" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command_log +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_log_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_log +msgid "Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Command Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_result_html +msgid "Command Result Html" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_config_settings__cetmix_tower_command_timeout +msgid "Command Timeout" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__command_variable_ids +msgid "Command Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command code that was executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__is_running +msgid "Command is being executed right now" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Command is not compatible with the server" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Command log is required for 'Create a Waypoint' commands!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Command log is required for 'Jet Action' commands!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__created_from_command_log_id +msgid "" +"Command log that created this waypoint; the waypoint callback finishes it " +"when the waypoint reaches ready/current or error. Kept for debugging/audit." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Command not found" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__command_id +msgid "" +"Command that will be executed to get the log data.\n" +"Be careful with commands that don't support parallel execution!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/constants.py:0 +msgid "Command timed out and was terminated" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "" +"Command timeout in seconds after which the command will be terminated. Set " +"to 0 to disable timeout." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__command_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__plan_line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__command_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__command_ids +msgid "Commands used in this flight plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_install__line_ids +msgid "Complete list of templates to install/uninstall including dependencies" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__date_done +msgid "Completed on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__condition +msgid "Condition" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__condition +msgid "" +"Conditions under which this Flight Plan Line will be launched. e.g.: {{ " +"odoo_version}} == '14.0'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Configuration" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Configuration Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__line_ids +msgid "Configuration Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Confirm" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Connection failed." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Connection successful." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "" +"Connection test passed! \n" +"%(res)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Connection test passed." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_res_partner +msgid "Contact" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_create_id +msgid "Create Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__action_create_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "Create Jet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Create Server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +msgid "Create from Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_create_wizard +msgid "Create new jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard +msgid "Create new server from template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard_line +msgid "Create new server from template variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_state_action +msgid "Create your first Jet State!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_template_action +msgid "Create your first Jet Template!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_action +msgid "Create your first Jet!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_waypoint_template_action +msgid "Create your first Waypoint Template!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_waypoint_action +msgid "Create your first Waypoint!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__created_from_command_log_id +msgid "Created From" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__create_uid +msgid "Created by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__create_date +msgid "Created on" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/res_config_settings.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Cron Job" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/res_config_settings.py:0 +msgid "Cron job not found" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__current +msgid "Current" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Current Cetmix Tower Jet waypoint this command is running on" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Current Cetmix Tower jet template this command is running on" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Current Cetmix Tower jet this command is running on" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Current Cetmix Tower server this command is running on" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Current Odoo user" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Current Odoo user ID" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__state_id +msgid "Current State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__state +msgid "" +"Current state of the jet. NB: this is the reference of the state, not the " +"name." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__waypoint_id +msgid "Current waypoint of the jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__current_line_id +msgid "Currently Installing" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Custom Exit Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__custom_message +msgid "Custom Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__custom_variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__custom_variable_value_ids +msgid "Custom Variable Value" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__custom_variable_value_ids +msgid "Custom Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Custom label. Can be used for search/tracking" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__custom_message +msgid "Custom message to be displayed in the plan log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Custom message to display when pattern check fails" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +msgid "Custom secret values can be defined only for key type 'secret'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +msgid "" +"Custom values will be available in the current flight plan context only. " +"They can be used in Python commands and line conditions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_custom_variable_value_mixin +msgid "Custom variable values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_run_wizard_variable_value +msgid "Custom variable values for command run wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_run_wizard_variable_value +msgid "Custom variable values for plan run wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_scheduled_task_cv +msgid "Custom variable values for scheduled tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__variable_values +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__variable_values_text +msgid "Custom variable values for this waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__variable_values +msgid "Custom variable values passed to the command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__variable_values +msgid "Custom variable values passed to the flight plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Date and time of the latest successful synchronisation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Date and time of the next synchronisation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__days +msgid "Days" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__dow +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Days of Week" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__path +msgid "Default Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "Default full file name with file type for example: test.txt" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__template_requires_ids +msgid "" +"Define other templates that must be in specific states for this template to " +"function" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__template_required_by_ids +msgid "" +"Define other templates that require this template to be in a specific state " +"to function" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__deletable +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "Deletable" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Delete" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_delete_id +msgid "Delete Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_delete_action +msgid "Delete from server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Delete the Waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__deleted +msgid "Deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__deleting +msgid "Deleting" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Dependencies" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__dependency_graph_image +msgid "Dependency Graph" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__dependency_graph_svg +msgid "Dependency Graph Svg" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_request__for_dependency_id +msgid "Dependency for which request is created" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_dependency__jet_depends_on_id +msgid "Depends On" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Depends on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__state_to_id +msgid "Destination state for this transition. Leave blank for a final state" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__target_state_id +msgid "Destination state to which the jet is currently transitioning" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__action_destroy_id +msgid "Destroy Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Different Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Directory on Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__server_dir +msgid "Directory on server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__disconnect_file +msgid "Disconnect from Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_dependency__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__display_name +msgid "Display Name" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Do you want to delete this waypoint? This action cannot be undone." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Do you want to fly to this waypoint?" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Do you want to prepare this waypoint?" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Do you want to set this state?" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Do you want to trigger this action?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__jet_template_domain +msgid "Domain for jet template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__server_domain +msgid "Domain for server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__skip_host_key +msgid "Don't Check Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install__state__done +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install_line__state__done +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +msgid "Done" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_download_action +msgid "Download" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__draft +msgid "Draft" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "Due to security restrictions you are not allowed to delete %(fp)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Duration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "Duration, sec" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_vault_vault_unique_key +msgid "Each secret (model, record, field) must be unique in the vault." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Eg '/home/user' or '/var/log'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_request.py:0 +msgid "Either a jet or a jet template must be provided to create a request" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__skip_host_key +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__skip_host_key +msgid "Enable to skip host key verification" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Enter Python code here. Help about Python expression is available in the " +"help tab of this document." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_error +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__error +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_config_settings__cetmix_tower_notification_type_error +msgid "Error Notifications" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__state_error_id +msgid "Error State" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Error retrieving host key: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "" +"Example:\n" +"
\n" +" \n" +" result = value.lower().strip().replace('_', '-').replace(\" \",\"\") if value.startswith('http') else 'https://' + re.sub(r'\\s+', '', value)\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__current_action_id +msgid "Executing Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__current_command_log_id +msgid "Executing Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Execution Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__e +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__e +msgid "Exit with command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__ec +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__ec +msgid "Exit with custom exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_request__state__failed +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install__state__failed +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install_line__state__failed +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "Failed" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Failed to connect after %(attempts)s attempts. Error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Failed to connect. Error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py:0 +msgid "Failed to create jet. Please check the server and template settings." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "Failed to generate unique jet name after %(attempts)d attempts" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Failed to leave current waypoint." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +msgid "Failure" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__field_name +msgid "Field Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_ids +msgid "File" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "" +"File %(f)s is not 'tower' type. This operation is supported for 'tower' " +"files only" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "" +"File %(f)s shouldn't have the '%(src)s' source for the '%(act)s' action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_ids_count +msgid "File Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "File Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_template_ids +msgid "File Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_template_ids_count +msgid "File Template Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__file_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "File Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "File Type" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "File already exists" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file_template.py:0 +msgid "File already exists on server." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "File already exists on server. Upload skipped" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__code +msgid "File content" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "File content with variables rendered" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "File created and uploaded successfully" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "File deleted!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "File downloaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__name +msgid "File name WITHOUT path. Eg 'test.txt'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "File source cannot be determined: '%(source)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File that will be executed to get the log data" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "File uploaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File will be disconnected from template. Continue?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "File will be kept on server when deleted in Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_count +msgid "File(s)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Files" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "Files deleted!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "Files downloaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__file_ids +msgid "Files of this jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__file_ids +msgid "Files of this jet template" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "Files uploaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "" +"Files will be pulled from server to Tower automatically using cron job." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Finish date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__finish_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__finish_date +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Finished" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__plan_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__action__plan +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_shortcut__action__plan +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_tree +msgid "Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_run_line_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Flight Plan Lines" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan_log +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan_log +msgid "Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Flight Plan Logs" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "Flight Plan Logs - %(install_name)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +msgid "Flight Plan Stopped" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids +msgid "Flight Plan Used" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids_count +msgid "Flight Plan Used Ids Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Flight Plan line that is being currently executed" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_tree +msgid "" +"Flight Plan will be terminated after executing the current command. " +"Continue?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan +msgid "Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +msgid "Flight Plans Stopped" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +msgid "Flight plan is not compatible with the server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "" +"Flight plan is run on the cloned jet, not on the original one. Following " +"variables are available in the flight plan:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__plan_run_id +msgid "Flight plan run by the command" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Flight plan running error" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Flight plan running error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids_count +msgid "Flight plan this command is used in" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__plan_id +msgid "Flight plan to execute when this action is triggered" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_create_id +msgid "Flight plan to run after waypoint is created" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_arrive_id +msgid "Flight plan to run after waypoint is reached" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_delete_id +msgid "Flight plan to run before deleting the waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_leave_id +msgid "Flight plan to run before leaving the waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__plan_clone_same_server_id +msgid "Flight plan used to clone the jet on the same server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__plan_clone_different_server_id +msgid "Flight plan used to clone the jet to a different server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__plan_install_id +msgid "Flight plan used to install the template from a server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__plan_uninstall_id +msgid "Flight plan used to uninstall the template from a server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_stopped +msgid "Flight plan was stopped by user" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Float compare. Odoo helper function to compare floats." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__fly_here +msgid "Fly Here" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Fly here" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Fly to this waypoint" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Flying to waypoint %(waypoint)s on jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Following jets cannot be deleted as they are not deletable: %s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Following keyword is not allowed in Python code: '%(banned_keyword)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "" +"Following templates cannot be deleted as they are installed on servers: %s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "Following templates cannot be deleted as they still have jets: %s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__for_dependency_id +msgid "For Dependency" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__color +msgid "For better visualization in views" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "For how long a flight plan is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__applicability__this +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_run_wizard__applicability__this +msgid "For selected server(s)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__friday +msgid "Friday" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__state_from_id +msgid "From State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__full_server_path +msgid "Full Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cetmix_tower_config_settings +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cetmix_tower_general_settings +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "General Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Get from Host" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__is_global +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__is_global +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Global" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Group By" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/common.py:0 +msgid "Group reference %s not found!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__has_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__has_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__has_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__has_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__has_message +msgid "Has Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__has_missing_required_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__has_missing_required_values +msgid "Has Missing Required Values" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Has Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__have_access_to_server +msgid "Have Access To Server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Help:" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__host_key +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__host_key +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__host_key +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "Host Key" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Host key" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Host key not found for server %(server)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__host_key +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__host_key +msgid "Host key to verify the server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__hours +msgid "Hours" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "" +"I confirm that the key is correct and I want to insert it in the server " +"settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "I want a new" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__name_type__m +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__url_type__m +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_create_wizard__name_type__m +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_create_wizard__url_type__m +msgid "I will put myself" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_dependency__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__id +msgid "ID" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__res_id +msgid "ID of the resource that uses this vault" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v4_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v4_address +msgid "IPv4 Address" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v6_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v6_address +msgid "IPv6 Address" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__icon +msgid "Icon image" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__icon +msgid "" +"Icon of the related product to make navigation easier. E.g. Docker logo for " +"the Docker jet template." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__if_file_exists +msgid "If File Exists" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "If enabled file will be synced automatically using cron" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__disconnect_file +msgid "" +"If enabled, disconnects the file from its template after running the " +"command.\n" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__no_split_for_sudo +msgid "" +"If enabled, do not split command on '&&' when using sudo.Prepend sudo once " +"to the whole command." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__auto_sync +msgid "" +"If enabled, files created from this template will have Auto Sync enabled by " +"default. Used only with 'Tower' source." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +msgid "" +"If enabled, multiple instances of the same command can be run on the same server at the same time.\n" +"Otherwise, ANOTHER_COMMAND_RUNNING status will be returned if another instance of the same command is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +msgid "" +"If enabled, multiple instances of the same flight plan can be run on the same server at the same time.\n" +"Otherwise, ANOTHER_PLAN_RUNNING status will be returned if another instance of the same flight plan is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__show_in_create_wizard +msgid "" +"If enabled, the template will be shown in the wizard to create a new jet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "If exit code" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/test_plan.py:0 +msgid "If exit code == 35 then Exit with command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "" +"In the Secure Shell (SSH) protocol, host keys are used to verify the " +"identity of remote hosts. Accepting unknown host keys may leave the " +"connection open to man-in-the-middle attacks." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +msgid "Indicates if this variable is mandatory for server creation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__is_destination +msgid "Indicates if this waypoint is the current destination" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "Insert Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install__action__install +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_wiz_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Install" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_wiz_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Install Jet Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_template_install_wiz +msgid "Install Jet Template on Selected Servers" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_template_install_wiz_action +msgid "Install on Servers" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Installation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__plan_install_id +msgid "Installation Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Installation Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_form +msgid "Installation Steps" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__install_ids +msgid "Installations" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__install_ids +msgid "Installations of the template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__jet_template_ids +msgid "Installed Jet Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__server_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Installed on Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__state_transit_id +msgid "Intermediate state during the transition" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__interval_number +msgid "Interval Number" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +msgid "Interval Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__interval_type +msgid "Interval Unit" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_scheduled_task_interval_positive +msgid "Interval number must be greater than zero." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "" +"Invalid URL: '%(url)s'. URL must contain a protocol and a proper domain or " +"IP, eg 'https://my_tower_jet.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file_template.py:0 +msgid "" +"Invalid if_file_exists value: %(if_file_exists)s. Expected one of " +"%(valid_behaviors)s." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +msgid "Invalid value!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__is_destination +msgid "Is Destination" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__is_error +msgid "Is Error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__is_running +msgid "Is Running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_skipped +msgid "Is Skipped" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__is_waypoints_available +msgid "Is Waypoints Available" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Is final" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Is initial" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__jet_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_dependency__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__jet_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__jet_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "Jet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +msgid "Jet %(jet)s does not belong to server %(server)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Jet %(jet)s has no dependent jets with template %(template)s." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_request.py:0 +msgid "Jet %(jet)s is not on server %(server)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Jet %(jet)s was moved to the '%(state)s' state." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Jet '%(jet)s' doesn't belong to the server '%(server)s'." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "Jet '%(jet)s' is currently executing an action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__jet_action_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__jet_action_id +msgid "Jet Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__jet_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__jet_count +msgid "Jet Count" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Jet Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_jet_request +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet_request +msgid "Jet Requests" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_form +msgid "Jet State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_state_action +msgid "Jet States" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_state_action +msgid "" +"Jet States represent the different states a jet can be in during its " +"lifecycle." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__jet_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__jet_template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "Jet Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__jet_template_count +msgid "Jet Template Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_dependency__jet_template_dependency_id +msgid "Jet Template Dependency" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__jet_template_domain +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__jet_template_domain +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__jet_template_domain +msgid "Jet Template Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__jet_template_install_id +msgid "Jet Template Install" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__jet_template_install_id +msgid "Jet Template Install Job" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_template_install +msgid "Jet Template Install/Uninstall" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_template_install_line +msgid "Jet Template Install/Uninstall Line" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__jet_template_install_id +msgid "Jet Template Install/Uninstall record being run. " +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_form +msgid "Jet Template Installation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet_template_install +msgid "Jet Template Installations" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__jet_template_message +msgid "Jet Template Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__jet_template_state_ids +msgid "Jet Template State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_template_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__jet_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Jet Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_template_action +msgid "Jet Templates are used to create and manage Jets." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__url +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Jet URL, eg 'https://meme.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/tests/test_server_jet_action_command.py:0 +msgid "Jet action is not found." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Jet dependencies are not satisfied" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Jet for which command is run is not found." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "Jet icon" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__icon +msgid "Jet icon, computed from the template by default" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Jet limit per server reached for '%(jet)s' on server '%(server)s'!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.action_cx_tower_jet_request +msgid "" +"Jet requests will appear here when jets request resources from templates." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Jet template and server cannot be changed once the jet is created!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_kanban +msgid "Jet template icon" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Jet template is not found." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__jet_template_id +msgid "Jet template that this action belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__jet_template_id +msgid "Jet template this file belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__jet_template_id +msgid "Jet template this waypoint template belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Jet template will be uninstalled from the server. Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_request__jet_id +msgid "Jet that is requested" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_request__requested_by_jet_id +msgid "Jet that is requesting the jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_dependency__jet_depends_on_id +msgid "Jet this Jet depends on." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_dependency__jet_id +msgid "Jet this dependency belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__jet_id +msgid "Jet this file belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__jet_cloned_from_id +msgid "" +"Jet this jet was cloned from. This field is set when the jet is cloned from " +"another jet." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__jet_id +msgid "Jet this waypoint belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +msgid "Jet will be cloned. Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__jet_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__jet_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__jet_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__jet_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__jet_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__jet_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet_root +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet_settings_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_tree +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Jets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_action +msgid "" +"Jets represent application instances that are managed independently\n" +" from their host servers." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__jet_required_by_ids +msgid "Jets that depend on this jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__jet_ids +msgid "Jets to run the command on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "Keep When Deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__key_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__k +msgid "Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__key_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Key Type" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Key Value" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py:0 +msgid "Key inserted successfully!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Key reference for inline usage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key_root +msgid "Keys and Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Label" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Labeled" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__last_call +msgid "Last Execution Date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Last Sync Date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__write_date +msgid "Last Updated on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Latest version of file content on server" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_jet_create +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_create_jet +msgid "Launch New Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__plan_leave_id +msgid "Leave Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_form +msgid "Leave blank to autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__leaving +msgid "Leaving" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__action_ids +msgid "Lifecycle Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__limit_per_server +msgid "Limit per Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__line_id +msgid "Line" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_install__current_line_id +msgid "Line that is currently being installed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__line_ids +msgid "Lines" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_line_ids +msgid "Lines of the associated flight plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Local" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "" +"Location where command will be executed. Overrides command default path. You" +" can use {{ variables }} in path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__path +msgid "" +"Location where command will be run. You can use {{ variables }} in path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_html +msgid "Log Html" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_text +msgid "Log Text" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_type +msgid "Log Type" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Logger object. Use with caution! Only for debugging purposes." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_log_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__parent_flight_plan_log_id +msgid "Main Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plans" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_access_mixin.py:0 +#: model:res.groups,name:cetmix_tower_server.group_manager +msgid "Manager" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +msgid "Manager Access" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/common_jets.py:0 +msgid "Manager must have access to both jets before creating" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__manager_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Managers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers who can modify this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__limit_per_server +msgid "" +"Maximum number of Jets that can be launched on a server. Set to 0 for no " +"limit." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__jet_template_message +msgid "Message for the user" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__validation_message +msgid "" +"Message to display when the variable value is invalid. \n" +"First line will be added automatically: `Variable:, Value: `\n" +"Eg: `Variable: Customer Name, Value: Test\n" +"Invalid value!`\n" +"If empty, the default message will be used." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_ids +msgid "Messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__metadata +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__metadata +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_metadata_mixin__metadata +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__metadata +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Metadata" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__metadata_text +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__metadata_text +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_metadata_mixin__metadata_text +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__metadata_text +msgid "Metadata Text" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__minutes +msgid "Minutes" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables +msgid "Missing Required Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__missing_required_variables_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables_message +msgid "Missing Required Variables Message" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js:0 +msgid "Mode" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__res_model +msgid "Model name of the resource that uses this vault" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Modify Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__monday +msgid "Monday" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__months +msgid "Months" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "" +"Multiple records found with Variable '%(variable_name)s' and Server " +"'%(server_name)s' with both Jet and Jet Template empty." +msgstr "" + +#. module: cetmix_tower_server +#: model:cx.tower.variable,validation_message:cetmix_tower_server.variable_demo_branch +msgid "Must be lowercase and contain only letters and numbers!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__name +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__name_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__name_type +msgid "Name Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__field_name +msgid "Name of the field that contains the secret value" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +msgid "Name/Reference" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "Name/Reference/URL" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_request__state__new +msgid "New" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "New Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "New Jet will be created. Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_calendar +msgid "Next Call" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__next_call +msgid "Next Execution Date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Next Sync Date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__next_call +msgid "Next planned execution date for this task." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__same_server__n +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "No" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__no_split_for_sudo +msgid "No Split for sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Synced" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.action_cx_tower_jet_request +msgid "No jet requests found" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py:0 +msgid "" +"No jet templates are currently configured as 'Show in Wizard'. Please check " +"your jet template settings." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py:0 +msgid "" +"No jet templates configured as 'Show in Wizard' are installed on the " +"selected server. Please check your jet template settings." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "" +"No output received. Please log in manually and check for any issues.\n" +"===\n" +"CODE: %(status)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "No path found to bring the jet %(jet)s to the state '%(state)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "No runner found for command action '%(cmd_action)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml:0 +msgid "No secrets found" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "No server found for the provided reference." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "No server selected" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_template_install_action +msgid "No template installations found!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml:0 +msgid "No variables found" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "No/Undefined" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__applicability__shared +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_run_wizard__applicability__shared +msgid "Non server restricted" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/res_config_settings.py:0 +msgid "Non-sticky" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "Not Deletable" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Not Running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "Note" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_form +msgid "Notes" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Notifications" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_os +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "OS" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "" +"OS %(os)s used by the server '%(srv)s' is not present in the command's OS " +"compatibility list" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__os_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "OSes" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Odoo Environment" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "On Delete Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "On Error" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +msgid "Only one global secret value can be defined for a key" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "Only one global value can be defined for variable '%(var)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +msgid "Only one global value can be defined for variable 'meme'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +msgid "Only one secret value can be defined for a partner" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +msgid "Only one secret value can be defined for a server" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +msgid "Only one secret value can be defined for a server and partner" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Only values that the current user has access to are shown." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_kanban +msgid "Open Jet URL" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open full form" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_kanban +msgid "Open jets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Open jets that depend on this jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Open jets that this jet depends on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__os_id +msgid "Operating System" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_os +msgid "Operating Systems" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__option_id +msgid "Option" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "Option '%(val)s' is not available for variable '%(var)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__option_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__o +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Options" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__order +msgid "Order" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__os_compatibility_warning +msgid "Os Compatibility Warning" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__jet_requires_ids +msgid "Other jets this jet depends on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command__if_file_exists__overwrite +msgid "Overwrite" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__partner_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Partner" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_clone_wizard__partner_id +msgid "Partner associated with the cloned jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__partner_id +msgid "Partner associated with the jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__partner_id +msgid "Partner associated with this jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__partner_id +msgid "Partner to which the key belongs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_cetmix_tower_partner +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_partner +msgid "Partners" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__p +msgid "Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__path +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__plan_clone_different_server_id +msgid "Plan Clone Different Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__plan_clone_same_server_id +msgid "Plan Clone Same Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__plan_domain +msgid "Plan Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__plan_line_ids +msgid "Plan Line" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__plan_line_action_id +msgid "Plan Line Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__plan_line_ids_count +msgid "Plan Line Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Plan Line Executed" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Plan Lines" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__plan_log_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__plan_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__plan_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_log_ids +msgid "Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Plan failed while arriving." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Plan failed." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Plan is being executed right now" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +msgid "Plan line condition check failed." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__plan_ids +msgid "Plans" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Please provide IPv4 or IPv6 address for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Please provide SSH Key for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Please provide SSH password for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +msgid "" +"Please provide values for the following configuration variables: " +"%(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "" +"Please provide values for the following configuration variables: %(vars)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +msgid "Please resolve the following issues with configuration variables:" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "Please select a command to execute" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py:0 +msgid "Please select a server to create a jet." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Post Run Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Prepare" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Prepare Waypoint" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__preparing +msgid "Preparing" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Preparing waypoint %(waypoint)s on jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Preview" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__parent_id +msgid "Previous Version" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__last_call +msgid "Previous time the task ran successfully." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__priority +msgid "Priority" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_request__state__processing +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install__state__processing +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install_line__state__processing +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +msgid "Processing" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_property_root +msgid "Properties" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Pull files from server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Pull from Server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Push to Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__path +msgid "" +"Put custom path to run the command.\n" +"IMPORTANT: this field does NOT support variables!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +msgid "Put your notes here" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here..." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 'datetime' library" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 'dateutil' library" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "" +"Python 'dnspython' library. Documentation.
  • dns.resolver: wrapped" +" dnspython. Use dns.resolver.resolve(hostname, \"A\") for DNS " +"lookups.
  • dns.reversename: wrapped dnspython. Use " +"dns.reversename.from_address(\"8.8.8.8\") to build and reverse " +"PTR records.
  • dns.exception: wrapped dnspython. Catch " +"dns.exception.DNSException to handle DNS-related " +"errors.
" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "" +"Python 'hashlib' library. Documentation. Available methods: 'sha1', 'sha224', " +"'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', " +"'sha3_512', 'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "" +"Python 'hmac' library. Documentation. Use 'new' to create HMAC objects. " +"Available methods on the HMAC *object*: 'update', 'copy', 'digest', " +"'hexdigest'. Module-level function: 'compare_digest'." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 'json' library. Available methods: 'dumps'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 're' library for regex operations" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "" +"Python 'requests' library. Available methods: 'post', 'get', 'delete', " +"'request'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 'time' library" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 'timezone' library" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "" +"Python 'tldextract' library. Use tldextract.extract() to parse " +"domains. Check tldextract for more information." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "Python 'urllib.parse' library methods." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__action__python_code +msgid "Python code" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Python code running error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "" +"Python code that is used to modify the variable values.\n" +"
\n" +" Available variables and functions:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__applied_expression +msgid "" +"Python expression to apply to the variable value. \n" +"You can use general python sting functions and 're' module for regex operations. Use 'value' variable to refer to the variable value, use 'result' to assign the final result that will be used as a variable value.\n" +"Eg 'result = value.lower().replace(' ', '_')'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command__if_file_exists__raise +msgid "Raise Error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_waypoint__state__ready +msgid "Ready" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +msgid "Recursive plan call detected in plan %(name)s." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__reference +msgid "Reference" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Reference Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_command_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_rel_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_remote_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_repo_owner_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_repo_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_source_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_action_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_state_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_template_dependency_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_waypoint_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_waypoint_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_value_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_os_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_action_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_reference_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_scheduled_task_cv_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_scheduled_task_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_log_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_shortcut_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_tag_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_webhook_authenticator_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_webhook_eval_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_webhook_reference_unique +msgid "Reference must be unique" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "" +"Reference. Can contain English letters, digits and '_'. Leave blank to " +"autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +msgid "Refresh" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Refresh All" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Regex expression, eg. ^[a-z0-9]+$" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__validation_pattern +msgid "" +"Regex pattern to validate the variable values using the 're.match' function. Eg. ^[a-z0-9]+$ \n" +"If empty, the variable values will not be validated." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_dependency__jet_template_dependency_id +msgid "" +"Related jet template dependency. Used to track dependency changes at the " +"template level." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote\n" +" one." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote " +"one." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__rendered_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "Rendered Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_name +msgid "Rendered Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_server_dir +msgid "Rendered Server Dir" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__interval_number +msgid "Repeat every x." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__served_jet_request_id +msgid "Request this jet is currently serving" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "Requested By Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__jet_template_id +msgid "Requested Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__requested_by_jet_id +msgid "Requested by Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_clone_wizard__state_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__state_id +msgid "Requested state of the jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__required +msgid "Required" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__jet_required_by_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Required By" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__template_required_id +msgid "Required Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_dependency__state_required_id +msgid "Required State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__template_required_by_ids +msgid "Required by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__jet_requires_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__template_requires_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Requires" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__res_id +msgid "Resource ID" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__res_model +msgid "Resource Model" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_response +msgid "Response" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__result +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Result" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__command_result_html +msgid "Result converted to HTML. Used for SSH commands." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_access_mixin.py:0 +#: model:res.groups,name:cetmix_tower_server.group_root +msgid "Root" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +msgid "Root Access" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Run" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_run_wizard +msgid "Run Command in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_run_id +msgid "Run Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_run_wizard +msgid "Run Flight Plan in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Run Manually" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run New Command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Run Plan" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "Run Result" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "" +"Run code as it appears in 'Rendered code' in wizard and return to wizard. " +"Result will not be logged" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run code using server method and log result" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_shortcut__use_sudo +msgid "Run command using 'sudo'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__use_sudo +msgid "Run commands using 'sudo'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__use_sudo +msgid "Run commands using 'sudo'. Leave empty if 'sudo' is not needed." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run in wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__n +msgid "Run next command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Run on" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Run scheduled tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Running" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Running Now" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_auth_mode +msgid "SSH Auth Mode" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "SSH Client is not defined." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__k +msgid "SSH Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_key +msgid "SSH Key / Secret" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_password +msgid "SSH Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__secret_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_key_id +msgid "SSH Private Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "SSH Username" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__action__ssh_command +msgid "SSH command" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "SSH connection error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_port +msgid "SSH port" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "SSH run command error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__dependency_graph_svg +msgid "SVG image content of the dependency graph of the template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__dependency_graph_image +msgid "SVG image of the dependency graph of the template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__same_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "Same Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__saturday +msgid "Saturday" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_scheduled_task +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__scheduled_task_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__scheduled_task_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__scheduled_task_id +msgid "Scheduled Task" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_scheduled_task +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__scheduled_task_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__scheduled_task_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__scheduled_task_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__scheduled_task_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_scheduled_task +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Scheduled Tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__scheduled_task_id +msgid "Scheduled task that triggered this command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__scheduled_task_id +msgid "Scheduled task that triggered this flight plan" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +msgid "Scheduled tasks run successfully." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Scheduled tasks will be run automatically using cron job." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Search Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Search Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +msgid "Search File Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Search Files" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Search Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Search Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_search +msgid "Search Jet States" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +msgid "Search Jet Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "Search Jets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Search Keys/Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_search_view +msgid "Search OS" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +msgid "Search Scheduled Tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Search Server Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Search Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Search Shortcuts" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_search_view +msgid "Search Tags" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +msgid "Search Template Installations" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Search Values" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +msgid "Search Waypoint Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +msgid "Search Waypoints" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__s +msgid "Secret" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__data +msgid "Secret Data" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__secret_value +msgid "Secret Value" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Secret Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_partner__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__secret_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_form_inherit_cetmix_tower +msgid "Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_wiz_view_form +msgid "Select a jet template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_wizard_view_form +msgid "" +"Select a state to set for the selected jets. Only states that appear in the\n" +" \"State to\" field of jet templates of all selected jets are available." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_wizard_view_form +msgid "Select a state..." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_wizard_view_form +msgid "Select an action..." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__applicability +msgid "" +"Selected server(s): only Commands that are specific to the selected server(s)\n" +"Non server restricted: all Commands that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_run_wizard__applicability +msgid "" +"Selected server(s): only Flight Plans that are specific to the selected server(s)\n" +"Non server restricted: all Flight Plans that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint_template__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__sequence +msgid "Sequence" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__served_jet_request_id +msgid "Served Jet Request" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_ir_actions_server +msgid "Server Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__server_allowed_domain +msgid "Server Allowed Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_partner__server_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__server_count +msgid "Server Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__server_domain +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__server_domain +msgid "Server Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__server_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__server_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_log_ids +msgid "Server Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_log_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__name +msgid "Server Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_response +msgid "Server Response" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_status +msgid "Server Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Server Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server_template +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__server_template_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__server_template_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Server Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server URL, eg 'https://meme.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Server Version" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Server not found" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_response +msgid "" +"Server response received during the last operation.\n" +"Default value if no error happened is 'ok'.\n" +"Otherwise there will be a server error message logged." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Server tight" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_install__server_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_install_line__server_id +msgid "Server to install/uninstall the template on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__server_id +msgid "Server to which the key belongs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__url +msgid "Server web interface, eg 'https://doge.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_request__server_id +msgid "Server where the jet is requested" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__server_id +msgid "Server where this jet is running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_wiz__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_partner__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__server_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_filter +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_form_inherit_cetmix_tower +msgid "Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Servers (SSH)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_ids +msgid "" +"Servers on which the command will be run.\n" +"If empty, command can be run on all servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Serves dependencies" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__jet_id +msgid "Serviced by Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +msgid "Set Custom Values" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_wizard_view_form +msgid "Set Jet State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_state_wizard +msgid "Set Jet State Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Set Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Set state" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_status +msgid "" +"Set the following status if command finishes with success. Leave 'Undefined'" +" if you don't need to update the status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_settings +msgid "Settings" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_shortcut.py:0 +msgid "Shortcut '%(shr)s' triggered. Check %(t)s log for result" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_shortcut +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__shortcut_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__shortcut_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_shortcut +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Shortcuts" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__show_available_states +msgid "Show Available States" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Show Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Show Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_host_key_wizard +msgid "Show Host Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__show_jets +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__show_jets +msgid "Show Jets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__show_servers +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__show_servers +msgid "Show Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__show_available_states +#: model:ir.model.fields,help:cetmix_tower_server.field_res_users__cetmix_tower_show_jet_available_states +msgid "Show available states in the jet view" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__show_in_create_wizard +msgid "Show in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_kanban +msgid "Show in create wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "" +"Show notifications for error events. Select 'Sticky' to keep the " +"notification visible until dismissed. Leave empty to disable notifications." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "" +"Show notifications for success events. Select 'Sticky' to keep the " +"notification visible until dismissed. Leave empty to disable notifications." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command__if_file_exists__skip +msgid "Skip" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__skip_host_key +msgid "Skip Host Key" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Skipped" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "Some servers don't support this command" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_state.py:0 +msgid "" +"Some states are still used in the following actions: %(actions)s\n" +"Jet templates: %(templates)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +msgid "" +"Some variable options are invalid:\n" +"%(detailed_message)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__source +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__source +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Source" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__state_from_id +msgid "Source state for this transition. Leave blank for an initial state" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Start date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__start_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__start_date +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_tree +msgid "Started" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__state_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__state_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_state_wizard__state_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install_line__state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__state +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "State" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "State '%(state)s' not found for jet '%(jet)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__state_available_ids +msgid "State Available" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__state_domain +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__state_domain +msgid "State Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__state +msgid "State Reference" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_request__state_requested_id +msgid "State Requested" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_state.py:0 +msgid "State can be set only for a single jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_wizard_view_form +msgid "State of selected jets will be changed. Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_request__state_requested_id +msgid "State of the jet that is requested" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_action__state_error_id +msgid "State to transition to if an error occurs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet_state +msgid "States" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "States and Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_state_wizard__available_state_ids +msgid "" +"States that appear in the 'state_to' field of jet templates of all selected " +"jets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_status +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__status +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/res_config_settings.py:0 +msgid "Sticky" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_tree +msgid "Stop" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_stopped +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Stopped" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command_log.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +msgid "Stopped by user %(user)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__s +msgid "String" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_request__state__success +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_jet_request_search +msgid "Success" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_config_settings__cetmix_tower_notification_type_success +msgid "Success Notifications" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Successfully arrived at waypoint %(waypoint)s on jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Successfully deleted waypoint %(waypoint)s on jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Successfully flew to waypoint %(waypoint)s on jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Successfully prepared waypoint %(waypoint)s on jet %(jet)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__sunday +msgid "Sunday" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Sync Error" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Synced" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "System Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_tag +msgid "Tag" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Tagged" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag_mixin__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__tag_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_tag +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +msgid "Tags" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__target_state_id +msgid "Target State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "Template" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "" +"Template '%(template_name)s' is not installed on the server " +"'%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__template_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Template Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_template_install_action +msgid "Template Installations" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_template_install_action +msgid "" +"Template installations are created automatically when you install jet " +"templates on servers." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_request__jet_template_id +msgid "" +"Template of the jet that is requested. Used to create a new jet if not " +"found." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__jet_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_clone_wizard__jet_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__jet_template_id +msgid "Template that this jet is based on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_install__jet_template_id +msgid "Template to install/uninstall" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_template_action +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_template +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_jet_template +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_template +msgid "Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template_install__line_ids +msgid "Templates to install" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Test Connection" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_dependency__template_required_id +msgid "The Jet template that is required to be in a specific state" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_dependency__template_id +msgid "The Jet template that requires another template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_clone_wizard__url +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__url +msgid "The URL of the jet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +msgid "" +"The access level for Variable Option '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Option Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "" +"The access level for Variable Value '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Value Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +msgid "" +"The access level of command(s) '%(command_names)s' included in the current " +"Flight plan is higher than the access level of the Flight plan itself. " +"Please ensure that you want to allow those commands to be run anyway." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__action_create_id +msgid "The action is used to create a new Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__action_destroy_id +msgid "The action is used to destroy a Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option_name +msgid "The combination of Name and Variable must be unique." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option +msgid "The combination of Value and Variable must be unique." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "The file %(f_path)s not found." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_create_wizard__name +msgid "The name of the jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_clone_wizard__name +msgid "The name of the new jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__data +msgid "The secret data to be stored in the vault" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +msgid "" +"The selected task interval is too low in relation to the general system " +"settings. This may lead to task execution delays." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template_dependency__state_required_id +msgid "The state of the required Jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Then" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "" +"There are other templates that depend on template '%(template_name)s' that " +"are installed on the server '%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template.py:0 +msgid "" +"There are still jets of template '%(template_name)s' on the server " +"'%(server_name)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__server_ids +msgid "These servers have this jet template installed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "This Flightplan will be executed when the server is deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__plan_delete_id +msgid "This Flightplan will be run when the server is deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "" +"This action will be triggered on error if no command action can be applied" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "This command can be used only in Flight Plans." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_jet_dependency_unique_jet_dependency +msgid "This dependency already exists!" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py:0 +msgid "" +"This dependency would create a circular reference chain! Template " +"'%(template)s' would indirectly depend on itself." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__deletable +msgid "" +"This field is set by the jet actions. If enabled, the jet can be deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__note +msgid "This field is used to put some notes regarding template." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_template_mixin__code +msgid "This field will be rendered using variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_template_id +msgid "" +"This file template will be used to create log files when server is created " +"from a template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +msgid "This flight plan will be run upon server creation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "" +"This is required, however you can change this later in the server settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "This jet can be deleted." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__jet_template_id +msgid "This jet template will be used to create log files when jet is created" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +msgid "This template will be used to create or update the pushed file" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__is_global +msgid "This value is applicable to all servers and partners" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__thursday +msgid "Thursday" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Time consumed for execution, seconds" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_res_config_settings__cetmix_tower_command_timeout +msgid "" +"Timeout for commands in seconds after which the command will be terminated" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install_line__state__to_process +msgid "To Process" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__state_to_id +msgid "To State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_tools +msgid "Tools" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_count +msgid "Total Files" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__tower +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__tower +msgid "Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_mixin +msgid "Tower Variables mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_action__state_transit_id +msgid "Transit State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_jet_trigger_action +msgid "Trigger Action" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_wizard_view_form +msgid "Trigger Jet Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_action_wizard +msgid "Trigger Jet Action Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Trigger action" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Trigger shortcut" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_wizard_view_form +msgid "Trigger the action for the selected jets?" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Triggered Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_command_log_ids +msgid "Triggered Flight Plan Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_log_id +msgid "Triggered Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__tuesday +msgid "Tuesday" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__waypoint_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_type +msgid "Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_res_config_settings__cetmix_tower_notification_type_error +msgid "Type of error notifications" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_res_config_settings__cetmix_tower_notification_type_success +msgid "Type of success notifications" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__url +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__url +msgid "URL" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "" +"Unable to delete file '%(f)s'.\n" +"Delete operation is not supported for 'server' type files." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +msgid "Unable to run scheduled task '%(f)s'. Error: %(e)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Undefined" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_template_install__action__uninstall +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Uninstall" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_install_view_search +msgid "Uninstall Action" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "Uninstallation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__plan_uninstall_id +msgid "Uninstallation Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_upload_action +msgid "Upload" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__url +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__url +msgid "Url" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__url_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__url_type +msgid "Url Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__use_custom_variables +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__use_custom_variables +msgid "Use Custom Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__use_sudo +msgid "Use Sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__use_sudo +msgid "Use sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH key in the following servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Used for" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Used in Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Used in Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__sequence +msgid "Used to sort jets in views" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint_template__sequence +msgid "Used to sort waypoints in views" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__jet_action_id +msgid "Used to track flight plans executed by jet actions" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_access_mixin.py:0 +#: model:ir.model,name:cetmix_tower_server.model_res_users +#: model:res.groups,name:cetmix_tower_server.group_user +msgid "User" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +msgid "User Access" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +msgid "UserError. Helper to raise UserError." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__user_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Users" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users who can view this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__validation_message +msgid "Validation Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__validation_pattern +msgid "Validation Pattern" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Value" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids_count +msgid "Value Count" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Value Modifier" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +msgid "Value is invalid" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Variable" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +msgid "" +"Variable '%(var)s' can only be assigned to one of the models at a time: " +"Server, Jet, Jet Template, Server Template, or Plan Line Action." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_mixin.py:0 +msgid "Variable '%(variable_reference)s' not found" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard__line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard__line_ids +msgid "Variable Lines" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +msgid "Variable Reference" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +msgid "Variable Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_value_ids +msgid "Variable Value" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_value_ids_count +msgid "Variable Value Count" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__variable_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__variable_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__variable_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_value +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_form +msgid "Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_waypoint__variable_values_text +msgid "Variable Values Text" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_tower_variable_value_uniq +msgid "Variable can be declared only once for the same record!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_clone_wizard_variable_line +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_jet_create_wizard_variable_line +msgid "Variable lines" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_name_uniq +msgid "Variable names must be unique" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Variable not found" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +msgid "" +"Variable reference '%(var_ref)s' has an invalid option reference " +"'%(opt_ref)s'." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_template_mixin.py:0 +msgid "Variable syntax error: %s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Variable value created" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +msgid "Variable value updated" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +msgid "Variable values for selected record" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +msgid "" +"Variable: %(var)s, Value: %(val)s\n" +"%(msg)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_root +msgid "Variables" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_install.py:0 +msgid "View Installation" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "View Jet" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command_log.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +msgid "View Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__warning_message +msgid "Warning Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__os_compatibility_warning +msgid "Warning about OS compatibility of the command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__waypoint_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__waypoint_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__waypoint_id +msgid "Waypoint" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "" +"Waypoint %(existing)s is already set as the destination for jet %(jet)s. " +"Only one destination waypoint is allowed per jet." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__waypoint_template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_search +msgid "Waypoint Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_waypoint_template_action +msgid "Waypoint Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_waypoint_template_action +msgid "Waypoint Templates define waypoints for jet templates." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "" +"Waypoint creation failed (e.g. waypoint template does not match jet " +"template)." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py:0 +msgid "Waypoint reached %s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "" +"Waypoint template %(waypoint_template)s does not belong to the jet template " +"%(jet_template)s" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +msgid "Waypoint template %s not found" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +msgid "Waypoint template is not set." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_waypoint__waypoint_template_id +msgid "Waypoint template this waypoint is based on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__waypoint_template_id +msgid "" +"Waypoint template to create the waypoint from. Used when action is Create a " +"Waypoint." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__waypoint_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__waypoint_id +msgid "Waypoint this plan log belongs to" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_jet_waypoint_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet__waypoint_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_template__waypoint_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "Waypoints" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet__waypoint_ids +msgid "Waypoints of the jet" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_jet_template__waypoint_template_ids +msgid "Waypoints of the template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_jet_waypoint_action +msgid "Waypoints represent waypoints for jets." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__wednesday +msgid "Wednesday" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__weeks +msgid "Weeks" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__if_file_exists +msgid "" +"What to do if file already exists on the server.\n" +"- Skip: Do not create or update the file.\n" +"- Overwrite: Replace the existing file with the new one.\n" +"- Raise Error: Raise an error if the file already exists." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__fly_here +msgid "" +"When enabled, the created waypoint is set as current (fly to) after " +"creation." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Where command was executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Will be used instead of the command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "" +"Will use sudo based on server settings.If no sudo is configured will run " +"without sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "With Jet Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "With Jets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_filter +msgid "With Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_search +msgid "With URL" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__use_sudo__p +msgid "With password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__use_sudo__n +msgid "Without password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__wizard_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_clone_wizard_variable_line__wizard_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_jet_create_wizard_variable_line__wizard_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__wizard_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__wizard_id +msgid "Wizard" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +msgid "Wrong action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__same_server__y +msgid "Yes" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "You are not allowed to execute commands in wizard" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_log.py:0 +msgid "You are not allowed to modify the server log output." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_jet_state.py:0 +msgid "You are not allowed to set the '%(state)s' state!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "" +"You can also use jet.jet_cloned_from_id to get the original jet" +" in Python commands." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "You cannot execute an empty command" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py:0 +msgid "" +"You cannot modify an existing template dependency! Please remove it and " +"create a new one." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "You cannot run custom code on multiple jets at once." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +msgid "You cannot run custom code on multiple servers at once." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "" +"You need 'Manager' access to the server to override the default configuration values.\n" +" Without this access, the server's configured values will be used." +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/ir_actions_server.py:0 +msgid "" +"You need to have 'write' access to all servers you want to run this action " +"on." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "" +"You need to save your changes to be able to select newly added actions in " +"the fields below." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_view_form +msgid "action name, e.g. 'Start'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__use_custom_variables__y +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_create_wizard__use_custom_variables__y +msgid "custom settings" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__use_custom_variables__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_create_wizard__use_custom_variables__n +msgid "default settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "e.g. /home/user This field does NOT support variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "e.g. /such/much/{{ path }}, overrides command path" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "e.g. Odoo Instance" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "e.g. Production Odoo" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_state_view_form +msgid "e.g. Running" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "eg 'https://myjet.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "general python functions (eg. lower(), replace(), etc.)" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/common.py:0 +msgid "groups_ref must be string or list of strings!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "in the state" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_action_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "leave blank to autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "managers who can modify this jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "managers who can modify this jet template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "managers who can modify this record" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "managers who can modify this server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "managers who can modify this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "new server name" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +msgid "on the same server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "on the server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "optional, eg /home/{{ tower.server.username }}" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "put the name here" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "re: regex operations (eg. re.sub, re.match, etc.)" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "result: final result that will be used as a variable value" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "select a partner if required" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "that will use" +msgstr "" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +msgid "then" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "this can be changed later" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +msgid "to" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "undefined" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_view_form +msgid "users who can access this jet" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_template_view_form +msgid "users who can access this jet template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "users who can access this server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "users who can access this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "users who can view this record" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "value: variable value" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_view_form +msgid "waypoint name, e.g. 'Snapshot before update'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_waypoint_template_view_form +msgid "waypoint template name, e.g. 'Snapshot'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__name_type__a +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_clone_wizard__url_type__a +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_create_wizard__name_type__a +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_jet_create_wizard__url_type__a +msgid "will be auto-generated" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "with tags" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "with the URL that" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_clone_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_jet_create_wizard_view_form +msgid "with the name that" +msgstr "" diff --git a/addons/cetmix_tower_server/i18n/de.po b/addons/cetmix_tower_server/i18n/de.po new file mode 100644 index 0000000..2585d42 --- /dev/null +++ b/addons/cetmix_tower_server/i18n/de.po @@ -0,0 +1,3462 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: weblatereports@cetmix.com\n" +"PO-Revision-Date: 2025-03-03 12:07+0000\n" +"Last-Translator: CetmixWeblateBot \n" +"Language-Team: German \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.3-dev\n" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__source +msgid "" +"\n" +" - Tower: file is pushed from Tower to server.\n" +" - Server: file is pulled from server to Tower.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_user +msgid "" +"\n" +" Basic actions for selected servers.\n" +" " +msgstr "" +"\n" +" Basisaktionen für die ausgewählten Server.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_manager +msgid "" +"\n" +" Create and modify selected servers.\n" +" " +msgstr "" +"\n" +" Erstelle und editiere den gewählten Server.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_root +msgid "" +"\n" +" Full control over all servers.\n" +" " +msgstr "" +"\n" +" Alle Rechte für alle Server.\n" +" " + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Empty values for variables: %(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Missing variables: %(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "...save record to see the final expression or click the line to edit" +msgstr "" +"... speichere um das Ergebnis zu sehen oder klicke in die Zeile zum Editieren" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-days +msgid "1 day" +msgstr "1 Tag" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-hours +msgid "1 hour" +msgstr "1 Stunde" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-months +msgid "1 month" +msgstr "1 Monat" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-weeks +msgid "1 week" +msgstr "1 Woche" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-years +msgid "1 year" +msgstr "1 Jahr" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__10-minutes +msgid "10 min" +msgstr "10 Minuten" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__12-hours +msgid "12 hour" +msgstr "12 Stunden" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__2-hours +msgid "2 hour" +msgstr "2 Stunden" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__30-minutes +msgid "30 min" +msgstr "30 Minuten" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__6-hours +msgid "6 hour" +msgstr "6 Stunden" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "AND" +msgstr "UND" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"\n" +" x = 2*10\n" +" COMMAND_RESULT = {\"exit_code\": x, \"message\": \"This will be\n" +" logged as an error message because exit code !=0\"}\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"UserError: Warning Exception to use with \n" +" raise" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"env: Odoo Environment on which the action is\n" +" triggered" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"hashlib: Python 'hashlib' library.\n" +" Available methods: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256',
\n" +" 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"hmac: Python 'hmac' library. Use 'new' to create HMAC objects.
\n" +" Available methods on the HMAC *object*: 'update', 'copy', 'digest', 'hexdigest'.
\n" +" Module-level function: 'compare_digest'." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "json: Python 'json' library. Available methods: 'dumps'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"requests: Python 'requests' library. Available methods: 'post'," +" 'get', 'delete', 'request'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "server: Server on which the command is run" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"time, datetime, dateutil\n" +" , timezone: useful Python libraries" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "tower: 'cetmix.tower' helper class shortcut" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "user: Current Odoo user" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "" +"\n" +" &nbsp;" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_log.py:0 +#, python-format +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "IPv4 Address:" +msgstr "IPv4 Addresse:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "IPv6 Address:" +msgstr "IPv6 Addresse:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "Operating System:" +msgstr "Betriebssystem:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Partner:" +msgstr "Partner:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Servers:" +msgstr "Server:" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_action +msgid "" +"A variable value cannot be assigned multiple times to the same plan line " +"action!" +msgstr "Ein Wert kann nicht mehrmals in der gleichen Planaktion genutzt werden!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_template +msgid "" +"A variable value cannot be assigned multiple times to the same server " +"template!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_server +msgid "A variable value cannot be assigned multiple times to the same server!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Access" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__access_level +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower_server +msgid "Access Level" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level_warn_msg +msgid "Access Level Warn Msg" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "Access level is not defined for '%(option)s'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Access level is not defined for '%(variable)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__action +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "" +"Actions trigger based on command result. If empty next command will be " +"executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__active +msgid "Active" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_ids +msgid "Activities" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "Activity State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_action +msgid "Add a new file" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_template_action +msgid "Add a new file template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Allow Parallel Run" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "An error occurred: %(error)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Another instance of the command is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__applicability +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__applicability +msgid "Applicability" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Archived" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "Auto Sync" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync_interval +msgid "Auto Sync Interval" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Binary" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_view_form +msgid "" +"Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Cancel" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for '%(var)s' with value '%(val)s'.\n" +"Try to assigns it to a record instead." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for 'meme' with value 'Pepe'.\n" +"Try to assigns it to a record instead." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Cannot download %(f)s from server: Binary content is not supported for " +"'Text' file type" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot execute command\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Cannot pull %(f)s from server: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot remove test file using command.\n" +" CODE: %(status)s. ERROR: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower +#: model:ir.ui.menu,name:cetmix_tower_server.menu_root +msgid "Cetmix Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command +msgid "Cetmix Tower Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_command_execute_wizard_action +msgid "Cetmix Tower Execute Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server_ir_actions_server +#: model:ir.cron,cron_name:cetmix_tower_server.ir_cron_auto_pull_files_from_server +#: model:ir.cron,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server +msgid "Cetmix Tower File Management: Auto pull files from server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan +msgid "Cetmix Tower Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line +msgid "Cetmix Tower Flight Plan Line" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line_action +msgid "Cetmix Tower Flight Plan Line Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_log +msgid "Cetmix Tower Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_os +msgid "Cetmix Tower Operating System" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_plan_execute_wizard_action +msgid "Cetmix Tower Run Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server +msgid "Cetmix Tower Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_log +msgid "Cetmix Tower Server Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template +msgid "Cetmix Tower Server Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_tag +msgid "Cetmix Tower Tag" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable +msgid "Cetmix Tower Variable" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_option +msgid "Cetmix Tower Variable Options" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_value +msgid "Cetmix Tower Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_mixin +msgid "Cetmix Tower access mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_role_mixin +msgid "Cetmix Tower access role mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key +msgid "Cetmix Tower private key storage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_reference_mixin +msgid "Cetmix Tower reference mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_template_mixin +msgid "Cetmix Tower template rendering mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Child plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Code On Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__color +msgid "Color" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__command_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Command '%(cmd)s' is not compatible with the server '%(server)s'." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__command_domain +msgid "Command Domain" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command_log +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_log_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_log +#, python-format +msgid "Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Command Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__is_running +msgid "Command is being executed right now" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__command_id +msgid "" +"Command that will be executed to get the log data.\n" +"Be careful with commands that don't support parallel execution!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_line_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_root +msgid "Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__condition +msgid "Condition" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__condition +msgid "" +"Conditions under which this Flight Plan Line will be launched. e.g.: {{ " +"odoo_version}} == '14.0'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Configuration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__line_ids +msgid "Configuration Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Confirm" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection failed." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection successful." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Connection test passed! \n" +"%(res)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#, python-format +msgid "Create Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard +msgid "Create new server from template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard_line +msgid "Create new server from template variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_uid +msgid "Created by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_date +msgid "Created on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Custom Exit Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Custom label. Can be used for search/tracking" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file +msgid "Cx Tower File" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file_template +msgid "Cx Tower File Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Date and time of the latest successful synchronisation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Date and time of the next synchronisation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__path +msgid "Default Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "Default full file name with file type for example: test.txt" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_delete_action +msgid "Delete from server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_dir +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__server_dir +msgid "Directory on server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server__display_name +msgid "Display Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_download_action +msgid "Download" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Due to security restrictions you are not allowed to delete %(fp)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Duration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "Duration, sec" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Each python code command returns the COMMAND_RESULT value\n" +" which is a dictionary." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Eg '/home/user' or '/var/log'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Enter Python code here. Help about Python expression is available in the " +"help tab of this document." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_error +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Error" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Error loading a private key. Unsupported key format or incorrect key." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Execute Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_execute_wizard +msgid "Execute Command in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_execute_wizard +msgid "Execute Flight Plan in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Execute Result" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute flight plan error" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute flight plan error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute python code error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Execution Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__e +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__e +msgid "Exit with command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__ec +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__ec +msgid "Exit with custom exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Failed" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect after %(attempts)s attempts. Error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect. Error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Failure" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"File %(f)s is not 'tower' type. This operation is supported for 'tower' " +"files only" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"File %(f)s shouldn't have the '%(src)s' source for the '%(act)s' action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "File Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "File Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "File Type" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File already exists" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file_template.py:0 +#, python-format +msgid "File already exists on server." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__code +msgid "File content" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "File content with variables rendered" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File created and uploaded successfully" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File deleted!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File downloaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "File from template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__name +msgid "File name WITHOUT path. Eg 'test.txt'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File source cannot be determined: '%(source)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File that will be executed to get the log data" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File uploaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File will be disconnected from template. Continue?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "File will be kept on server when deleted in Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_count +msgid "File(s)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Files" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files deleted!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files downloaded!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files uploaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Finish date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__finish_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__finish_date +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Finished" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_line_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Flight Plan Lines" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan_log +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan_log +msgid "Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Flight Plan Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Flight Plan line that is being currently executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan +msgid "Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "Flight plan '%(plan)s' is not compatible with the server '%(server)s'." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_channel_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_channel_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__color +msgid "For better visualization in views" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "For how long a flight plan is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__applicability__this +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_execute_wizard__applicability__this +msgid "For selected server(s)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__full_server_path +msgid "Full Server Path" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "General Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__is_global +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Global" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Group By" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__has_missing_required_values +msgid "Has Missing Required Values" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Has Template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help with Python expressions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server__id +msgid "ID" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v4_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v4_address +msgid "IPv4 Address" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v6_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v6_address +msgid "IPv6 Address" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_unread +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_unread +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +msgid "" +"If enabled command can be run on the same server while the same command is still running.\n" +"Returns ANOTHER_COMMAND_RUNNING if execution is blocked" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "If enabled file will be synced automatically using cron" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +msgid "" +"If enabled flightplan can be run on the same server while the same flightplan is still running.\n" +"Returns -5 status is execution is blocked" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#, python-format +msgid "If exit code" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_plan.py:0 +#, python-format +msgid "If exit code == 35 then Exit with command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +msgid "Indicates if this variable is mandatory for server creation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Is Running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_skipped +msgid "Is Skipped" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "Keep When Deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__k +msgid "Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__key_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Key Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Key reference for inline usage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key +msgid "Keys and Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Label" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Labeled" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server____last_update +msgid "Last Modified on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Last Sync Date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_date +msgid "Last Updated on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Latest version of file content on server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__partner_id +msgid "Leave blank to use for any partner" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Leave blank to use with any partner" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Leave blank to use with any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__line_id +msgid "Line" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Local" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "" +"Location where command will be executed. Overrides command default path. You" +" can use {{ variables }} in path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__path +msgid "" +"Location where command will be executed. You can use {{ variables }} in path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_text +msgid "Log Text" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_type +msgid "Log Type" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_main_attachment_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_main_attachment_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__parent_flight_plan_log_id +msgid "Main Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_manager +msgid "Manager" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers who can modify this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_ids +msgid "Messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables +msgid "Missing Required Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables_message +msgid "Missing Required Variables Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_mixin +msgid "Mixin for managing secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Modify Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__name +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Next Sync Date" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Synced" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Template" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"No output received. Please log in manually and check for any issues.\n" +"===\n" +"CODE: %(status)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "No runner found for command action '%(cmd_action)s'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "No server found for the provided reference." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "No sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__applicability__shared +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_execute_wizard__applicability__shared +msgid "Non server restricted" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Not Running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "Note" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_unread_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_unread_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_os +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "OS" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__os_ids +msgid "OSes" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_os +msgid "OSs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "On Delete Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "On Error" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Only one global value can be defined for variable '%(var)s'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "Only one global value can be defined for variable 'meme'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open full form" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__os_id +msgid "Operating System" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__option_id +msgid "Option" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Option '%(val)s' is not available for variable '%(var)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__option_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__o +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Options" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__partner_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Partner" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__p +msgid "Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__path +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_domain +msgid "Plan Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__plan_line_action_id +msgid "Plan Line Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Plan Line Executed" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_plan_execute_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__plan_log_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_log_ids +#, python-format +msgid "Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Plan is being executed right now" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Plan line condition check failed." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide IPv4 or IPv6 address for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH Key for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH password for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +#, python-format +msgid "" +"Please provide values for the following configuration variables: " +"%(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "Please resolve the following issues with configuration variables:" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Please select a command to execute" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Post Run Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Preview" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__parent_id +msgid "Previous Version" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Pull from Server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Push to Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__path +msgid "" +"Put custom path to run the command.\n" +"IMPORTANT: this field does NOT support variables!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here..." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__action__python_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Python code" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Recursive plan call detected in plan %(name)s." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Reference" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Reference Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_command_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_remote_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_source_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_os_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_action_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_reference_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_log_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_tag_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_reference_unique +msgid "Reference must be unique" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_key.py:0 +#, python-format +msgid "Reference must be unique for the combination of partner and server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "" +"Reference. Can contain English letters, digits and '_'. Leave blank to " +"autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +msgid "Refresh" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Refresh All" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote\n" +" one." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote " +"one." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__rendered_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "Rendered Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_name +msgid "Rendered Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_server_dir +msgid "Rendered Server Dir" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__required +msgid "Required" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_response +msgid "Response" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__result +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Result" +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_root +msgid "Root" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"Run\n" +" Command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"Run\n" +" Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#, python-format +msgid "Run Command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_plan +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#, python-format +msgid "Run Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run New Command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Run a flight plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "" +"Run code as it appears in 'Rendered code' in wizard and return to wizard. " +"Result will not be logged" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run code using sever method and log result" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Run command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__use_sudo +msgid "Run commands using 'sudo'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run in wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__n +msgid "Run next command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run on" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Running" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Running Now" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_auth_mode +msgid "SSH Auth Mode" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH Client is not defined." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "SSH Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__k +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_key +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key / Secret" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_password +msgid "SSH Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__secret_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_key_id +msgid "SSH Private Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "SSH Username" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__action__ssh_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "SSH command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH connection error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH execute command error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_port +msgid "SSH port" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Search Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Search Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +msgid "Search File Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Search Files" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Search Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Search Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Search Keys/Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_search_view +msgid "Search OS" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Search Server Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Search Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_search_view +msgid "Search Tags" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Search Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__s +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Secret" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__secret_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__applicability +msgid "" +"Selected server(s): only Commands that are specific to the selected server(s)\n" +"Non server restricted: all Commands that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_execute_wizard__applicability +msgid "" +"Selected server(s): only Flight Plans that are specific to the selected server(s)\n" +"Non server restricted: all Flight Plans that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__sequence +msgid "Sequence" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_ir_actions_server +msgid "Server Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_count +msgid "Server Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_log_ids +msgid "Server Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_log_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__name +msgid "Server Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_response +msgid "Server Response" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_status +msgid "Server Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_template_id +msgid "Server Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server_template +msgid "Server Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server URL, eg 'https://meme.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Server Version" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Server not found" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_response +msgid "" +"Server response received during the last operation.\n" +"Default value if no error happened is 'ok'.\n" +"Otherwise there will be a server error message logged." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Server tight" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__url +msgid "Server web interface, eg 'https://doge.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_ids +msgid "" +"Servers on which the command will be executed.\n" +"If empty, command canbe executed on all servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Set Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_status +msgid "" +"Set the following status if command is executed successfully. Leave " +"'Undefined' if you don't need to update the status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_settings +msgid "Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Show Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Show Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__show_servers +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__show_servers +msgid "Show Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Skipped" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Some servers don't support this command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Some variable options are invalid:\n" +"%(detailed_message)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__source +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__source +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Source" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Start date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__start_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__start_date +msgid "Started" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_status +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__status +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__s +msgid "String" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#, python-format +msgid "Success" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__use_sudo__p +msgid "Sudo with password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__use_sudo__n +msgid "Sudo without password" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Sync Error" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Synced" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_tag +msgid "Tag" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Tagged" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__tag_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_tag +msgid "Tags" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Template Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_template_action +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_template +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_template +msgid "Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Test Connection" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Text" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "" +"The access level for Variable Option '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Option Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"The access level for Variable Value '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Value Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "" +"The access level of command(s) '%(command_names)s' included in the current " +"Flight plan is higher than the access level of the Flight plan itself. " +"Please ensure that you want to allow those commands to be run anyway." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option +msgid "The combination of Name,Value and Variable must be unique." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "The file %(f_path)s not found." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Then" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "There are two default keys in the dictionary, e.g.:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "This Flightplan will be executed when the server is deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "" +"This action will be executed on error if no command action can be applied" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "This command can be used only in Flight Plans." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__note +msgid "This field is used to put some notes regarding template." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_template_mixin__code +msgid "This field will be rendered by default" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_template_id +msgid "" +"This file template will be used to create log files when server is created " +"from a template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +msgid "This flight plan will be run upon server creation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "" +"This is required, however you can change this later in the server settings" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +msgid "This template will be used to create or update the pushed file" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Time consumed for execution, seconds" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_tools +msgid "Tools" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_count +msgid "Total Files" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__tower +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__tower +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_mixin +msgid "Tower Variables mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cetmix_tower +msgid "Tower automation helper model" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_log_id +msgid "Triggered Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_type +msgid "Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__url +msgid "URL" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to delete file '%(f)s'.\n" +"Delete operation is not supported for 'server' type files." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_unread +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_unread +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_unread_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_unread_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_upload_action +msgid "Upload" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "Use Sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__use_sudo +msgid "Use sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH key in the following servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Used for" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_id +msgid "Used for selected server only. Leave blank to use globally" +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_user +msgid "User" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users who can view this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Value" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids_count +msgid "Value Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Variable" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Variable '%(var)s' can only be assigned to one of the models at a time: " +"Server, Server Template, or Plan Line Action." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +msgid "Variable Reference" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_value +#, python-format +msgid "Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_tower_variable_value_uniq +msgid "Variable can be declared only once for the same record!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_name_uniq +msgid "Variable names must be unique" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable not found" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Variable reference '%(var_ref)s' has an invalid option reference " +"'%(opt_ref)s'." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value created" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value updated" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +msgid "Variable values for selected record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable +msgid "Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Various fields may use Python code or Python expressions. The\n" +" following variables can be used:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Where command was executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Will be used instead of the command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "" +"Will use sudo based on server settings.If no sudo is configured will run " +"without sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__p +msgid "With password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__n +msgid "Without password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__wizard_id +msgid "Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "Wrong action" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You are not allowed to execute commands in wizard" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You cannot execute an empty command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You cannot run custom code on multiple servers at once." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/ir_actions_server.py:0 +#, python-format +msgid "" +"You need to have 'write' access to all servers you want to run this action " +"on." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "e.g. /home/user This field does NOT support variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "e.g. /such/much/{{ path }}, overrides command path" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/common.py:0 +#: code:addons/cetmix_tower_server/tests/common.py:0 +#, python-format +msgid "groups_ref must be string or list of strings!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "managers who can modify this record" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "managers who can modify this server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "managers who can modify this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "new server name" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "optional, eg /home/{{ tower.server.username }}" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "sudo password was not provided!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "then" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "this can be changed later" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "users who can access this server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "users who can access this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "users who can view this record" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "with tags" +msgstr "" diff --git a/addons/cetmix_tower_server/i18n/fi.po b/addons/cetmix_tower_server/i18n/fi.po new file mode 100644 index 0000000..fedcdf2 --- /dev/null +++ b/addons/cetmix_tower_server/i18n/fi.po @@ -0,0 +1,3449 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: fi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__source +msgid "" +"\n" +" - Tower: file is pushed from Tower to server.\n" +" - Server: file is pulled from server to Tower.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_user +msgid "" +"\n" +" Basic actions for selected servers.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_manager +msgid "" +"\n" +" Create and modify selected servers.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_root +msgid "" +"\n" +" Full control over all servers.\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Empty values for variables: %(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Missing variables: %(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "...save record to see the final expression or click the line to edit" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-days +msgid "1 day" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-hours +msgid "1 hour" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-months +msgid "1 month" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-weeks +msgid "1 week" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-years +msgid "1 year" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__10-minutes +msgid "10 min" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__12-hours +msgid "12 hour" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__2-hours +msgid "2 hour" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__30-minutes +msgid "30 min" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__6-hours +msgid "6 hour" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "AND" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"\n" +" x = 2*10\n" +" COMMAND_RESULT = {\"exit_code\": x, \"message\": \"This will be\n" +" logged as an error message because exit code !=0\"}\n" +" " +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"UserError: Warning Exception to use with \n" +" raise" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"env: Odoo Environment on which the action is\n" +" triggered" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"hashlib: Python 'hashlib' library.\n" +" Available methods: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256',
\n" +" 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"hmac: Python 'hmac' library. Use 'new' to create HMAC objects.
\n" +" Available methods on the HMAC *object*: 'update', 'copy', 'digest', 'hexdigest'.
\n" +" Module-level function: 'compare_digest'." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "json: Python 'json' library. Available methods: 'dumps'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"requests: Python 'requests' library. Available methods: 'post'," +" 'get', 'delete', 'request'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "server: Server on which the command is run" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"time, datetime, dateutil\n" +" , timezone: useful Python libraries" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "tower: 'cetmix.tower' helper class shortcut" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "user: Current Odoo user" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "" +"\n" +" &nbsp;" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_log.py:0 +#, python-format +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "IPv4 Address:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "IPv6 Address:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "Operating System:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Partner:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Servers:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_action +msgid "" +"A variable value cannot be assigned multiple times to the same plan line " +"action!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_template +msgid "" +"A variable value cannot be assigned multiple times to the same server " +"template!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_server +msgid "A variable value cannot be assigned multiple times to the same server!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Access" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__access_level +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower_server +msgid "Access Level" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level_warn_msg +msgid "Access Level Warn Msg" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "Access level is not defined for '%(option)s'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Access level is not defined for '%(variable)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__action +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "" +"Actions trigger based on command result. If empty next command will be " +"executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__active +msgid "Active" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_ids +msgid "Activities" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "Activity State" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_action +msgid "Add a new file" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_template_action +msgid "Add a new file template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Allow Parallel Run" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "An error occurred: %(error)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Another instance of the command is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__applicability +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__applicability +msgid "Applicability" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Archived" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Are you sure?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "Auto Sync" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync_interval +msgid "Auto Sync Interval" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Binary" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_view_form +msgid "" +"Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Cancel" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for '%(var)s' with value '%(val)s'.\n" +"Try to assigns it to a record instead." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for 'meme' with value 'Pepe'.\n" +"Try to assigns it to a record instead." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Cannot download %(f)s from server: Binary content is not supported for " +"'Text' file type" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot execute command\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Cannot pull %(f)s from server: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot remove test file using command.\n" +" CODE: %(status)s. ERROR: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower +#: model:ir.ui.menu,name:cetmix_tower_server.menu_root +msgid "Cetmix Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command +msgid "Cetmix Tower Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_command_execute_wizard_action +msgid "Cetmix Tower Execute Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server_ir_actions_server +#: model:ir.cron,cron_name:cetmix_tower_server.ir_cron_auto_pull_files_from_server +#: model:ir.cron,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server +msgid "Cetmix Tower File Management: Auto pull files from server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan +msgid "Cetmix Tower Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line +msgid "Cetmix Tower Flight Plan Line" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line_action +msgid "Cetmix Tower Flight Plan Line Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_log +msgid "Cetmix Tower Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_os +msgid "Cetmix Tower Operating System" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_plan_execute_wizard_action +msgid "Cetmix Tower Run Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server +msgid "Cetmix Tower Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_log +msgid "Cetmix Tower Server Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template +msgid "Cetmix Tower Server Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_tag +msgid "Cetmix Tower Tag" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable +msgid "Cetmix Tower Variable" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_option +msgid "Cetmix Tower Variable Options" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_value +msgid "Cetmix Tower Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_mixin +msgid "Cetmix Tower access mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_role_mixin +msgid "Cetmix Tower access role mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key +msgid "Cetmix Tower private key storage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_reference_mixin +msgid "Cetmix Tower reference mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_template_mixin +msgid "Cetmix Tower template rendering mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Child plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Code On Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__color +msgid "Color" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__command_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Command '%(cmd)s' is not compatible with the server '%(server)s'." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__command_domain +msgid "Command Domain" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command_log +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_log_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_log +#, python-format +msgid "Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Command Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__is_running +msgid "Command is being executed right now" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__command_id +msgid "" +"Command that will be executed to get the log data.\n" +"Be careful with commands that don't support parallel execution!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_line_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_root +msgid "Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__condition +msgid "Condition" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__condition +msgid "" +"Conditions under which this Flight Plan Line will be launched. e.g.: {{ " +"odoo_version}} == '14.0'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Configuration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__line_ids +msgid "Configuration Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Confirm" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection failed." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection successful." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Connection test passed! \n" +"%(res)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#, python-format +msgid "Create Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard +msgid "Create new server from template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard_line +msgid "Create new server from template variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_uid +msgid "Created by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_date +msgid "Created on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Custom Exit Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Custom label. Can be used for search/tracking" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file +msgid "Cx Tower File" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file_template +msgid "Cx Tower File Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Date and time of the latest successful synchronisation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Date and time of the next synchronisation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__path +msgid "Default Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "Default full file name with file type for example: test.txt" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_delete_action +msgid "Delete from server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_dir +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__server_dir +msgid "Directory on server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server__display_name +msgid "Display Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_download_action +msgid "Download" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Due to security restrictions you are not allowed to delete %(fp)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Duration" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "Duration, sec" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Each python code command returns the COMMAND_RESULT value\n" +" which is a dictionary." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Eg '/home/user' or '/var/log'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Enter Python code here. Help about Python expression is available in the " +"help tab of this document." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_error +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Error" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Error loading a private key. Unsupported key format or incorrect key." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Execute Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_execute_wizard +msgid "Execute Command in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_execute_wizard +msgid "Execute Flight Plan in Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Execute Result" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute flight plan error" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute flight plan error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute python code error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Execution Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__e +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__e +msgid "Exit with command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__ec +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__ec +msgid "Exit with custom exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Failed" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect after %(attempts)s attempts. Error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect. Error: %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Failure" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"File %(f)s is not 'tower' type. This operation is supported for 'tower' " +"files only" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"File %(f)s shouldn't have the '%(src)s' source for the '%(act)s' action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "File Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "File Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "File Type" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File already exists" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file_template.py:0 +#, python-format +msgid "File already exists on server." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__code +msgid "File content" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "File content with variables rendered" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File created and uploaded successfully" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File deleted!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File downloaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "File from template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__name +msgid "File name WITHOUT path. Eg 'test.txt'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File source cannot be determined: '%(source)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File that will be executed to get the log data" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File uploaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File will be disconnected from template. Continue?" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "File will be kept on server when deleted in Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_count +msgid "File(s)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Files" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files deleted!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files downloaded!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files uploaded!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Finish date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__finish_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__finish_date +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Finished" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_line_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Flight Plan Lines" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan_log +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan_log +msgid "Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Flight Plan Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Flight Plan line that is being currently executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan +msgid "Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "Flight plan '%(plan)s' is not compatible with the server '%(server)s'." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_channel_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_channel_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__color +msgid "For better visualization in views" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "For how long a flight plan is already running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__applicability__this +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_execute_wizard__applicability__this +msgid "For selected server(s)" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__full_server_path +msgid "Full Server Path" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "General Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__is_global +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Global" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Group By" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__has_missing_required_values +msgid "Has Missing Required Values" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Has Template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help with Python expressions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server__id +msgid "ID" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v4_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v4_address +msgid "IPv4 Address" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v6_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v6_address +msgid "IPv6 Address" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_unread +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_unread +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +msgid "" +"If enabled command can be run on the same server while the same command is still running.\n" +"Returns ANOTHER_COMMAND_RUNNING if execution is blocked" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "If enabled file will be synced automatically using cron" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +msgid "" +"If enabled flightplan can be run on the same server while the same flightplan is still running.\n" +"Returns -5 status is execution is blocked" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#, python-format +msgid "If exit code" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_plan.py:0 +#, python-format +msgid "If exit code == 35 then Exit with command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +msgid "Indicates if this variable is mandatory for server creation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Is Running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_skipped +msgid "Is Skipped" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "Keep When Deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__k +msgid "Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__key_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Key Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Key reference for inline usage" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key +msgid "Keys and Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Label" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Labeled" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server____last_update +msgid "Last Modified on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Last Sync Date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_date +msgid "Last Updated on" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Latest version of file content on server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__partner_id +msgid "Leave blank to use for any partner" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Leave blank to use with any partner" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Leave blank to use with any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__line_id +msgid "Line" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Local" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "" +"Location where command will be executed. Overrides command default path. You" +" can use {{ variables }} in path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__path +msgid "" +"Location where command will be executed. You can use {{ variables }} in path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_text +msgid "Log Text" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_type +msgid "Log Type" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_main_attachment_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_main_attachment_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__parent_flight_plan_log_id +msgid "Main Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_manager +msgid "Manager" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers who can modify this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_ids +msgid "Messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables +msgid "Missing Required Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables_message +msgid "Missing Required Variables Message" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_mixin +msgid "Mixin for managing secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Modify Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__name +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Next Sync Date" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Synced" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Template" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"No output received. Please log in manually and check for any issues.\n" +"===\n" +"CODE: %(status)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "No runner found for command action '%(cmd_action)s'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "No server found for the provided reference." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "No sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__applicability__shared +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_execute_wizard__applicability__shared +msgid "Non server restricted" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Not Running" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "Note" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_unread_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_unread_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_os +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "OS" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__os_ids +msgid "OSes" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_os +msgid "OSs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "On Delete Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "On Error" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Only one global value can be defined for variable '%(var)s'" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "Only one global value can be defined for variable 'meme'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open full form" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__os_id +msgid "Operating System" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__option_id +msgid "Option" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Option '%(val)s' is not available for variable '%(var)s'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__option_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__o +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Options" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__partner_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Partner" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__p +msgid "Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__path +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "Path" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_domain +msgid "Plan Domain" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__plan_line_action_id +msgid "Plan Line Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Plan Line Executed" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_plan_execute_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__plan_log_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_log_ids +#, python-format +msgid "Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Plan is being executed right now" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Plan line condition check failed." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide IPv4 or IPv6 address for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH Key for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH password for %(srv)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +#, python-format +msgid "" +"Please provide values for the following configuration variables: " +"%(variables)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "Please resolve the following issues with configuration variables:" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Please select a command to execute" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Post Run Actions" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Preview" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__parent_id +msgid "Previous Version" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Pull from Server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Push to Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__path +msgid "" +"Put custom path to run the command.\n" +"IMPORTANT: this field does NOT support variables!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here..." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__action__python_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Python code" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Recursive plan call detected in plan %(name)s." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Reference" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Reference Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_command_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_remote_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_source_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_os_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_action_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_reference_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_log_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_tag_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_reference_unique +msgid "Reference must be unique" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_key.py:0 +#, python-format +msgid "Reference must be unique for the combination of partner and server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "" +"Reference. Can contain English letters, digits and '_'. Leave blank to " +"autogenerate" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +msgid "Refresh" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Refresh All" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote\n" +" one." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote " +"one." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__rendered_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "Rendered Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_name +msgid "Rendered Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_server_dir +msgid "Rendered Server Dir" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__required +msgid "Required" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_response +msgid "Response" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__result +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Result" +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_root +msgid "Root" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"Run\n" +" Command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"Run\n" +" Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#, python-format +msgid "Run Command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_plan +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#, python-format +msgid "Run Flight Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run New Command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run Plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Run a flight plan" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "" +"Run code as it appears in 'Rendered code' in wizard and return to wizard. " +"Result will not be logged" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run code using sever method and log result" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Run command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__use_sudo +msgid "Run commands using 'sudo'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run in wizard" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__n +msgid "Run next command" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run on" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Running" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Running Now" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_auth_mode +msgid "SSH Auth Mode" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH Client is not defined." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "SSH Command" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__k +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_key +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key / Secret" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_password +msgid "SSH Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__secret_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_key_id +msgid "SSH Private Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "SSH Username" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__action__ssh_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "SSH command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH connection error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH execute command error %(err)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_port +msgid "SSH port" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Search Command Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Search Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +msgid "Search File Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Search Files" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Search Flight Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Search Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Search Keys/Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_search_view +msgid "Search OS" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Search Server Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Search Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_search_view +msgid "Search Tags" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Search Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__s +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Secret" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__secret_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__applicability +msgid "" +"Selected server(s): only Commands that are specific to the selected server(s)\n" +"Non server restricted: all Commands that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_execute_wizard__applicability +msgid "" +"Selected server(s): only Flight Plans that are specific to the selected server(s)\n" +"Non server restricted: all Flight Plans that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__sequence +msgid "Sequence" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_ir_actions_server +msgid "Server Action" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_count +msgid "Server Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_log_ids +msgid "Server Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_log_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server Logs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__name +msgid "Server Name" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_response +msgid "Server Response" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_status +msgid "Server Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_template_id +msgid "Server Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server_template +msgid "Server Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server URL, eg 'https://meme.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Server Version" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Server not found" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_response +msgid "" +"Server response received during the last operation.\n" +"Default value if no error happened is 'ok'.\n" +"Otherwise there will be a server error message logged." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Server tight" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__url +msgid "Server web interface, eg 'https://doge.example.com'" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_ids +msgid "" +"Servers on which the command will be executed.\n" +"If empty, command canbe executed on all servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Set Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_status +msgid "" +"Set the following status if command is executed successfully. Leave " +"'Undefined' if you don't need to update the status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_settings +msgid "Settings" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Show Commands" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Show Flight Plans" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__show_servers +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__show_servers +msgid "Show Servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Skipped" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Some servers don't support this command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Some variable options are invalid:\n" +"%(detailed_message)s" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__source +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__source +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Source" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Start date" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__start_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__start_date +msgid "Started" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_status +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__status +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__s +msgid "String" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#, python-format +msgid "Success" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__use_sudo__p +msgid "Sudo with password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__use_sudo__n +msgid "Sudo without password" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Sync Error" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Synced" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_tag +msgid "Tag" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Tagged" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__tag_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_tag +msgid "Tags" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Template Code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_template_action +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_template +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_template +msgid "Templates" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Test Connection" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Text" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "" +"The access level for Variable Option '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Option Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"The access level for Variable Value '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Value Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "" +"The access level of command(s) '%(command_names)s' included in the current " +"Flight plan is higher than the access level of the Flight plan itself. " +"Please ensure that you want to allow those commands to be run anyway." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option +msgid "The combination of Name,Value and Variable must be unique." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "The file %(f_path)s not found." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Then" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "There are two default keys in the dictionary, e.g.:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "This Flightplan will be executed when the server is deleted" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "" +"This action will be executed on error if no command action can be applied" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "This command can be used only in Flight Plans." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__note +msgid "This field is used to put some notes regarding template." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_template_mixin__code +msgid "This field will be rendered by default" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_template_id +msgid "" +"This file template will be used to create log files when server is created " +"from a template" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +msgid "This flight plan will be run upon server creation" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "" +"This is required, however you can change this later in the server settings" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +msgid "This template will be used to create or update the pushed file" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Time consumed for execution, seconds" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_tools +msgid "Tools" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_count +msgid "Total Files" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__tower +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__tower +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_mixin +msgid "Tower Variables mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cetmix_tower +msgid "Tower automation helper model" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_log_id +msgid "Triggered Plan Log" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_type +msgid "Type" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__url +msgid "URL" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to delete file '%(f)s'.\n" +"Delete operation is not supported for 'server' type files." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_unread +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_unread +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_unread_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_unread_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_upload_action +msgid "Upload" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "Use Sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__use_sudo +msgid "Use sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH Key" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH key in the following servers" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Used for" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_id +msgid "Used for selected server only. Leave blank to use globally" +msgstr "" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_user +msgid "User" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users who can view this record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Value" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids_count +msgid "Value Count" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Variable" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Variable '%(var)s' can only be assigned to one of the models at a time: " +"Server, Server Template, or Plan Line Action." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +msgid "Variable Reference" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_value +#, python-format +msgid "Variable Values" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_tower_variable_value_uniq +msgid "Variable can be declared only once for the same record!" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_name_uniq +msgid "Variable names must be unique" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable not found" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Variable reference '%(var_ref)s' has an invalid option reference " +"'%(opt_ref)s'." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value created" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value updated" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +msgid "Variable values for selected record" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable +msgid "Variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Various fields may use Python code or Python expressions. The\n" +" following variables can be used:" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Where command was executed" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Will be used instead of the command exit code" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "" +"Will use sudo based on server settings.If no sudo is configured will run " +"without sudo" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__p +msgid "With password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__n +msgid "Without password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__wizard_id +msgid "Wizard" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "Wrong action" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You are not allowed to execute commands in wizard" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You cannot execute an empty command" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You cannot run custom code on multiple servers at once." +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/ir_actions_server.py:0 +#, python-format +msgid "" +"You need to have 'write' access to all servers you want to run this action " +"on." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "e.g. /home/user This field does NOT support variables" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "e.g. /such/much/{{ path }}, overrides command path" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/common.py:0 +#: code:addons/cetmix_tower_server/tests/common.py:0 +#, python-format +msgid "groups_ref must be string or list of strings!" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "managers who can modify this record" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "managers who can modify this server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "managers who can modify this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "new server name" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "optional, eg /home/{{ tower.server.username }}" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "sudo password was not provided!" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "then" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "this can be changed later" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "users who can access this server" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "users who can access this template" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "users who can view this record" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "with tags" +msgstr "" diff --git a/addons/cetmix_tower_server/i18n/hr.po b/addons/cetmix_tower_server/i18n/hr.po new file mode 100644 index 0000000..ff65c72 --- /dev/null +++ b/addons/cetmix_tower_server/i18n/hr.po @@ -0,0 +1,3591 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: weblatereports@cetmix.com\n" +"PO-Revision-Date: 2025-03-06 09:11+0000\n" +"Last-Translator: Bole \n" +"Language-Team: Croatian \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_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__source +msgid "" +"\n" +" - Tower: file is pushed from Tower to server.\n" +" - Server: file is pulled from server to Tower.\n" +" " +msgstr "" +"\n" +" - Tower: datoteka se kopira sa Tower na server.\n" +" - Server: datoteka se povlači sa servera na Tower.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_user +msgid "" +"\n" +" Basic actions for selected servers.\n" +" " +msgstr "" +"\n" +" Osnovne radnje za odabrane servere.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_manager +msgid "" +"\n" +" Create and modify selected servers.\n" +" " +msgstr "" +"\n" +" Kreiraj i uredi odabrane servere.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_root +msgid "" +"\n" +" Full control over all servers.\n" +" " +msgstr "" +"\n" +" Puna kontrola nad svim serverima.\n" +" " + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Empty values for variables: %(variables)s" +msgstr " - Isprazni vrijednosti za varijable: %(variables)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Missing variables: %(variables)s" +msgstr " - Nedostaju variable: %(variables)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "...save record to see the final expression or click the line to edit" +msgstr "" +"... spremite zapis da biste vidjeli konačni izraz ili kliknite na liniju za " +"uređivanje" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-days +msgid "1 day" +msgstr "1 dan" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-hours +msgid "1 hour" +msgstr "1 sat" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-months +msgid "1 month" +msgstr "1 mjesec" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-weeks +msgid "1 week" +msgstr "1 tjedan" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__1-years +msgid "1 year" +msgstr "1 godina" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__10-minutes +msgid "10 min" +msgstr "10 min" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__12-hours +msgid "12 hour" +msgstr "12 sati" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__2-hours +msgid "2 hour" +msgstr "2 sata" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__30-minutes +msgid "30 min" +msgstr "30 min" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__auto_sync_interval__6-hours +msgid "6 hour" +msgstr "6 sati" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "AND" +msgstr "I" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"\n" +" x = 2*10\n" +" COMMAND_RESULT = {\"exit_code\": x, \"message\": \"This will be\n" +" logged as an error message because exit code !=0\"}\n" +" " +msgstr "" +"\n" +" x = 2*10\n" +" COMMAND_RESULT = {\"exit_code\": x, " +"\"message\": \"Ovo će biti \n" +" zapisano kao poruka greške jer je kod " +"izlaza !=0\"}\n" +" " + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"UserError: Warning Exception to use with \n" +" raise" +msgstr "" +"UserError: Poruka greške za korištenje sa \n" +" raise" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"env: Odoo Environment on which the action is\n" +" triggered" +msgstr "" +"env: Odoo Environment na kojem se pokreće\n" +" akcija" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"hashlib: Python 'hashlib' library.\n" +" Available methods: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256',
\n" +" 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" +msgstr "" +"hashlib: Python 'hashlib' library.\n" +" Dostupne metode: 'sha1', 'sha224', " +"'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256',
\n" +" 'sha3_384', 'sha3_512', " +"'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"hmac: Python 'hmac' library. Use 'new' to create HMAC objects.
\n" +" Available methods on the HMAC *object*: 'update', 'copy', 'digest', 'hexdigest'.
\n" +" Module-level function: 'compare_digest'." +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "json: Python 'json' library. Available methods: 'dumps'" +msgstr "json: Python 'json' library. Dostupne metode: 'dumps'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"requests: Python 'requests' library. Available methods: 'post'," +" 'get', 'delete', 'request'" +msgstr "" +"requests: Python 'requests' library. Dostupne metode: 'post', " +"'get', 'delete', 'request'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "server: Server on which the command is run" +msgstr "server: Server na kojem se pokreće naredba" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"time, datetime, dateutil\n" +" , timezone: useful Python libraries" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "tower: 'cetmix.tower' helper class shortcut" +msgstr "tower: 'cetmix.tower' prečac do pomoćne klase" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "user: Current Odoo user" +msgstr "user: Trenutni Odoo korisnik" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"" +msgstr "" +"" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "" +"\n" +" &nbsp;" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_log.py:0 +#, python-format +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "IPv4 Address:" +msgstr "IPv4 Adresa:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "IPv6 Address:" +msgstr "IPv6 Adresa:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban_manager +msgid "Operating System:" +msgstr "Operativni sistem:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Partner:" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Servers:" +msgstr "Serveri:" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_action +msgid "" +"A variable value cannot be assigned multiple times to the same plan line " +"action!" +msgstr "" +"Vrijednost varijable nije moguće dodijeliti više puta na jednu akciju plana!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_template +msgid "" +"A variable value cannot be assigned multiple times to the same server " +"template!" +msgstr "" +"Vrijednost varijable nije moguće dodijeliti više puta na jedan predložak " +"servera!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_server +msgid "A variable value cannot be assigned multiple times to the same server!" +msgstr "Vrijednost varijable nije moguće dodijeliti više puta na isti server!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Access" +msgstr "Pristup" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__access_level +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower_server +msgid "Access Level" +msgstr "Razina pristupa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level_warn_msg +msgid "Access Level Warn Msg" +msgstr "Poruka upozorenja za Razinu pristupa" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "Access level is not defined for '%(option)s'" +msgstr "Razina pristupa nije definirana za '%(option)s'" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Access level is not defined for '%(variable)s'" +msgstr "Razina pristupa nije definirana za '%(variable)s'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__action +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Action" +msgstr "Akcija" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "Action Needed" +msgstr "Potrebna radnja" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "Actions" +msgstr "Akcije" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "" +"Actions trigger based on command result. If empty next command will be " +"executed" +msgstr "" +"Pokretač akcije baziran na rezultatu naredbe. Ako je prazno sljedeća naredba " +"će biti izvršena" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__active +msgid "Active" +msgstr "Aktivno" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_ids +msgid "Activities" +msgstr "Aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "Dekoracija izuzetka aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "Activity State" +msgstr "Status aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Activity Type Icon" +msgstr "Ikona tipa aktivnosti" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_action +msgid "Add a new file" +msgstr "Dodaj novu datoteku" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_template_action +msgid "Add a new file template" +msgstr "Dodaj novi predložak" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Allow Parallel Run" +msgstr "Dozvoli paralelno pokretanje" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "An error occurred: %(error)s" +msgstr "Došlo je do greške: %(error)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Another instance of the command is already running" +msgstr "Druga instanca naredbe je već pokrenuta" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__applicability +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__applicability +msgid "Applicability" +msgstr "Primjenjivost" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Archived" +msgstr "Arhivirano" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Are you sure?" +msgstr "Jeste li sigurni?" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_attachment_count +msgid "Attachment Count" +msgstr "Broj priloga" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "Auto Sync" +msgstr "Auto sinkronizacija" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync_interval +msgid "Auto Sync Interval" +msgstr "Interval auto sinkronizacije" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Binary" +msgstr "Binarno" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_view_form +msgid "" +"Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "" +"Može sadržavati slova engleske abecede, brojke i ':'. Ostavite prazno za " +"automatsko generiranje" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Cancel" +msgstr "Odustani" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for '%(var)s' with value '%(val)s'.\n" +"Try to assigns it to a record instead." +msgstr "" +"Nije moguće promijeniti status 'global' za '%(var)s' sa vrijednošću " +"'%(val)s'.\n" +"Pokušajte ju dodijeliti nekom zapisu." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for 'meme' with value 'Pepe'.\n" +"Try to assigns it to a record instead." +msgstr "" +"Nije moguće promijeniti 'global' status za 'meme' sa vrijednosti 'Pepe'.\n" +"Pokušajte ju dodijeliti nekom zapisu." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Cannot download %(f)s from server: Binary content is not supported for " +"'Text' file type" +msgstr "" +"Nije moguće preuzeti %(f)s sa servera: Binarni sadržaj nije podržan za " +"datoteke tipa 'Tekst'" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot execute command\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" +msgstr "" +"Nije moguće izvršiti naredbu\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Cannot pull %(f)s from server: %(err)s" +msgstr "Nije moguće preuzeti %(f)s sa servera: %(err)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot remove test file using command.\n" +" CODE: %(status)s. ERROR: %(err)s" +msgstr "" +"Nije moguće ukloniti datoteku korištenjem naredbe.\n" +" CODE: %(status)s. ERROR: %(err)s" + +#. module: cetmix_tower_server +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower +#: model:ir.ui.menu,name:cetmix_tower_server.menu_root +msgid "Cetmix Tower" +msgstr "Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command +msgid "Cetmix Tower Command" +msgstr "Cetmix Tower Naredba" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "Cetmix Tower Log Naredbi" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_command_execute_wizard_action +msgid "Cetmix Tower Execute Command" +msgstr "Cetmix Tower Izvrši naredbu" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server_ir_actions_server +#: model:ir.cron,cron_name:cetmix_tower_server.ir_cron_auto_pull_files_from_server +#: model:ir.cron,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server +msgid "Cetmix Tower File Management: Auto pull files from server" +msgstr "Cetmix Tower Upravljanje datotekama: Automatski povuci sa servera" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan +msgid "Cetmix Tower Flight Plan" +msgstr "Cetmix Tower Plan Leta" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line +msgid "Cetmix Tower Flight Plan Line" +msgstr "Cetmix Tower Stavka plana leta" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line_action +msgid "Cetmix Tower Flight Plan Line Action" +msgstr "Cetmix Tower Akcija stavke plana leta" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_log +msgid "Cetmix Tower Flight Plan Log" +msgstr "Cetmix Tower Log plana leta" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_os +msgid "Cetmix Tower Operating System" +msgstr "Cetmix Tower Operativni sistem" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_plan_execute_wizard_action +msgid "Cetmix Tower Run Flight Plan" +msgstr "Cetmix Tower pokreni Plan leta" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server +msgid "Cetmix Tower Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_log +msgid "Cetmix Tower Server Log" +msgstr "Cetmix Tower Log servera" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template +msgid "Cetmix Tower Server Template" +msgstr "Cetmix Tower Predložak servera" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_tag +msgid "Cetmix Tower Tag" +msgstr "Cetmix Tower Oznaka" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable +msgid "Cetmix Tower Variable" +msgstr "Cetmix Tower Varijabla" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_option +msgid "Cetmix Tower Variable Options" +msgstr "Cetmix Tower Opcija varijable" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_value +msgid "Cetmix Tower Variable Values" +msgstr "Cetmix Tower Vrijednosti varijabli" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_mixin +msgid "Cetmix Tower access mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_role_mixin +msgid "Cetmix Tower access role mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key +msgid "Cetmix Tower private key storage" +msgstr "Cetmix Tower Pohrana privatnih ključeva" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_reference_mixin +msgid "Cetmix Tower reference mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_template_mixin +msgid "Cetmix Tower template rendering mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Child plans" +msgstr "Podređeni planovi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Code" +msgstr "Kod" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Code On Server" +msgstr "Kod na serveru" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__color +msgid "Color" +msgstr "Boja" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__command_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Command" +msgstr "Naredba" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Command '%(cmd)s' is not compatible with the server '%(server)s'." +msgstr "Naredba '%(cmd)s' nije kompatabilna sa serverom '%(server)s'." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command Code" +msgstr "Kod naredbe" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__command_domain +msgid "Command Domain" +msgstr "Domena naredbe" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command_log +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_log_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_log +#, python-format +msgid "Command Log" +msgstr "Log naredbe" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Command Logs" +msgstr "Logovi naredbi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__is_running +msgid "Command is being executed right now" +msgstr "Naredba se upravo izvršava" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__command_id +msgid "" +"Command that will be executed to get the log data.\n" +"Be careful with commands that don't support parallel execution!" +msgstr "" +"Naredba koja će biti izvršena za dohvaćanje podataka loga.\n" +"Budite oprezni sa naredbama koje ne podržavaju paralelno izvršavanje!" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_line_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_root +msgid "Commands" +msgstr "Naredbe" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__condition +msgid "Condition" +msgstr "Uvijet" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__condition +msgid "" +"Conditions under which this Flight Plan Line will be launched. e.g.: {{ " +"odoo_version}} == '14.0'" +msgstr "" +"Uvijeti pod kojima će se ovaj PlanLeta pokrenuti, nrp: {{ odoo_version }} == " +"'14.0'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Configuration" +msgstr "Konfiguracija" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__line_ids +msgid "Configuration Variables" +msgstr "Varijable konfiguracije" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Confirm" +msgstr "Potvrdi" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection failed." +msgstr "Uspostava veze nije uspjela." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection successful." +msgstr "Veza uspostavljena uspješno." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Connection test passed! \n" +"%(res)s" +msgstr "" +"Test veze prošao! \n" +"%(res)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#, python-format +msgid "Create Server" +msgstr "Kreiraj server" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard +msgid "Create new server from template" +msgstr "Kreiraj novi server iz predloška" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard_line +msgid "Create new server from template variables" +msgstr "Kreiraj novi server iz varijabli predloška" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_uid +msgid "Created by" +msgstr "Kreirao" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_date +msgid "Created on" +msgstr "Kreirano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Custom Exit Code" +msgstr "Prilagođeni kod izlaza" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Custom label. Can be used for search/tracking" +msgstr "Prilagođeni natpis. Može se koristiti za pretraživanje/praćenje" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file +msgid "Cx Tower File" +msgstr "Cx Tower datoteka" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file_template +msgid "Cx Tower File Template" +msgstr "CxTower Predložak datoteke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Date and time of the latest successful synchronisation" +msgstr "Datum i vrijeme posljednje uspješne sinkronizacije" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Date and time of the next synchronisation" +msgstr "Datum i vrijeme sljedeće sinkronizacije" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__path +msgid "Default Path" +msgstr "Zadana putanja" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "Default full file name with file type for example: test.txt" +msgstr "Zadani puni naziv datoteke sa vrstom datoteke: npr: test.txt" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_delete_action +msgid "Delete from server" +msgstr "Obriši sa servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_dir +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__server_dir +msgid "Directory on server" +msgstr "Direktorij na serveru" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server__display_name +msgid "Display Name" +msgstr "Naziv" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_download_action +msgid "Download" +msgstr "Preuzmi" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Due to security restrictions you are not allowed to delete %(fp)s" +msgstr "Zbog sigurnosnih ograničenja nije vam dozvoljeno brisanje: %(fp)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Duration" +msgstr "Trajanje" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "Duration, sec" +msgstr "Trajanje, sek" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Each python code command returns the COMMAND_RESULT value\n" +" which is a dictionary." +msgstr "" +"Svaka naredba tipa python code vraća COMMAND_RESULT vrijednost\n" +" koja je dictionary." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Eg '/home/user' or '/var/log'" +msgstr "Npr. '/home/user' ili '/var/log'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Enter Python code here. Help about Python expression is available in the " +"help tab of this document." +msgstr "" +"Unesite python kod ovdje. Pomoć oko Python izraza je dostupna na kartici " +"pomoći ovog dokumenta." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_error +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Error" +msgstr "Greška" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Error loading a private key. Unsupported key format or incorrect key." +msgstr "" +"Greška pri učitavanju privatnog ključa. Nepodržani format ključa ili " +"neispravan ključ." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Execute Command" +msgstr "Izvrši naredbu" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_execute_wizard +msgid "Execute Command in Wizard" +msgstr "Izvrši naredbu u Čarobnjaku" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_execute_wizard +msgid "Execute Flight Plan in Wizard" +msgstr "Izvrši Plan leta u Čarobnjaku" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Execute Result" +msgstr "Izvrši rezultat" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute flight plan error" +msgstr "Greška izvršavanja plana leta" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute flight plan error %(err)s" +msgstr "Greška izvršavanja plana leta: %(err)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Execute python code error: %(err)s" +msgstr "Greška izvršavanja Python coda: %(err)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Execution Path" +msgstr "Putanja izvršavanja" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "Izlazni kod" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__e +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__e +msgid "Exit with command exit code" +msgstr "Izađi iz naredbe sa izlaznim kodom naredbe" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__ec +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__ec +msgid "Exit with custom exit code" +msgstr "Izađi sa prilagođenim izlaznim kodom" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Failed" +msgstr "Neuspješno" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect after %(attempts)s attempts. Error: %(err)s" +msgstr "Neuspješno povezivanje nakon %(attempts)s pokušaja. Greška: %(err)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect. Error: %(err)s" +msgstr "Neuspješno povezivanje. Greška: %(err)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Failure" +msgstr "Neuspjeh" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File" +msgstr "Datoteka" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"File %(f)s is not 'tower' type. This operation is supported for 'tower' " +"files only" +msgstr "" +"Datoteka %(f)s nije tipa 'tower'. Ova operacija je podržana samo za 'tower' " +"tip datoteka" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"File %(f)s shouldn't have the '%(src)s' source for the '%(act)s' action" +msgstr "Datoteka %(f)s nebi trebala imati '%(src)s' izvor za '%(act)s' akciju" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "File Name" +msgstr "Naziv datoteke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "File Template" +msgstr "Predložak datoteke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "File Type" +msgstr "Tip datoteke" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File already exists" +msgstr "Datoteka već postoji" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file_template.py:0 +#, python-format +msgid "File already exists on server." +msgstr "Datoteka već postoji na serveru." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__code +msgid "File content" +msgstr "Sadržaj datoteke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "File content with variables rendered" +msgstr "Sadržaj datoteke sa vrijednostima varijabli" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File created and uploaded successfully" +msgstr "Datoteka uspješno kreirana i prebačena na server" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File deleted!" +msgstr "Datoteka obrisana!" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File downloaded!" +msgstr "Datoteka preuzeta!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "File from template" +msgstr "Datoteka iz predloška" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File name" +msgstr "Naziv datoteke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__name +msgid "File name WITHOUT path. Eg 'test.txt'" +msgstr "Naziv datoteke BEZ putanje. Npr: 'test.txt'" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File source cannot be determined: '%(source)s'" +msgstr "Izvor datoteke nije moguće odrediti: '%(source)s'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File that will be executed to get the log data" +msgstr "Datoteka koja će biti izvršena za dohvaćanje log podataka" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File uploaded!" +msgstr "Datoteka poslana na server!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File will be disconnected from template. Continue?" +msgstr "Datoteka će raskinuti vezu sa predloškom. Nastaviti?" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "File will be kept on server when deleted in Tower" +msgstr "Datoteka će biti ostavljena na serveru i nakon brisanja na Toweru" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_count +msgid "File(s)" +msgstr "Dototeka(e)" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Files" +msgstr "Datoteke" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files deleted!" +msgstr "Datoteke obrisane!" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files downloaded!" +msgstr "Datoteke preuzete!" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files uploaded!" +msgstr "Datoteke poslane na server!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Finish date" +msgstr "Datum završetka" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__finish_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__finish_date +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Finished" +msgstr "Završeno" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Flight Plan" +msgstr "Plan leta" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_line_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Flight Plan Lines" +msgstr "Stavke Plana leta" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan_log +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan_log +msgid "Flight Plan Log" +msgstr "Log Plana leta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Flight Plan Logs" +msgstr "Logovi Plana leta" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Flight Plan line that is being currently executed" +msgstr "Plan leta koji se trenutno izvršava" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan +msgid "Flight Plans" +msgstr "Planovi leta" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "Flight plan '%(plan)s' is not compatible with the server '%(server)s'." +msgstr "Plan leta '%(plan)s' nije kompatabilan sa serverom '%(server)s'." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_follower_ids +msgid "Followers" +msgstr "Pratitelji" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_channel_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_channel_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_channel_ids +msgid "Followers (Channels)" +msgstr "Pratitelji (kanali)" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_partner_ids +msgid "Followers (Partners)" +msgstr "Pratitelji (Partneri)" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "Font awesome ikona npr. fa-tasks" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__color +msgid "For better visualization in views" +msgstr "Za bolu vizualizaciju u pregledima" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "For how long a flight plan is already running" +msgstr "Koliko dugo je plan već pokrenut" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__applicability__this +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_execute_wizard__applicability__this +msgid "For selected server(s)" +msgstr "Za odabrane server(e)" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__full_server_path +msgid "Full Server Path" +msgstr "Puna putanja servera" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "General Settings" +msgstr "Generalne postavke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__is_global +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Global" +msgstr "Globalno" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Group By" +msgstr "Grupiraj po" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__has_missing_required_values +msgid "Has Missing Required Values" +msgstr "Nedostaju obavezne vrijednosti" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Has Template" +msgstr "Ima predložak" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help" +msgstr "Pomoć" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help with Python expressions" +msgstr "Pomoć oko Python izraza" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server__id +msgid "ID" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v4_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v4_address +msgid "IPv4 Address" +msgstr "IPv4 Adresa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v6_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v6_address +msgid "IPv6 Address" +msgstr "IPv6 Adresa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon" +msgstr "Ikona" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "Ikona koja indicira izuzetak aktivnosti." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_unread +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_unread +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_unread +msgid "If checked, new messages require your attention." +msgstr "Ako je označeno, nove poruke traže vašu pozornost." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "Ako je označeno, neke poruke imaju greške pri isporuci." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +msgid "" +"If enabled command can be run on the same server while the same command is still running.\n" +"Returns ANOTHER_COMMAND_RUNNING if execution is blocked" +msgstr "" +"Ako je omogućeno, naredbe se mogu izvršavati na istom serveru dok se ista " +"naredba već izvršava.\n" +"Vraća ANOTHER_COMMAND_RUNNING ako je izvršavanje blokirano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "If enabled file will be synced automatically using cron" +msgstr "" +"Ako je omogućeno datoteka će biti sinkronitzirana automatski korištenjem " +"crona" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +msgid "" +"If enabled flightplan can be run on the same server while the same flightplan is still running.\n" +"Returns -5 status is execution is blocked" +msgstr "" +"Ako je označeno Plan leta može biti pokrenut na istom serveru dok je isti " +"plan leta već pokrenut.\n" +"Vraća status -5 ako je izvršavanje blokirano" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#, python-format +msgid "If exit code" +msgstr "Ako je kod izlaza" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_plan.py:0 +#, python-format +msgid "If exit code == 35 then Exit with command exit code" +msgstr "Ako je kod izlaza == 35 tada Izađi sa izlaznim kodom naredbe" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +msgid "Indicates if this variable is mandatory for server creation" +msgstr "Označava da je varijabla obavezna za kreiranje servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_is_follower +msgid "Is Follower" +msgstr "Je pratitelj" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Is Running" +msgstr "Je pokrenut" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_skipped +msgid "Is Skipped" +msgstr "Je preskočen" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "Keep When Deleted" +msgstr "Zadrži nakon brisanja" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__k +msgid "Key" +msgstr "Ključ" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__key_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Key Type" +msgstr "Tip ključa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Key reference for inline usage" +msgstr "Referenca ključa za korištenje u kodu" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key +msgid "Keys and Secrets" +msgstr "Ključevi i Tajne" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Label" +msgstr "Labela" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Labeled" +msgstr "Obilježeno" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cetmix_tower____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value____last_update +#: model:ir.model.fields,field_description:cetmix_tower_server.field_ir_actions_server____last_update +msgid "Last Modified on" +msgstr "Zadnje modificirano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Last Sync Date" +msgstr "Datum posljednje sinkronizacije" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_uid +msgid "Last Updated by" +msgstr "Zadnji ažurirao" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_date +msgid "Last Updated on" +msgstr "Zadnje ažurirano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Latest version of file content on server" +msgstr "Zadnja verzija sadržaja datoteke na serveru" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__partner_id +msgid "Leave blank to use for any partner" +msgstr "Ostavite prazno za korištenje sa bilo kojim partnerom" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Leave blank to use with any partner" +msgstr "Ostavite prazno za korištenje sa bilo kojim partnerom" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Leave blank to use with any server" +msgstr "Ostavite prazno za korištenje na bilo kojem serveru" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__line_id +msgid "Line" +msgstr "Stavka" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Local" +msgstr "Localno" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "" +"Location where command will be executed. Overrides command default path. You" +" can use {{ variables }} in path" +msgstr "" +"Lokacija na kojoj će naredba biti izvršena. Nadjačava zadanu putanju " +"naredbe. Možete koristiti {{ varijable }} u putanji" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__path +msgid "" +"Location where command will be executed. You can use {{ variables }} in path" +msgstr "" +"Lokacija na kojoj će naredba biti izvršena. Možete koristiti {{varijable }} " +"u putanji" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_text +msgid "Log Text" +msgstr "Sadržaj loga" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_type +msgid "Log Type" +msgstr "Tip loga" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Logs" +msgstr "Logovi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_main_attachment_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_main_attachment_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_main_attachment_id +msgid "Main Attachment" +msgstr "Glavni prilog" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__parent_flight_plan_log_id +msgid "Main Log" +msgstr "Glavni log" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plan" +msgstr "Glavni plan" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plans" +msgstr "Glavni planovi" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_manager +msgid "Manager" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers" +msgstr "Manageri" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers who can modify this record" +msgstr "Manageri koji mogu urediti ovaj zapis" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "Message Delivery error" +msgstr "Greška pri isporuci poruke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_ids +msgid "Messages" +msgstr "Poruke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables +msgid "Missing Required Variables" +msgstr "Nedostaju obavezne variable" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables_message +msgid "Missing Required Variables Message" +msgstr "Poruka za nedostajuće obavezne varijable" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_mixin +msgid "Mixin for managing secrets" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Modify Code" +msgstr "Modificiraj kod" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "Krajnji rok moje aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__name +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Name" +msgstr "Naziv" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "Krajnji rok sljedeće aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_summary +msgid "Next Activity Summary" +msgstr "Sažetak sljedeće aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_id +msgid "Next Activity Type" +msgstr "Tip sljedeće aktivnosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Next Sync Date" +msgstr "Datum sljedeće sinkronizacije" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Synced" +msgstr "Nije sinkronizirano" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Template" +msgstr "Nema predloška" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"No output received. Please log in manually and check for any issues.\n" +"===\n" +"CODE: %(status)s" +msgstr "" +"Rezultat nije primljen. Molimo prijavite se ručno i provjerite ima li " +"problema.\n" +"===\n" +"KOD: %(status)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "No runner found for command action '%(cmd_action)s'" +msgstr "Nije pronađen pokretač za akciju komande '%(cmd_action)s'" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "No server found for the provided reference." +msgstr "Nije pronađen server za upisanu referencu." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "No sudo" +msgstr "Bez sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__applicability__shared +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_execute_wizard__applicability__shared +msgid "Non server restricted" +msgstr "Nema ograničenja za servere" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Not Running" +msgstr "Nije pokrenut" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "Note" +msgstr "Bilješka" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of Actions" +msgstr "Broj akcija" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of errors" +msgstr "Broj grešaka" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "Broj poruka koje zahtijevaju neku akciju" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Broj poruka sa greškama pri isporuci" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_unread_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_unread_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_unread_counter +msgid "Number of unread messages" +msgstr "Broj nepročitanih poruka" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_os +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "OS" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__os_ids +msgid "OSes" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_os +msgid "OSs" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "On Delete Plan" +msgstr "Plan za brisanje" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "On Error" +msgstr "Nakon Greške" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Only one global value can be defined for variable '%(var)s'" +msgstr "" +"Samo jedna globalna vrijednost može biti definirana za varijablu '%(var)s'" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "Only one global value can be defined for variable 'meme'" +msgstr "Samo jedna globalna vrijednost može biti definirna za varijablu 'meme'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open" +msgstr "Otvori" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open full form" +msgstr "Otvori punu formu" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__os_id +msgid "Operating System" +msgstr "Operativni sustav" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__option_id +msgid "Option" +msgstr "Opcija" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Option '%(val)s' is not available for variable '%(var)s'" +msgstr "Opcija '%(val)s' nije dostupna za varijablu '%(var)s'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__option_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__o +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Options" +msgstr "Opcije" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__partner_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Partner" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__p +msgid "Password" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__path +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "Path" +msgstr "Putanja" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__plan_domain +msgid "Plan Domain" +msgstr "Domena plana" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__plan_line_action_id +msgid "Plan Line Action" +msgstr "Akcija stavke plana" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Plan Line Executed" +msgstr "Stavka plana izvršena" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_plan_execute_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__plan_log_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_log_ids +#, python-format +msgid "Plan Log" +msgstr "Log plana" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Plan is being executed right now" +msgstr "Plan se trenutno izvršava" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Plan line condition check failed." +msgstr "Provjera uvijeta stavke plana nije uspjela." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide IPv4 or IPv6 address for %(srv)s" +msgstr "Molimo upišite IPv4 ili IPv6 adresu za %(srv)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH Key for %(srv)s" +msgstr "Molimo unesite SSH ključ za %(srv)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH password for %(srv)s" +msgstr "Molimo upišite SSH password za %(srv)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +#, python-format +msgid "" +"Please provide values for the following configuration variables: " +"%(variables)s" +msgstr "" +"Upišite vrijednosti za sljedeće konfiguracijske varijable: %(variables)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "Please resolve the following issues with configuration variables:" +msgstr "Molimo riješite sljedeće probleme sa konfiguracijskim varijablama:" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Please select a command to execute" +msgstr "Molim odaberite naredbu za izvršavanje" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Post Run Actions" +msgstr "Akcije nakon izvršavanja" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Preview" +msgstr "Pregled" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__parent_id +msgid "Previous Version" +msgstr "Prethodna verzija" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Pull from Server" +msgstr "Povuci sa servera" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Push to Server" +msgstr "Prebaci na server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__path +msgid "" +"Put custom path to run the command.\n" +"IMPORTANT: this field does NOT support variables!" +msgstr "" +"Stavite prilagođenu putanju za pokretanje naredbe.\n" +"VAŽNO: ovo polje NE PODRŽAVA varijable!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here" +msgstr "Upišite Vaše bilješke ovdje" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here..." +msgstr "Stavite vaše bilješke ovdje ..." + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__action__python_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Python code" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Recursive plan call detected in plan %(name)s." +msgstr "Rekurzivni plan detektiran u planu %(name)s." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Reference" +msgstr "Referenca" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Reference Code" +msgstr "Kod reference" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_command_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_remote_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_source_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_os_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_action_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_reference_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_log_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_tag_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_reference_unique +msgid "Reference must be unique" +msgstr "Referenca mora biti jedinstvena" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_key.py:0 +#, python-format +msgid "Reference must be unique for the combination of partner and server" +msgstr "Referenca mora biti jedinstvena za kombinaciju partnera i servera" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "" +"Reference. Can contain English letters, digits and '_'. Leave blank to " +"autogenerate" +msgstr "" +"Referenca. Može sadržavati slova engleske abecede, brojke i '_'. Ostavite " +"prazno za automatsko generiranje" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +msgid "Refresh" +msgstr "Osvježi" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Refresh All" +msgstr "Osvježi sve" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote\n" +" one." +msgstr "" +"Zapamtite: Python kod se izvršava na Tower serveru, ne na udaljenom\n" +" serveru." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote " +"one." +msgstr "" +"Zapamtite: Python kod se izvršava na Tower serveru, ne na udaljenom serveru." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__rendered_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "Rendered Code" +msgstr "Renderirani kod" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_name +msgid "Rendered Name" +msgstr "Renderirani naziv" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_server_dir +msgid "Rendered Server Dir" +msgstr "Renderirani direktorij na serveru" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__required +msgid "Required" +msgstr "Obavezno" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_response +msgid "Response" +msgstr "Odgovor" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_user_id +msgid "Responsible User" +msgstr "Odgovorni korisnik" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__result +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Result" +msgstr "Rezultat" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_root +msgid "Root" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run" +msgstr "Pokreni" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"Run\n" +" Command" +msgstr "" +"Pokreni\n" +" " +"Naredbu" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "" +"Run\n" +" Flight Plan" +msgstr "" +"Pokreni\n" +" Plan leta" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#, python-format +msgid "Run Command" +msgstr "Pokreni Naredbu" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_plan +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#, python-format +msgid "Run Flight Plan" +msgstr "Pokreni Plan Leta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run New Command" +msgstr "Pokreni novu naredbu" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run Plan" +msgstr "Pokreni Plan" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Run a flight plan" +msgstr "Pokreni plan leta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "" +"Run code as it appears in 'Rendered code' in wizard and return to wizard. " +"Result will not be logged" +msgstr "" +"Pokreni kod kako izgleda u 'Renderiranom kodu' u čarobnjaku i vrati se u " +"čarobnjak. Rezultat neće biti upisan u log" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run code using sever method and log result" +msgstr "Pokreni kod korištenjem metode servera i zapiši rezultat u log" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Run command" +msgstr "Pokreni naredbu" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__use_sudo +msgid "Run commands using 'sudo'" +msgstr "Pokreni naredbu koristeći 'sudo'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Run in wizard" +msgstr "Pokreni u čarobnjaku" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__n +msgid "Run next command" +msgstr "Pokreni sljedeću naredbu" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Run on" +msgstr "Pokreni na" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Running" +msgstr "Pokrenut" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Running Now" +msgstr "Izvršava se sada" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_auth_mode +msgid "SSH Auth Mode" +msgstr "Način autentikacije za SSH" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH Client is not defined." +msgstr "SSH clijent nije definiran." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "SSH Command" +msgstr "SSH naredba" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__k +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key" +msgstr "SSH ključ" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_key +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key / Secret" +msgstr "SSH ključ/tajna" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_password +msgid "SSH Password" +msgstr "SSH password" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__secret_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_key_id +msgid "SSH Private Key" +msgstr "SSH privatni ključ" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "SSH Username" +msgstr "SSH korisničko ime" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__action__ssh_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "SSH command" +msgstr "SSH naredba" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH connection error %(err)s" +msgstr "Greška SSH povezivanja %(err)s" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH execute command error %(err)s" +msgstr "SSH greška izvršavanja naredbe %(err)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_port +msgid "SSH port" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Search Command Log" +msgstr "Pretraži log naredbi" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Search Commands" +msgstr "Pretraži naredbe" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +msgid "Search File Templates" +msgstr "Pretraži predloške datoteka" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Search Files" +msgstr "Pretraži datoteke" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Search Flight Plan Log" +msgstr "Pretraži planove leta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Search Flight Plans" +msgstr "Pretraži planove leta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Search Keys/Secrets" +msgstr "Pretraži Ključeve/Tajne" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_search_view +msgid "Search OS" +msgstr "Pretraži Operativne sisteme" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Search Server Templates" +msgstr "Pretraži predloške servera" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Search Servers" +msgstr "Pretraži servere" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_search_view +msgid "Search Tags" +msgstr "Pretraži oznake" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Search Values" +msgstr "Pretraži vrijednosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__s +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Secret" +msgstr "Tajna" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__secret_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Secrets" +msgstr "Tajne" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__applicability +msgid "" +"Selected server(s): only Commands that are specific to the selected server(s)\n" +"Non server restricted: all Commands that are not specific to any server" +msgstr "" +"Odabrani server(i): Samo naredbe koje su specifične za odabrane servere\n" +"Bez ograničenja servera: sve naredbe koje nisu specifične za bilokoji server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_execute_wizard__applicability +msgid "" +"Selected server(s): only Flight Plans that are specific to the selected server(s)\n" +"Non server restricted: all Flight Plans that are not specific to any server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__sequence +msgid "Sequence" +msgstr "Sekvenca" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Server" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_ir_actions_server +msgid "Server Action" +msgstr "Serverska akcija" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_count +msgid "Server Count" +msgstr "Broj servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_log_ids +msgid "Server Log" +msgstr "Log servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_log_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server Logs" +msgstr "Logovi servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__name +msgid "Server Name" +msgstr "Naziv servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_response +msgid "Server Response" +msgstr "Odgovor servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_status +msgid "Server Status" +msgstr "Status servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_template_id +msgid "Server Template" +msgstr "Predložak servera" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server_template +msgid "Server Templates" +msgstr "Predlošci servera" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server URL, eg 'https://meme.example.com'" +msgstr "URL servera, npr. 'https://meme.example.com'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Server Version" +msgstr "Verzija servera" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Server not found" +msgstr "Server nije pronađen" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_response +msgid "" +"Server response received during the last operation.\n" +"Default value if no error happened is 'ok'.\n" +"Otherwise there will be a server error message logged." +msgstr "" +"Odgovor servera primljen tijekom posljednje operacije.\n" +"Zadana vrijednost ukoliko nije došlo do greške je 'ok'.\n" +"Inače će greška biti upisana u log servera." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Server tight" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__url +msgid "Server web interface, eg 'https://doge.example.com'" +msgstr "Web sučenje servera, npr. 'https://doge.example.com'" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "Servers" +msgstr "Serveri" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_ids +msgid "" +"Servers on which the command will be executed.\n" +"If empty, command canbe executed on all servers" +msgstr "" +"Serveri na kojima će naredba biti izvršena.\n" +"Ako je prazno, naredba može biti izvršena na svim serverima" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Set Variable Values" +msgstr "Postavite vrijednosti varijabli" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_status +msgid "" +"Set the following status if command is executed successfully. Leave " +"'Undefined' if you don't need to update the status" +msgstr "" +"Postavi sljedeći status ako je naredba izvršena uspješno. Ostavite " +"'Nedefinirano' ako nema potrebe ažurirati status" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_settings +msgid "Settings" +msgstr "Postavke" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "Show Commands" +msgstr "Prikaži naredbe" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "Show Flight Plans" +msgstr "Prikaži Planove leta" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__show_servers +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__show_servers +msgid "Show Servers" +msgstr "Prikaži servere" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Skipped" +msgstr "Preskočeno" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "Some servers don't support this command" +msgstr "Neki serveri ne podržavaju ovu naredbu" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Some variable options are invalid:\n" +"%(detailed_message)s" +msgstr "" +"Neke vrijednosti varijabli nisu valjane:\n" +"%(detailed_message)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__source +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__source +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Source" +msgstr "Izvor" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Start date" +msgstr "Početni datum" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__start_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__start_date +msgid "Started" +msgstr "Pokrenuto" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_status +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__status +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Status" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"Status baziran na aktivnostima\n" +"Prekoračen rok: Krajnji datum je već prošao\n" +"Danas: Datum aktivnosti je danas\n" +"Planirano: Buduće aktivnosti." + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__s +msgid "String" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#, python-format +msgid "Success" +msgstr "Uspješno" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__use_sudo__p +msgid "Sudo with password" +msgstr "Sudo sa passwordom" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_execute_wizard__use_sudo__n +msgid "Sudo without password" +msgstr "Sudo bez passworda" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Sync Error" +msgstr "Greška sinkronizacije" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Synced" +msgstr "Sinkronizirano" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_tag +msgid "Tag" +msgstr "Oznaka" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Tagged" +msgstr "Označeno" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_execute_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__tag_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_tag +msgid "Tags" +msgstr "Oznake" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Template" +msgstr "Predložak" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Template Code" +msgstr "Šifra predloška" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_template_action +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_template +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_template +msgid "Templates" +msgstr "Predlošci" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Test Connection" +msgstr "Test povezivanja" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Text" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "" +"The access level for Variable Option '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Option Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"The access level for Variable Value '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Value Access Level: %(val_level)s" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "" +"The access level of command(s) '%(command_names)s' included in the current " +"Flight plan is higher than the access level of the Flight plan itself. " +"Please ensure that you want to allow those commands to be run anyway." +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option +msgid "The combination of Name,Value and Variable must be unique." +msgstr "Kombinacija Naziv, Vrijednost i Varijabla mora biti jedinstvena." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "The file %(f_path)s not found." +msgstr "Datoteka %(f_path)s nije pronađena." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Then" +msgstr "Onda" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "There are two default keys in the dictionary, e.g.:" +msgstr "Postoje dva zadana ključa u dictionary.u npr:" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "This Flightplan will be executed when the server is deleted" +msgstr "Ovaj plan leta će biti izvršen kada je server obrisan" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "" +"This action will be executed on error if no command action can be applied" +msgstr "" +"Ova će akcija biti izvršena prilikom greške ako niti jedna druga akcija nije " +"primjenjiva" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "This command can be used only in Flight Plans." +msgstr "Ova naredba se može koristiti samo u Planovima leta." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__note +msgid "This field is used to put some notes regarding template." +msgstr "Ovo polje koristi se za upisivanje bilješki vezanih za predložak." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_execute_wizard__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_template_mixin__code +msgid "This field will be rendered by default" +msgstr "Ovo polje će biti generirano prema postavkama" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_template_id +msgid "" +"This file template will be used to create log files when server is created " +"from a template" +msgstr "" +"Ovaj predložak datoteke će biti korišten za kreiranje log datoteke kad je " +"server kreiran iz predloška" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +msgid "This flight plan will be run upon server creation" +msgstr "Ovaj plan leta će biti pokrenut prilikom kreiranja servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "" +"This is required, however you can change this later in the server settings" +msgstr "" +"Ovo je obavezno, ali možete vrijednost promijeniti kasnije u postavkama " +"servera" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +msgid "This template will be used to create or update the pushed file" +msgstr "" +"Ovaj predložak će biti korišten za kreiranje ili ažuriranje datoteke koju se " +"prebacuje na server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Time consumed for execution, seconds" +msgstr "Vrije izvršavanja u sekundama" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_tools +msgid "Tools" +msgstr "Alati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_count +msgid "Total Files" +msgstr "Ukupno datoteka" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__tower +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__tower +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Tower" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_mixin +msgid "Tower Variables mixin" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cetmix_tower +msgid "Tower automation helper model" +msgstr "" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_log_id +msgid "Triggered Plan Log" +msgstr "Log pokrenutih planova" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_type +msgid "Type" +msgstr "Tip" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "Tip aktivnosti izuzetka na zapisu." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__url +msgid "URL" +msgstr "" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to delete file '%(f)s'.\n" +"Delete operation is not supported for 'server' type files." +msgstr "" +"Nije moguće obrisati datoteku '%(f)s'.\n" +"Operacija brisanja nije podržana za tip datoteke 'server'." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" +"Nije moguće slanje datoteke '%(f)s' na server.\n" +"Operacija slanja nije podržana za datoteku tipa 'server'." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_unread +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_unread +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_unread +msgid "Unread Messages" +msgstr "Nepročitane poruke" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_unread_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_unread_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_unread_counter +msgid "Unread Messages Counter" +msgstr "Brojač nepročitanih poruka" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_upload_action +msgid "Upload" +msgstr "Pošalji na server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "Use Sudo" +msgstr "Koristi sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__use_sudo +msgid "Use sudo" +msgstr "Koristi sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH Key" +msgstr "Korišteno kao SSH ključ" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH key in the following servers" +msgstr "Koristi se kao SSH ključ na sljedećim serverima" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Used for" +msgstr "Koristi se za" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_id +msgid "Used for selected server only. Leave blank to use globally" +msgstr "" +"Koristi se samo za odabrane server. Ostavite prazno za globalno korištenje" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_user +msgid "User" +msgstr "Korisnik" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users" +msgstr "Korisnici" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users who can view this record" +msgstr "Korisnici koji mogu vidjeti ovaj zapis" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Value" +msgstr "Vrijednost" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids_count +msgid "Value Count" +msgstr "Broj vrijednosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Values" +msgstr "Vrijednosti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Variable" +msgstr "Varijabla" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Variable '%(var)s' can only be assigned to one of the models at a time: " +"Server, Server Template, or Plan Line Action." +msgstr "" +"Varijabla '%(var)s' može biti dodijeljena samo jednom modelu istovremena: " +"Server, Predložak servera, Akcija plana leta." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +msgid "Variable Reference" +msgstr "Referenca varijable" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_value +#, python-format +msgid "Variable Values" +msgstr "Vrijednosti varijable" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_tower_variable_value_uniq +msgid "Variable can be declared only once for the same record!" +msgstr "Varijabla može biti deklarirana samo jednom za isti zapis!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_name_uniq +msgid "Variable names must be unique" +msgstr "Nazivi varijabli moraju biti jedinstveni" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable not found" +msgstr "Varijabla nije pronađena" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Variable reference '%(var_ref)s' has an invalid option reference " +"'%(opt_ref)s'." +msgstr "" +"Referenca varijable '%(var_ref)s' ima neispravnu referencu opcije " +"'%(opt_ref)s'." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value created" +msgstr "Vrijednost varijable kreirana" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value updated" +msgstr "Vrijednost varijable ažurirana" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +msgid "Variable values for selected record" +msgstr "Vrijednosti varijabli za odabrani zapis" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_execute_wizard__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable +msgid "Variables" +msgstr "Varijable" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Various fields may use Python code or Python expressions. The\n" +" following variables can be used:" +msgstr "" +"Različita polja mogu koristiti python kod ili python izraze. Moguće je\n" +" " +"koristiti sljedeće varijable:" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Where command was executed" +msgstr "Gdje je naredba izvršena" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Will be used instead of the command exit code" +msgstr "Će biti korišten umjesto izlaznog koda naredbe" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "" +"Will use sudo based on server settings.If no sudo is configured will run " +"without sudo" +msgstr "" +"Koristi se sudo bazirano na postavkama server. Ako sudo nije postavljen " +"izvršava se bez sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__p +msgid "With password" +msgstr "Sa passwordom" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__n +msgid "Without password" +msgstr "Bez passworda" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__wizard_id +msgid "Wizard" +msgstr "Čarobnjak" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "Wrong action" +msgstr "Pogrešna akcija" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You are not allowed to execute commands in wizard" +msgstr "Nije vam dozvoljeno izvršavati naredbe u čarobnjaku" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You cannot execute an empty command" +msgstr "Nije moguće izvršiti praznu naredbu" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_execute_wizard.py:0 +#, python-format +msgid "You cannot run custom code on multiple servers at once." +msgstr "Nije moguće pokrenuti prilagođeni kod na više servera odjednom." + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/ir_actions_server.py:0 +#, python-format +msgid "" +"You need to have 'write' access to all servers you want to run this action " +"on." +msgstr "" +"Potrebno vam je pravo pisanja na svim serverima na kojima želite pokrenuti " +"ovu akciju." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +msgid "e.g. /home/user This field does NOT support variables" +msgstr "npr. /home/user Ovo polje NE PODRŽAVA varijable" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "e.g. /such/much/{{ path }}, overrides command path" +msgstr "npr. /tamo/neki/{{path}} , nadjačava putanju varijable" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/tests/common.py:0 +#: code:addons/cetmix_tower_server/tests/common.py:0 +#, python-format +msgid "groups_ref must be string or list of strings!" +msgstr "groups_ref mora biti znakovni izraz ili lista znakovnih izraza!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "managers who can modify this record" +msgstr "manageri koji mogu modificirati ovaj zapis" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "managers who can modify this server" +msgstr "manageri koji mogu modificirati ovaj sevrer" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "managers who can modify this template" +msgstr "manageri koji mogu modificirati ovaj predložak" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "new server name" +msgstr "naziv novog servera" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "optional, eg /home/{{ tower.server.username }}" +msgstr "opcionalno, npr. /home/{{ tower.server.username }}" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "sudo password was not provided!" +msgstr "sudo password nije unešen!" + +#. module: cetmix_tower_server +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "then" +msgstr "onda" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "this can be changed later" +msgstr "ovo može biti izmjenjeno kasnije" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "users who can access this server" +msgstr "korisnici koji mogu pristupiti ovom serveru" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "users who can access this template" +msgstr "korisnici koji mogu pristupiti ovom predlošku" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "users who can view this record" +msgstr "korisnici koji mogu vidjeti ovaj zapis" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_execute_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_execute_wizard_view_form +msgid "with tags" +msgstr "sa oznakama" diff --git a/addons/cetmix_tower_server/i18n/it.po b/addons/cetmix_tower_server/i18n/it.po new file mode 100644 index 0000000..b527687 --- /dev/null +++ b/addons/cetmix_tower_server/i18n/it.po @@ -0,0 +1,5305 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: 2025-11-17 10:49+0100\n" +"Last-Translator: Stefano Consolaro \n" +"Language-Team: Italian \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Poedit 2.3\n" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__source +msgid "" +"\n" +" - Tower: file is pushed from Tower to server.\n" +" - Server: file is pulled from server to Tower.\n" +" " +msgstr "" +"\n" +" - Tower: il file è inviato da Tower al server.\n" +" - Server: il file è scaricato dal server a Tower.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_user +msgid "" +"\n" +" Basic actions for selected servers.\n" +" " +msgstr "" +"\n" +" Azioni base per i server selezionati.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_manager +msgid "" +"\n" +" Create and modify selected servers.\n" +" " +msgstr "" +"\n" +" Crea e modifica i server selezionati.\n" +" " + +#. module: cetmix_tower_server +#: model:res.groups,comment:cetmix_tower_server.group_root +msgid "" +"\n" +" Full control over all servers.\n" +" " +msgstr "" +"\n" +" Controllo completo di tutti i server.\n" +" " + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/constants.py:0 +#, python-format +msgid "" +"\n" +"

Help with Python expressions

\n" +"
\n" +"

\n" +" Each Python code command returns the result value which is a dictionary.\n" +"
There are two keys in the dictionary:\n" +"

    \n" +"
  • exit_code: Integer. Exit code of the command. \"0\" means success, any other value means failure. Default value is \"0\".
  • \n" +"
  • message: String. Message to be logged. Default value is \"None\".
  • \n" +"
\n" +"You can also access the custom_values dictionary that contains custom values provided to the command or flight plan.\n" +"Custom values can be modified, thus can be used to pass data between commands in a flight plan.\n" +"Please keep in mind that custom values are persistent only between commands in a flight plan and are not saved to the database.\n" +"
\n" +"Here is an example of a python code command:\n" +"\n" +"\n" +" server_name = server.name\n" +" build_name = custom_values.get(\"build_name\")\n" +" if build_name:\n" +" result = {\"exit_code\": 0, \"message\": \"Build name for \" + server_name + \" is \" + build_name}\n" +" else:\n" +" result = {\"exit_code\": 0, \"message\": \"No build name provided for \" + server_name}\n" +" custom_values[\"build_name\"] = \"New build name\"\n" +"\n" +"

\n" +"
\n" +"Please refer to the official documentation for more information and examples.\n" +"
\n" +"Various fields may use Python code or Python expressions. The\n" +" following variables can be used:

\n" +msgstr "" +"\n" +"

Aiuto con le espressioni Python

\n" +"
\n" +"

\n" +" Ogni codice comando Python restituisce il valoreresult che è un dizionario.\n" +"
Ci sono due chiavi nel dizionario:\n" +"

    \n" +"
  • exit_code: Intero. Codice uscita del comando. \"0\" significa successo, altri valori indicano un fallimento. Il valore predefinito è \"0\".
  • \n" +"
  • message: Stringa. Messaggio da registrare. Il valore predefinito è \"None\".
  • \n" +"
\n" +"Si può anche accedere al dizionario custom_values che contiene valori personalizzati forniti dal comando o dal piano di volo.\n" +"I valori predefiniti possono essere modificati, quindi possono essere usati per passare dati tra i comandi in un piano di volo.\n" +"Tenere presente che i valori personalizzati sono persistenti solo tra comandi in un piano di volo e non salvati nel data base.\n" +"
\n" +"Qui un esempiuo di un comando Python:\n" +"\n" +"\n" +" server_name = server.name\n" +" build_name = custom_values.get(\"build_name\")\n" +" if build_name:\n" +" result = {\"exit_code\": 0, \"message\": \"Il nome creato per \" + server_name + \" è \" + build_name}\n" +" else:\n" +" result = {\"exit_code\": 0, \"message\": \"Nessun nome fornito per \" + server_name}\n" +" custom_values[\"build_name\"] = \"Nuovo nome creato\"\n" +"\n" +"

\n" +"
\n" +"Fare riferimento alla documentazione ufficiale per altre informazioni ed esempi.\n" +"
\n" +"Diversi campi possono usare il codice Python o le espressioni Python.\n" +" Possono essere usate le seguenti variabili:

\n" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Empty values for variables: %(variables)s" +msgstr " - Valori vuoti per le variabili: %(variables)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid " - Missing variables: %(variables)s" +msgstr " - Variabili mancanti: %(variables)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/constants.py:0 +#, python-format +msgid "" +"# Please refer to the 'Help' tab and documentation for more information.\n" +"#\n" +"# You can return command result in the 'result' variable which is a dictionary:\n" +"# result = {\"exit_code\": 0, \"message\": \"Some message\"}\n" +"# default value is {\"exit_code\": 0, \"message\": None}\n" +msgstr "" +"# Fare riferimtno alla sezione 'Aiuto' e alla documentazione per ulteriori informazioni.\n" +"#\n" +"# Si può restituire il risultato del comando nella variabile 'result' che è un dizionario:\n" +"# result = {\"exit_code\": 0, \"message\": \"Un messaggio\"}\n" +"# default value is {\"exit_code\": 0, \"message\": None}\n" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_reference_mixin.py:0 +#, python-format +msgid "%(name)s (copy %(number)s)" +msgstr "%(name)s (copy %(number)s)" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_reference_mixin.py:0 +#, python-format +msgid "%(name)s (copy)" +msgstr "%(name)s (copia)" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_shortcut.py:0 +#, python-format +msgid "%(shr)s triggered" +msgstr "%(shr)s attivato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "...save record to see the final expression or click the line to edit" +msgstr "... salvare il record per vedere l'espressione finale o fare clic sulla riga per modificare" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "" +"0 if command finished successfully.\n" +"-100 general error,\n" +"-101 not found,\n" +"-201 another instance of this command is running,\n" +"-202 no runner found for the command action,\n" +"-203 Python code execution failed\n" +"-205 plan line condition check failed\n" +"503 if SSH connection error occurred\n" +"601 if queue job failed" +msgstr "" +"0 se il comando si è concluso con successo.\n" +"-100 errore generale,\n" +"-101 non trovato,\n" +"-201 altra interfaccia o comando in esecuzione,\n" +"-202 esecutore per l'azione comando non trovato,\n" +"-203 esecuzione codice Python fallita\n" +"-205 controllo condizione riga piano fallito\n" +"503 se si è verificato un errore di connessione SSH601 se il lavoro in coda è fallito" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_status +#, fuzzy +msgid "" +"0 if plan is finished successfully. \n" +"-301 if another instance of this flight plan is running, \n" +"-302 if plan is empty, \n" +"-303 if plan reference is missing, \n" +"-304 if plan line reference is missing, \n" +"-306 if plan is not compatible with server,\n" +"-308 if plan is stopped by user" +msgstr "" +"0 se il piano si è concluso con successo. \n" +"-301 se un'altra istanza o questo piano di volo è in esecuzione, \n" +"-302 se il piano è vuoto, \n" +"-303 se manca il piano di riferimento, \n" +"-304 se manca il riferimento alla riga del piano, \n" +"-306 se il piano non è compatibile con il server, \n" +"-308 se il piano è stato fermato dall'utente" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "AND" +msgstr "E" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "" +"\n" +" &nbsp;" +msgstr "" +"\n" +" &nbsp;" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_log.py:0 +#, python-format +msgid "" +msgstr "" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "sudo" +msgstr "sudo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Files will be pulled from server to Tower automatically using cron job." +msgstr "I file verranno scaricati dal server a Tower automaticamente usando un lavoro cron." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Fill in required configuration variables on the “Configuration Values” tab before you can run the command." +msgstr "Compilare le variabili di configurazione richieste nella sezione “Valori configurazione” prima di eseguire il comando." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Pull files from server" +msgstr "Scarica i file dal server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Run scheduled tasks" +msgstr "Esegui lavori schedulati" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Scheduled tasks will be run automatically using cron job." +msgstr "I lavori schedulati verranno eseguiti automaticamente usando un lavoro cron." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "IPv4 Address:" +msgstr "Indirizzo IPv4:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "IPv6 Address:" +msgstr "Indirizzo IPv6:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Operating System: " +msgstr "Sistema operativo:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Operating System:" +msgstr "Sistema operativo:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +msgid "Partner:" +msgstr "Partner:" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +msgid "Servers: " +msgstr "Partner:" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_action +msgid "A variable value cannot be assigned multiple times to the same plan line action!" +msgstr "Non è possibile assegnare più volte alla stessa azione della linea del piano un valore variabile!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_template +msgid "A variable value cannot be assigned multiple times to the same server template!" +msgstr "Un valore di variabile non può essere assegnato più volte allo stesso modello di server!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_unique_variable_value_server +msgid "A variable value cannot be assigned multiple times to the same server!" +msgstr "Un valore variabile non può essere assegnato più volte allo stesso server!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Access" +msgstr "Accesso" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_mixin__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__access_level +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__access_level +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Access Level" +msgstr "Livello accesso" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__access_level_warn_msg +msgid "Access Level Warn Msg" +msgstr "Messaggio avviso livello accesso" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "Access level is not defined for '%(option)s'" +msgstr "Il livello di accesso non è definito per '%(option)s'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Access level is not defined for '%(variable)s'" +msgstr "Il livello di accesso non è definito per '%(variable)s'" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js:0 +#, python-format +msgid "Ace Tower Editor" +msgstr "Editor Ace Tower" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__action +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Action" +msgstr "Azione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "Action Needed" +msgstr "Richiesta una azione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "Actions" +msgstr "Azioni" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__action_ids +msgid "Actions trigger based on command result. If empty next command will be executed" +msgstr "Le azioni vengono attivate in base al risultato del comando. Se vuoto, verrà eseguito il comando successivo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__active +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__active +msgid "Active" +msgstr "Attivo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_ids +msgid "Activities" +msgstr "Attività" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "Decorazione eccezione attività" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "Activity State" +msgstr "Stato attività" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Activity Type Icon" +msgstr "Icona tipo attività" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_action +msgid "Add a new file" +msgstr "Aggiungi nuovo file" + +#. module: cetmix_tower_server +#: model_terms:ir.actions.act_window,help:cetmix_tower_server.cx_tower_file_template_action +msgid "Add a new file template" +msgstr "Aggiungi nuovo modello file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "" +"Additional notes about the variable. \n" +"This field will be displayed in the variable form." +msgstr "" +"Note addizionali sulla variabile.\n" +"Questo campo verrà visualizzato nel modulo della variabile." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +msgid "All" +msgstr "Tutti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Allow Parallel Run" +msgstr "Consenti esecuzione in parallelo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "An error occurred: %(error)s" +msgstr "Si è verificato un errore: %(error)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "Another instance of the command is already running" +msgstr "Un'altra istanza del comando è già in esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__applicability +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__applicability +msgid "Applicability" +msgstr "Applicabilità" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__applied_expression +msgid "Applied Expression" +msgstr "Espressione applicata" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Archived" +msgstr "In archivio" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Are you sure?" +msgstr "Si è sicuri?" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_attachment_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_attachment_count +msgid "Attachment Count" +msgstr "Conteggio allegati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__auto_sync +msgid "Auto Sync" +msgstr "Sincronizzazione automatica" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__auto_sync_interval +msgid "Auto Sync Interval" +msgstr "Intervallo sincronizzazione automatica" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__value_char +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task_cv__value_char +msgid "Automatically populated from selected option. Manual edits will be overwritten when option changes." +msgstr "Compilato auitomaticamente dall'opzione selezionata. Le modifiche manuali verranno sovrascritte quando si modifica l'opzione." + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_automation_root +msgid "Automation" +msgstr "Automazione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Binary" +msgstr "Binario" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file +msgid "Binary Content" +msgstr "Contenuto binario" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_shortcut__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_view_form +msgid "Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "Può contenere lettere inglesi, cifre e '_'. Lasciare vuoto per la generazione automatica" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Cancel" +msgstr "Annulla" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for '%(var)s' with value '%(val)s'.\n" +"Try to assigns it to a record instead." +msgstr "" +"Impossibile modificare lo stato 'global' per '%(var)s' con valore '%(val)s'. \n" +"Provare ad assegnarlo ad un record." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "" +"Cannot change 'global' status for 'meme' with value 'Pepe'.\n" +"Try to assigns it to a record instead." +msgstr "" +"Impossibile modificare lo stato 'global' per 'meme' con valore 'Pepe'. \n" +"Provare ad assegnarlo ad un record." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_tag.py:0 +#, python-format +msgid "Cannot delete tag '%(tag_name)s' because it is used in related records." +msgstr "Non si può cancellare l'etichetta '%(tag_name)s' perché è usata nei record relativi." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Cannot download %(f)s from server: Binary content is not supported for 'Text' file type" +msgstr "Impossibile scaricare %(f)s dal server: il contenuto binario non è supportato per il tipo di file 'Text'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Cannot pull %(f)s from server: %(err)s" +msgstr "Impossibile scaricare %(f)s dal server: %(err)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot remove test file using command.\n" +" CODE: %(status)s. ERROR: %(err)s" +msgstr "" +"Non si può rimuovere il file test usando il comando.\n" +" CODE: %(status)s. ERROR: %(err)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Cannot run command\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" +msgstr "" +"Impossibile eseguire il comando\n" +". CODE: %(status)s. RESULT: %(res)s. ERROR: %(err)s" + +#. module: cetmix_tower_server +#: model:ir.module.category,name:cetmix_tower_server.ir_module_category_tower +#: model:ir.ui.menu,name:cetmix_tower_server.menu_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_form_inherit_cetmix_tower +msgid "Cetmix Tower" +msgstr "Cetmix Tower" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Cetmix Tower helper class shortcut" +msgstr "Scorciatoia classe aiuto Cetmix Tower " + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command +msgid "Cetmix Tower Command" +msgstr "Comando Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "Registro comando Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file +msgid "Cetmix Tower File" +msgstr "File Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_file_template +msgid "Cetmix Tower File Template" +msgstr "Modello file Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan +msgid "Cetmix Tower Flight Plan" +msgstr "Piano di volo Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line +msgid "Cetmix Tower Flight Plan Line" +msgstr "Riga piano di volo Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_line_action +msgid "Cetmix Tower Flight Plan Line Action" +msgstr "Azione riga piano di volo Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_log +msgid "Cetmix Tower Flight Plan Log" +msgstr "Registro piano di volo Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_mixin +msgid "Cetmix Tower Key/Secret Mixin" +msgstr "Mixin chiave/segreto Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key +msgid "Cetmix Tower Key/Secret Storage" +msgstr "Deposito chiave/segreto Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cetmix_tower +msgid "Cetmix Tower Odoo Automation" +msgstr "Automazione Odoo Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_os +msgid "Cetmix Tower Operating System" +msgstr "Sistema operativo Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_command_run_wizard_action +msgid "Cetmix Tower Run Command" +msgstr "Esecuzione comando Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_plan_run_wizard_action +msgid "Cetmix Tower Run Flight Plan" +msgstr "Cetmix Tower esegue piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_key_value +msgid "Cetmix Tower Secret Value Storage" +msgstr "Deposito valore segreto Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Cetmix Tower Server" +msgstr "Server Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_log +msgid "Cetmix Tower Server Log" +msgstr "Registro server Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template +msgid "Cetmix Tower Server Template" +msgstr "Modello server Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_shortcut +msgid "Cetmix Tower Shortcut" +msgstr "Scorciatoia Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_tag +msgid "Cetmix Tower Tag" +msgstr "Etichetta Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable +msgid "Cetmix Tower Variable" +msgstr "Variabile Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_option +msgid "Cetmix Tower Variable Options" +msgstr "Opzioni variabile Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_value +msgid "Cetmix Tower Variable Values" +msgstr "Valori variabile Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_vault +msgid "Cetmix Tower Vault" +msgstr "Archivio Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_vault_mixin +msgid "Cetmix Tower Vault Mixin" +msgstr "Mixin archivio Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_mixin +msgid "Cetmix Tower access mixin" +msgstr "Mixin accesso Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_access_role_mixin +msgid "Cetmix Tower access role mixin" +msgstr "Mixin ruolo accesso Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_reference_mixin +msgid "Cetmix Tower reference mixin" +msgstr "Mixin riferimento Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_template_mixin +msgid "Cetmix Tower template rendering mixin" +msgstr "Mixin compilazione modello Cetmix Tower" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_auto_pull_files_from_server_ir_actions_server +msgid "Cetmix Tower: Auto pull files from server" +msgstr "Cetmix Tower: scarica automaticamente i file dal server" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_check_zombie_commands_ir_actions_server +msgid "Cetmix Tower: Check zombie commands" +msgstr "Cetmix Tower: controllo comandi zombi" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.ir_cron_run_scheduled_tasks_ir_actions_server +msgid "Cetmix Tower: Run scheduled tasks" +msgstr "Cetmix Tower: esegui lavori schedulati" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_shortcut.py:0 +#, python-format +msgid "Check %(t)s log for result" +msgstr "Controllare il log %(t)s per il risultato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "Check the key before inserting in the server settings. Do not insert the key if you have any doubts!" +msgstr "Controllare la chiave prima di inserire le impostazioni server. Non inserire la chiave se si hanno dei dubbi!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Child plans" +msgstr "Piani figli" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +#, python-format +msgid "Close" +msgstr "Chiudi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Code" +msgstr "Codice" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Code On Server" +msgstr "Codice sul server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__template_code +msgid "Code of the associated file template" +msgstr "Codice del modello fille associato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__color +msgid "Color" +msgstr "Colore" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__command_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__command_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__action__command +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_shortcut__action__command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Command" +msgstr "Comando" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "Command %(command_name)s is not compatible with the server" +msgstr "Il comando %(command_name)s non è compatibile con il server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command Code" +msgstr "Codice comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__command_ids_count +msgid "Command Count" +msgstr "Conteggio comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__command_domain +msgid "Command Domain" +msgstr "Dominio comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__command_help +msgid "Command Help" +msgstr "Aiuto comando" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_command_log +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__command_log_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_log_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_log +#, python-format +msgid "Command Log" +msgstr "Registro comando" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Command Logs" +msgstr "Registri comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_config_settings__cetmix_tower_command_timeout +msgid "Command Timeout" +msgstr "Timaout comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__command_variable_ids +msgid "Command Variables" +msgstr "Variabili comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__code +msgid "Command code that was executed" +msgstr "Il codice comando che è stato eseguito" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__is_running +msgid "Command is being executed right now" +msgstr "Il comando è appena stato eseguito" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Command is not compatible with the server" +msgstr "Il comando non è compatibile con il server" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Command not found" +msgstr "Comando non trovato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__command_id +msgid "" +"Command that will be executed to get the log data.\n" +"Be careful with commands that don't support parallel execution!" +msgstr "" +"Comando che verrà eseguito per ottenere i dati del log. \n" +"Fare attenzione ai comandi che non supportano l'esecuzione parallela!" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/constants.py:0 +#, python-format +msgid "Command timed out and was terminated" +msgstr "Il comando è andato in timeout ed è stato terminato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Command timeout in seconds after which the command will be terminated. Set to 0 to disable timeout." +msgstr "Timeout in secondi del comando dopo i quali il comando verrà terminato. Impostare 0 per disabilitare il timeout." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__command_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__plan_line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__command_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__command_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_command_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Commands" +msgstr "Comandi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__command_ids +msgid "Commands used in this flight plan" +msgstr "Comando usato in questo piano di volo." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__condition +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__condition +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Condition" +msgstr "Condizione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__condition +msgid "Conditions under which this Flight Plan Line will be launched. e.g.: {{ odoo_version}} == '14.0'" +msgstr "Condizioni in base alle quali verrà lanciata questa linea del piano di volo. Ad esempio: {{ odoo_version}} == '14.0'" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_res_config_settings +msgid "Config Settings" +msgstr "Impostazioni configurazione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Configuration" +msgstr "Configurazione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Configuration Values" +msgstr "Valori configurazione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__line_ids +msgid "Configuration Variables" +msgstr "Variabili configurazione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "Confirm" +msgstr "Conferma" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection failed." +msgstr "Connessione fallita." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Connection successful." +msgstr "Connessione riuscita." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"Connection test passed! \n" +"%(res)s" +msgstr "" +"Test connessione riuscito! \n" +"%(res)s" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_res_partner +msgid "Contact" +msgstr "Contatto" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_kanban +#, python-format +msgid "Create Server" +msgstr "Crea server" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard +msgid "Create new server from template" +msgstr "Crea nuovo server da modello" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_template_create_wizard_line +msgid "Create new server from template variables" +msgstr "Crea nuovo server da variabili modello" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__create_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/res_config_settings.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +#, python-format +msgid "Cron Job" +msgstr "Lavoro cron" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/res_config_settings.py:0 +#, python-format +msgid "Cron job not found" +msgstr "Lavoro cron non trovato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Current Cetmix Tower server this command is running on" +msgstr "Server attuale Cetmix Tower su cui il comando è in esecuzione" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Current Odoo user" +msgstr "Utente Odoo attuale" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Current Odoo user ID" +msgstr "ID utente Odoo attuale" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Custom Exit Code" +msgstr "Codice uscita personalizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__custom_message +msgid "Custom Message" +msgstr "Messaggio personalizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__custom_variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__custom_variable_value_ids +msgid "Custom Variable Value" +msgstr "Valore variabile personalizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__custom_variable_value_ids +msgid "Custom Variable Values" +msgstr "Valori variabile personalizzati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Custom label. Can be used for search/tracking" +msgstr "Etichetta personalizzata. Può essere utilizzata per ricerca/tracciamento" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__custom_message +msgid "Custom message to be displayed in the plan log" +msgstr "Messaggio personalizzato da visualizzare nel registro del piano" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Custom message to display when pattern check fails" +msgstr "Messaggio personalizzato da visualizzare quando fallisce il controllo dello schema" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +#, python-format +msgid "Custom secret values can be defined only for key type 'secret'" +msgstr "Valori segreti personalizzati possono essere definiti solo per il tipo chiave 'segreta'" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_custom_variable_value_mixin +msgid "Custom variable values" +msgstr "Valori variabile personalizzati" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_run_wizard_variable_value +msgid "Custom variable values for command run wizard" +msgstr "Valori variabile personalizzati per procedura guidata esecuzione comando" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_run_wizard_variable_value +msgid "Custom variable values for plan run wizard" +msgstr "Valori variabile personalizzati per procedura guidata esecuzione piano" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_scheduled_task_cv +msgid "Custom variable values for scheduled tasks" +msgstr "Valori variabile personalizzati per lavori schedulati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__variable_values +msgid "Custom variable values passed to the command" +msgstr "Valori variabile personalizzati passati al comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__variable_values +msgid "Custom variable values passed to the flight plan" +msgstr "Valori variabile personalizzati passati al piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Date and time of the latest successful synchronisation" +msgstr "Data e ora dell'ultima sincronizzazione riuscita" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Date and time of the next synchronisation" +msgstr "Data e ora della prossima sincronizzazione" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__days +msgid "Days" +msgstr "Giorni" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__path +msgid "Default Path" +msgstr "Percorso predefinito" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "Default full file name with file type for example: test.txt" +msgstr "Nome file completo predefinito con tipo di file, ad esempio: test.txt" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_delete_action +msgid "Delete from server" +msgstr "Cancella dal server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Directory on Server" +msgstr "Cartella nel server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__server_dir +msgid "Directory on server" +msgstr "Cartella nel server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__disconnect_file +msgid "Disconnect from Template" +msgstr "Disconneti dal modello" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__display_name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__skip_host_key +msgid "Don't Check Key" +msgstr "Non controllare la chiave" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_download_action +msgid "Download" +msgstr "Scarica" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Due to security restrictions you are not allowed to delete %(fp)s" +msgstr "Per motivi di sicurezza non si è autorizzati a cancellare %(fp)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Duration" +msgstr "Durata" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "Duration, sec" +msgstr "Durata, sec" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_vault_vault_unique_key +msgid "Each secret (model, record, field) must be unique in the vault." +msgstr "Ogni segreto (modello, record, campo) deve essere unico nell'archivio." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_dir +msgid "Eg '/home/user' or '/var/log'" +msgstr "Es '/home/user' o '/var/log'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__skip_host_key +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__skip_host_key +msgid "Enable to skip host key verification" +msgstr "Abilita il salto della verifica della chiave dell'host" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Enter Python code here. Help about Python expression is available in the help tab of this document." +msgstr "Inserisci qui il codice Python. La guida sull'espressione Python è disponibile nella scheda della guida di questo documento." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_error +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Error" +msgstr "Errore" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Error retrieving host key: %(err)s" +msgstr "Errore nel recuperare la chiave host: %(err)s" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "" +"Example:\n" +"
\n" +" \n" +" result = value.lower().strip().replace('_', '-').replace(\" \",\"\") if value.startswith('http') else 'https://' + re.sub(r'\\s+', '', value)\n" +" " +msgstr "" +"Esempio:\n" +"
\n" +" \n" +" result = value.lower().strip().replace('_', '-').replace(\" \",\"\") if value.startswith('http') else 'https://' + re.sub(r'\\s+', '', value)\n" +" " + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Execution Path" +msgstr "Percorso esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "Codice di uscita" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__e +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__e +msgid "Exit with command exit code" +msgstr "Uscita con comando codice di uscita" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__ec +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__ec +msgid "Exit with custom exit code" +msgstr "Uscita con codice di uscita personalizzato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Failed" +msgstr "Fallito" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect after %(attempts)s attempts. Error: %(err)s" +msgstr "Connessione fallita dopo %(attempts)s tentativi. Errore: %(err)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Failed to connect. Error: %(err)s" +msgstr "Connessione fallita. Errore: %(err)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +#, python-format +msgid "Failure" +msgstr "Fallimento" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__field_name +msgid "Field Name" +msgstr "Nome campo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_ids +msgid "File" +msgstr "File" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File %(f)s is not 'tower' type. This operation is supported for 'tower' files only" +msgstr "Il file %(f)s non è di tipo 'tower'. Questa operazione è supportata solo per file di tipo 'tower'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File %(f)s shouldn't have the '%(src)s' source for the '%(act)s' action" +msgstr "Il file %(f)s non dovrebbe avere l'origine '%(src)s' per l'azione '%(act)s'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_ids_count +msgid "File Count" +msgstr "Conteggio file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_name +msgid "File Name" +msgstr "Nome file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__file_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "File Template" +msgstr "Modello file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__file_template_ids_count +msgid "File Template Count" +msgstr "Conteggio modello file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__file_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "File Templates" +msgstr "Modelli file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__file_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "File Type" +msgstr "Tipo file" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File already exists" +msgstr "Il file esiste già" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file_template.py:0 +#, python-format +msgid "File already exists on server." +msgstr "Il file esiste già nel server." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File already exists on server. Upload skipped" +msgstr "Il file esiste già nel server. Aggiornamento saltato." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__code +msgid "File content" +msgstr "Contenuto del file" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "File content with variables rendered" +msgstr "Contenuto del file con variabili valorizzate" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File created and uploaded successfully" +msgstr "File creato e caricaco ocn successo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File deleted!" +msgstr "File cancellato!" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File downloaded!" +msgstr "File scaricato!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "File from template" +msgstr "File da modello" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__name +msgid "File name WITHOUT path. Eg 'test.txt'" +msgstr "Nome file senza percorso. Es. 'test.txt'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "File source cannot be determined: '%(source)s'" +msgstr "L'origine del file non può essere determinata: '%(source)s'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_id +msgid "File that will be executed to get the log data" +msgstr "File che verrà eseguito per ottenere i dati registro" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "File uploaded!" +msgstr "File caricato!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "File will be disconnected from template. Continue?" +msgstr "Il file verrà scollegato dal modello. Continuare?" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "File will be kept on server when deleted in Tower" +msgstr "Il file verrà mantenuto nel server quando cancellato in Tower" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__file_count +msgid "File(s)" +msgstr "File" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_action +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Files" +msgstr "File" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files deleted!" +msgstr "File cancellati!" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files downloaded!" +msgstr "File scaricati!" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "Files uploaded!" +msgstr "File caricati!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Files will be pulled from server to Tower automatically using cron job." +msgstr "I file verranno scaricati dal server a Tower automaticamente usando un lavoro cron." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Finish date" +msgstr "Data fine" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__finish_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__finish_date +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Finished" +msgstr "Finito" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__plan_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__action__plan +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_shortcut__action__plan +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Flight Plan" +msgstr "Piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_run_line_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Flight Plan Lines" +msgstr "Righe piano di volo" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_plan_log +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan_log +msgid "Flight Plan Log" +msgstr "Registro piano di volo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Flight Plan Logs" +msgstr "Registri piano di volo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +#, python-format +msgid "Flight Plan Stopped" +msgstr "Piano di volo fermato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids +msgid "Flight Plan Used" +msgstr "Piano di volo usato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids_count +msgid "Flight Plan Used Ids Count" +msgstr "Conteggio ID piano di volo usato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Flight Plan line that is being currently executed" +msgstr "Riga piano di volo attualmente inesecuzione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Flight Plan will be terminated after executing the current command. Continue?" +msgstr "Il piano di volo verrà terminato dopo l'esecuzione del comando attuale. Continuare?" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_plan +msgid "Flight Plans" +msgstr "Piani di volo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +#, python-format +msgid "Flight Plans Stopped" +msgstr "Piani di volo fermati" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Flight plan" +msgstr "Piano di volo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "Flight plan is not compatible with the server" +msgstr "Piano di volo non è compatibile con il server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__plan_run_id +msgid "Flight plan run by the command" +msgstr "Piano di volo eseguito dal comando" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Flight plan running error" +msgstr "Errore esecuzione piano di volo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Flight plan running error %(err)s" +msgstr "Errore %(err)s esecuzione piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_used_ids_count +msgid "Flight plan this command is used in" +msgstr "Piano di volo in cui è usato questo comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_stopped +msgid "Flight plan was stopped by user" +msgstr "Il piano di volo è stato fermato dall'utente" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Float compare. Odoo helper function to compare floats." +msgstr "Comparazione virgola mobile. Funzione aiuto Odoo per confronto virgola mobile." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_follower_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_follower_ids +msgid "Followers" +msgstr "Chi segue" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_partner_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_partner_ids +msgid "Followers (Partners)" +msgstr "Chi segue (partner)" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_type_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "Font awesome icon es. fa-tasks" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_os__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__color +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_tag__color +msgid "For better visualization in views" +msgstr "Per una visualizzazione migliore nelle viste" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration_current +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration_current +msgid "For how long a flight plan is already running" +msgstr "Da quanto è in esecuzione un piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__applicability__this +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_run_wizard__applicability__this +msgid "For selected server(s)" +msgstr "Per server selezionati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__full_server_path +msgid "Full Path" +msgstr "Percorso completo" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cetmix_tower_config_settings +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cetmix_tower_general_settings +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "General Settings" +msgstr "Impostazioni generali" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Get from Host" +msgstr "Ottieni da host" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__is_global +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__is_global +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Global" +msgstr "Globale" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Group By" +msgstr "Raggruppa per" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/common.py:0 +#, python-format +msgid "Group reference %s not found!" +msgstr "Referenza di gruppo %s non trovata!" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__has_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__has_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__has_message +msgid "Has Message" +msgstr "Ha messaggio" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__has_missing_required_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__has_missing_required_values +msgid "Has Missing Required Values" +msgstr "Ha valori richiesti mancanti" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Has Template" +msgstr "Ha modello" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__have_access_to_server +msgid "Have Access To Server" +msgstr "Ha accesso al server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Help" +msgstr "Aiuto" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__host_key +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__host_key +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__host_key +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +#, python-format +msgid "Host Key" +msgstr "Chiave host" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Host key" +msgstr "Chiave host" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Host key not found for server %(server)s" +msgstr "Chiave host non trovata per il server %(server)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__host_key +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__host_key +msgid "Host key to verify the server" +msgstr "Chiave host per verificare il server" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__hours +msgid "Hours" +msgstr "Ore" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "I confirm that the key is correct and I want to insert it in the server settings" +msgstr "Confermo che la chiave è corretta e che voglio inserirla nelle impostazioni server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__id +msgid "ID" +msgstr "ID" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__res_id +msgid "ID of the resource that uses this vault" +msgstr "ID della risorsa che utilizza questo archivio" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v4_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v4_address +msgid "IPv4 Address" +msgstr "Indirizzo IPv4" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ip_v6_address +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ip_v6_address +msgid "IPv6 Address" +msgstr "Indirizzo IPv6" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon" +msgstr "Icona" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_icon +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "Icona per indicare una attività eccezione." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__if_file_exists +msgid "If File Exists" +msgstr "Il file esiste" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction +msgid "If checked, new messages require your attention." +msgstr "Se selezionata, nuovi messaggi richiedono attenzione." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "Se selezionata, alcuni messaggi hanno errori di consegna." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__auto_sync +msgid "If enabled file will be synced automatically using cron" +msgstr "Se abilitata, il file verrà sincronizzato automaticamente tramite cron" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__disconnect_file +msgid "If enabled, disconnects the file from its template after running the command.\n" +msgstr "Se abilitata, disconnette il file dal suo modello dopo l'esecuzione del comando.\n" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__no_split_for_sudo +msgid "If enabled, do not split command on '&&' when using sudo.Prepend sudo once to the whole command." +msgstr "Se abilitata, non dividere il comando su '&&' quando si usa sudo. Aggiungere sudo all'inizio dell'intero comando." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__auto_sync +msgid "If enabled, files created from this template will have Auto Sync enabled by default. Used only with 'Tower' source." +msgstr "Se abilitata, i file creati da questo modello avranno Auto Sync abilitato in modo predefinito. Utilizzato solo con l'origine 'Tower'." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__allow_parallel_run +msgid "" +"If enabled, multiple instances of the same command can be run on the same server at the same time.\n" +"Otherwise, ANOTHER_COMMAND_RUNNING status will be returned if another instance of the same command is already running" +msgstr "" +"Se abilitata, istanze multiple dello stesso comando possono essere eseguite sullo stesso server contemporaneamente.\n" +"Altrimenti, verrà restituito lo stato ANOTHER_COMMAND_RUNNING se un'altra istanza dello stesso comando è già in esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__allow_parallel_run +msgid "" +"If enabled, multiple instances of the same flight plan can be run on the same server at the same time.\n" +"Otherwise, ANOTHER_PLAN_RUNNING status will be returned if another instance of the same flight plan is already running" +msgstr "" +"Se abilitata, istanze multiple dello stesso piano di volo possono essere eseguite sullo stesso server contempraneamente.\n" +"Altrimenti, verrà restituito lo stato ANOTHER_PLAN_RUNNING se un'altra istanza dello stesso piano di volo è già in esecuzione" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#, python-format +msgid "If exit code" +msgstr "Se codice di uscita" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/test_plan.py:0 +#, python-format +msgid "If exit code == 35 then Exit with command exit code" +msgstr "Se il codice di uscita == 35 allora esci con il comando codice di uscita" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "In the Secure Shell (SSH) protocol, host keys are used to verify the identity of remote hosts. Accepting unknown host keys may leave the connection open to man-in-the-middle attacks." +msgstr "Nel protocollo Secure Shell (SSH), le chiavi host sono usate per verificare l'identità dell'host remoto. Accettare le chiavi di un host sconosciuto può lasciare la connessione aperta ad attacchi man-in-the-middle." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +msgid "Indicates if this variable is mandatory for server creation" +msgstr "Indica se questa variabile è obbligatoria per la creazione del server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_server_host_key_wizard_form +msgid "Insert Key" +msgstr "Inserisci chiave" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__interval_number +msgid "Interval Number" +msgstr "Numero intervallo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__interval_type +msgid "Interval Unit" +msgstr "Unità intervallo" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_scheduled_task_interval_positive +msgid "Interval number must be greater than zero." +msgstr "Il numero intervallo deve essere maggiore di zero." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#, python-format +msgid "Invalid value!" +msgstr "Valore non valido" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__is_error +msgid "Is Error" +msgstr "È errore" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_is_follower +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_is_follower +msgid "Is Follower" +msgstr "Segue" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_running +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__is_running +msgid "Is Running" +msgstr "In esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__is_skipped +msgid "Is Skipped" +msgstr "È saltato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__keep_when_deleted +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__keep_when_deleted +msgid "Keep When Deleted" +msgstr "Mantieni quando cancellato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__key_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__k +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__k +msgid "Key" +msgstr "Chiave" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__key_type +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Key Type" +msgstr "Tipo chiave" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Key Value" +msgstr "Valore chiave" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py:0 +#, python-format +msgid "Key inserted successfully!" +msgstr "Chiave inserita con successo!" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Key reference for inline usage" +msgstr "Riferimento chiave per utilizzo inline" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_key_root +msgid "Keys and Secrets" +msgstr "Chiavi e segreti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__label +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__label +msgid "Label" +msgstr "Etichetta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Labeled" +msgstr "Etichettato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__last_call +msgid "Last Execution Date" +msgstr "Ultima data esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_last +msgid "Last Sync Date" +msgstr "Ultima data sincronia" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__write_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code_on_server +msgid "Latest version of file content on server" +msgstr "Ultima versione del contenuto del file sul server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__line_id +msgid "Line" +msgstr "Riga" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__flight_plan_line_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__line_ids +msgid "Lines" +msgstr "Righe" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__flight_plan_line_ids +msgid "Lines of the associated flight plan" +msgstr "Righe del piano di volo associato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Local" +msgstr "Locale" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "Location where command will be executed. Overrides command default path. You can use {{ variables }} in path" +msgstr "Posizione in cui verrà eseguito il comando. Sostituisce il percorso predefinito del comando. Si può usare {{ variables }} nel percorso" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__path +msgid "Location where command will be run. You can use {{ variables }} in path" +msgstr "Posizione in cui verrà eseguito il comando. Si può usare {{ variables }} nel percorso" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_text +msgid "Log Text" +msgstr "Testo registro" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__log_type +msgid "Log Type" +msgstr "Tipo registro" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_log_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +msgid "Logs" +msgstr "Registri" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__parent_flight_plan_log_id +msgid "Main Log" +msgstr "Registro principale" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plan" +msgstr "Piano principale" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Main plans" +msgstr "Piani principali" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_manager +msgid "Manager" +msgstr "Responsabile" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers" +msgstr "Responsabili" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__manager_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__manager_ids +msgid "Managers who can modify this record" +msgstr "Responsabili che possono modificare questo record" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error +msgid "Message Delivery error" +msgstr "Errore consegna messaggio" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__validation_message +msgid "" +"Message to display when the variable value is invalid. \n" +"First line will be added automatically: `Variable:, Value: `\n" +"Eg: `Variable: Customer Name, Value: Test\n" +"Invalid value!`\n" +"If empty, the default message will be used." +msgstr "" +"Messaggio da visualizzare quando il valore della variabile non è valido. \n" +"La prima riga verrà aggiunta automaticamente: `Variabile:, Valore: `\n" +"Es.: `Variabile: Nome cliente, Valore: Test\n" +"Valore non valido!`\n" +"Se vuoto, verrà usato il messaggio predefinito." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_ids +msgid "Messages" +msgstr "Messaggi" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__minutes +msgid "Minutes" +msgstr "Minuti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables +msgid "Missing Required Variables" +msgstr "Variabili richieste mancanti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__missing_required_variables_message +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__missing_required_variables_message +msgid "Missing Required Variables Message" +msgstr "Messaggio variabili richieste mancanti" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js:0 +#, python-format +msgid "Mode" +msgstr "Modalità" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__res_model +msgid "Model name of the resource that uses this vault" +msgstr "Nome del modello della risorsa che usa questo archivio" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Modify Code" +msgstr "Modifica codice" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__months +msgid "Months" +msgstr "Mesi" + +#. module: cetmix_tower_server +#: model:cx.tower.variable,validation_message:cetmix_tower_server.variable_demo_branch +msgid "Must be lowercase and contain only letters and numbers!" +msgstr "Deve essere in minuscolo e contenere lettere e numeri!" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__my_activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "Scadenza mia attività" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__name +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__name +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Name" +msgstr "Nome" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__field_name +msgid "Name of the field that contains the secret value" +msgstr "Nome del campo che contiene il valore segreto" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_date_deadline +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "Scadenza attività successiva" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_summary +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_summary +msgid "Next Activity Summary" +msgstr "Riepilogo attività successiva" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_type_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_type_id +msgid "Next Activity Type" +msgstr "Tipo attività successiva" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__next_call +msgid "Next Execution Date" +msgstr "Data prossima esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__sync_date_next +msgid "Next Sync Date" +msgstr "Data sincronizzazione successiva" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__next_call +msgid "Next planned execution date for this task." +msgstr "Data prossima esecuzione pianificata per questo lavoro." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "No" +msgstr "No" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__no_split_for_sudo +msgid "No Split for sudo" +msgstr "Non dividere per sudo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Synced" +msgstr "Non sincronizzato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "No Template" +msgstr "Nessun modello" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "" +"No output received. Please log in manually and check for any issues.\n" +"===\n" +"CODE: %(status)s" +msgstr "" +"Nessun output ricevuto. Accedere manualmente e verifica eventuali problemi.\n" +"===\n" +"CODE: %(status)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "No runner found for command action '%(cmd_action)s'" +msgstr "Nessuna esecuzione trovata per l'azione comando '%(cmd_action)s'" + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml:0 +#, python-format +msgid "No secrets found" +msgstr "Nessun segreto trovato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "No server found for the provided reference." +msgstr "Nessun server trovato per il riferimento fornito." + +#. module: cetmix_tower_server +#. odoo-javascript +#: code:addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml:0 +#, python-format +msgid "No variables found" +msgstr "Nessuna variabile trovata" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "No/Undefined" +msgstr "No/indefinito" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__applicability__shared +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_run_wizard__applicability__shared +msgid "Non server restricted" +msgstr "Non limitato al server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Not Running" +msgstr "Non in esecuzione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__note +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__note +msgid "Note" +msgstr "Note" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of Actions" +msgstr "Numero di azioni" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of errors" +msgstr "Numero di errori" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_needaction_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "Numero di messaggi che richiedono una azione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__message_has_error_counter +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Numero di messaggi con errore di consegna" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_os +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "OS" +msgstr "SO" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "OS %(os)s used by the server '%(srv)s' is not present in the command's OS compatibility list" +msgstr "SO %(os)s usato dal server '%(srv)s' non è presente nella lista compatibilità del SO del comando " + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__os_ids +msgid "OSes" +msgstr "SO" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Odoo Environment" +msgstr "Ambiente Odoo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_delete_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "On Delete Plan" +msgstr "Su cancella piano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "On Error" +msgstr "Su errore" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +#, python-format +msgid "Only one global secret value can be defined for a key" +msgstr "Si può definire solo una un valore segreto globale per una chiave" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Only one global value can be defined for variable '%(var)s'" +msgstr "Per la variabile '%(var)s' può essere definito un solo valore globale" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/test_variable.py:0 +#, python-format +msgid "Only one global value can be defined for variable 'meme'" +msgstr "Per la variabile 'meme' può essere definito un solo valore globale" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +#, python-format +msgid "Only one secret value can be defined for a partner" +msgstr "Si può definire solo un valore segreto per un partner" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +#, python-format +msgid "Only one secret value can be defined for a server" +msgstr "Si può definire solo un valore segreto per un server" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_key_value.py:0 +#, python-format +msgid "Only one secret value can be defined for a server and partner" +msgstr "Si può definire solo un valore segreto per un server e partner" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Only values that the current user has access to are shown." +msgstr "Sono visualizzati solo i valori a cui ha accesso l'utente attuale." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open" +msgstr "Apri" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Open full form" +msgstr "Apri maschera completa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__os_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__os_id +msgid "Operating System" +msgstr "Sistema operativo" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_os +msgid "Operating Systems" +msgstr "Sistemi operativi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__option_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__option_id +msgid "Option" +msgstr "Opzione" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Option '%(val)s' is not available for variable '%(var)s'" +msgstr "L'opzione '%(val)s' non è disponibile per la variabile '%(var)s'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__option_ids +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__o +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Options" +msgstr "Opzioni" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__os_compatibility_warning +msgid "Os Compatibility Warning" +msgstr "Avviso compatibilità SO" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command__if_file_exists__overwrite +msgid "Overwrite" +msgstr "Sovrascrivi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__partner_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__partner_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Partner" +msgstr "Partner" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__partner_id +msgid "Partner to which the key belongs" +msgstr "Partner a cui appartiene la chiave" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_cetmix_tower_partner +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_partner +msgid "Partners" +msgstr "Partner" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__ssh_auth_mode__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__ssh_auth_mode__p +msgid "Password" +msgstr "Password" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__path +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__path +msgid "Path" +msgstr "Percorso" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__plan_domain +msgid "Plan Domain" +msgstr "Dominio piano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__plan_line_ids +msgid "Plan Line" +msgstr "Riga piano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__plan_line_action_id +msgid "Plan Line Action" +msgstr "Azione riga piano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__plan_line_ids_count +msgid "Plan Line Count" +msgstr "Conteggio righe piano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_line_executed_id +msgid "Plan Line Executed" +msgstr "Riga piano eseguita" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +#, python-format +msgid "Plan Lines" +msgstr "Righe piano" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard.py:0 +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__plan_log_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__plan_log_ids +#, python-format +msgid "Plan Log" +msgstr "Registro piano" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__is_running +msgid "Plan is being executed right now" +msgstr "Il piano è appena stato eseguito" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Plan line condition check failed." +msgstr "Controllo condizione riga piano fallito." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__plan_ids +msgid "Plans" +msgstr "Piani" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide IPv4 or IPv6 address for %(srv)s" +msgstr "Fornire un indirizzo un indirizzo IPv4 o IPv6 per %(srv)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH Key for %(srv)s" +msgstr "Fornire la chiave SSH per %(srv)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Please provide SSH password for %(srv)s" +msgstr "Fornire la password SSH per %(srv)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +#, python-format +msgid "Please provide values for the following configuration variables: %(variables)s" +msgstr "Fornire valori per le seguenti variabili configurabili: %(variables)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "Please provide values for the following configuration variables: %(vars)s" +msgstr "Fornire valori per le seguenti variabili configurabili: %(vars)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "Please resolve the following issues with configuration variables:" +msgstr "Risolvere il seguente problema con le variabili configurazione:" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "Please select a command to execute" +msgstr "Selezionare un comando da eseguire" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Post Run Actions" +msgstr "Azioni post esecuzione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Preview" +msgstr "Anteprima" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__parent_id +msgid "Previous Version" +msgstr "Versione anteprima" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__last_call +msgid "Previous time the task ran successfully." +msgstr "Orario precedente in cui il lavoro è stato eseguito con successo." + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_property_root +msgid "Properties" +msgstr "Proprietà" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Pull files from server" +msgstr "Ricevi file dal server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Pull from Server" +msgstr "Ricevi dal server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Push to Server" +msgstr "Invia al server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__path +msgid "" +"Put custom path to run the command.\n" +"IMPORTANT: this field does NOT support variables!" +msgstr "" +"Inserire il percorso personalizzato per eseguire il comando.\n" +"IMPORTANTE: questo campo NON supporta variabili!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +msgid "Put your notes here" +msgstr "Inserire qui le proprie note" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Put your notes here..." +msgstr "Inserire qui le proprie note..." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'datetime' library" +msgstr "Libreria Python 'datetime'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'dateutil' library" +msgstr "Libreria Python 'dateutil'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'dnspython' library. Documentation.
  • dns.resolver: wrapped dnspython. Use dns.resolver.resolve(hostname, \"A\") for DNS lookups.
  • dns.reversename: wrapped dnspython. Use dns.reversename.from_address(\"8.8.8.8\") to build and reverse PTR records.
  • dns.exception: wrapped dnspython. Catch dns.exception.DNSException to handle DNS-related errors.
" +msgstr "Libreria Python 'dnspython'. Documentazione.
  • dns.resolver: dnspython incapsulato. Utilizza dns.resolver.resolve(hostname, \"A\") per le ricerche DNS.
  • dns.reversename: dnspython incapsulato. Utilizza dns.reversename.from_address(\"8.8.8.8\") per creare e invertire i record PTR.
  • dns.exception: dnspython incapsulato. Cattura dns.exception.DNSException per gestire gli errori relativi al DNS.
" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'hashlib' library. Documentation. Available methods: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" +msgstr "Libreria Python 'hashlib'. Documentazione. Metodi disponibili: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'hmac' library. Documentation. Use 'new' to create HMAC objects. Available methods on the HMAC *object*: 'update', 'copy', 'digest', 'hexdigest'. Module-level function: 'compare_digest'." +msgstr "Libreria Python 'hmac'. Documentazione. Usare 'new' per creare oggetti HMAC. Metodi disponibili nell'*oggetto* HMAC: 'update', 'copy', 'digest', 'hexdigest'. Module-level function: 'compare_digest'." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'json' library. Available methods: 'dumps'" +msgstr "Libreria 'json' Python. Metodi disponibili: 'dumps'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'requests' library. Available methods: 'post', 'get', 'delete', 'request'" +msgstr "Libreria Python 'requests'. Metodi disponibili: 'post', 'get', 'delete', 'request'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'time' library" +msgstr "Libreria Python 'time'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'timezone' library" +msgstr "Libreria Python 'timezone'" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "Python 'tldextract' library. Use tldextract.extract() to parse domains. Check tldextract for more information." +msgstr "Libreria Python 'tldextract'. Utilizza tldextract.extract() per analizzare i domini. Consulta tldextract per ulteriori informazioni." + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__action__python_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Python code" +msgstr "Codice Python" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "Python code running error: %(err)s" +msgstr "Errore esecuzione codice Python: %(err)s" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "" +"Python code that is used to modify the variable values.\n" +"
\n" +" Available variables and functions:" +msgstr "" +"Codice Pyhon utilizzato per modificare i valori della variabile.\n" +"
\n" +" Variabili e funzioni disponibili:" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__applied_expression +msgid "" +"Python expression to apply to the variable value. \n" +"You can use general python sting functions and 're' module for regex operations. Use 'value' variable to refer to the variable value, use 'result' to assign the final result that will be used as a variable value.\n" +"Eg 'result = value.lower().replace(' ', '_')'" +msgstr "" +"Espressione Python da applicare al valore della variabile.\n" +"È possibile utilizzare le funzioni stringa generali di Python e il modulo 're' per le operazioni regex. Utilizzare la variabile 'value' per fare riferimento al valore della variabile, utilizzare 'result' per assegnare il risultato finale che verrà utilizzato come valore della variabile.\n" +"Es. 'result = value.lower().replace(' ', '_')'" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command__if_file_exists__raise +msgid "Raise Error" +msgstr "Genera errore" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line.py:0 +#, python-format +msgid "Recursive plan call detected in plan %(name)s." +msgstr "Rilevata chiamata piano ricorsiva nel piano %(name)s." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_os__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_reference_mixin__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__reference +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__reference +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Reference" +msgstr "Riferimento" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__reference_code +msgid "Reference Code" +msgstr "Codice riferimento" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_command_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_file_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_project_rel_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_remote_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_git_source_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_key_value_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_os_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_action_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_line_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_plan_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_reference_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_scheduled_task_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_log_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_server_template_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_shortcut_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_tag_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_webhook_authenticator_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_webhook_eval_mixin_reference_unique +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_webhook_reference_unique +msgid "Reference must be unique" +msgstr "Il riferimento deve essere univoco" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Reference. Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "Riferimento. Può contenere lettere inglesi, cifre e '_'. Lasciare vuoto per la generazione automatica" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_log_view_form +msgid "Refresh" +msgstr "Aggiorna" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Refresh All" +msgstr "Aggiorna tutto" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Regex expression, eg. ^[a-z0-9]+$" +msgstr "Espressione regex, es. ^[a-z0-9]+$" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable__validation_pattern +msgid "" +"Regex pattern to validate the variable values using the 're.match' function. Eg. ^[a-z0-9]+$ \n" +"If empty, the variable values will not be validated." +msgstr "" +"Schema regex per validare i valori della variabile usando la funzione 're.match'. Es. ^[a-z0-9]+$ \n" +"Se vuoto, i valori della variabile non verranno validati." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "" +"Remember: Python code is executed on the Tower server, not on the remote\n" +" one." +msgstr "Ricorda: il codice Python viene eseguito sul server Tower, non su quello remoto." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Remember: Python code is executed on the Tower server, not on the remote one." +msgstr "Ricorda: il codice Python viene eseguito sul server Tower, non su quello remoto." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__rendered_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_code +msgid "Rendered Code" +msgstr "Codice realizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_name +msgid "Rendered Name" +msgstr "Nome realizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__rendered_server_dir +msgid "Rendered Server Dir" +msgstr "Cartella server realizzato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__interval_number +msgid "Repeat every x." +msgstr "Ripeti ogni x." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__required +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__required +msgid "Required" +msgstr "Richiesto" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__res_id +msgid "Resource ID" +msgstr "ID risorsa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__res_model +msgid "Resource Model" +msgstr "Modello risorsa" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__command_response +msgid "Response" +msgstr "Risposta" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__activity_user_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__activity_user_id +msgid "Responsible User" +msgstr "Utente responsabile" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__result +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Result" +msgstr "Risultato" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_root +msgid "Root" +msgstr "Radice" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Run" +msgstr "Esegui" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +#, python-format +msgid "Run Command" +msgstr "Esegui comando" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_command_run_wizard +msgid "Run Command in Wizard" +msgstr "Eseguire comando nella procedura guidata" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model:ir.actions.server,name:cetmix_tower_server.action_execute_cx_tower_plan +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__plan_run_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_kanban +#, python-format +msgid "Run Flight Plan" +msgstr "Esegui piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_plan_run_wizard +msgid "Run Flight Plan in Wizard" +msgstr "Esegui piano di volo nella procedura guidata" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Run Manually" +msgstr "Esegui manualmente" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run New Command" +msgstr "Esegui nuovo comando" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Run Plan" +msgstr "Esegui piano" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "Run Result" +msgstr "Esegui risultato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Run a flight plan" +msgstr "Esegui un piano di volo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run code as it appears in 'Rendered code' in wizard and return to wizard. Result will not be logged" +msgstr "Eseguire il codice come appare in 'Codice realizzato' nella procedura guidata e torna alla procedura guidata. Il risultato non verrà registrato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run code using server method and log result" +msgstr "Esegue il codice utilizzando il metodo server e registra il risultato" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Run command" +msgstr "Esegui comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_shortcut__use_sudo +msgid "Run command using 'sudo'" +msgstr "Esegui comando usando 'sudo'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__use_sudo +msgid "Run commands using 'sudo'" +msgstr "Esegui comando usando 'sudo'" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__use_sudo +msgid "Run commands using 'sudo'. Leave empty if 'sudo' is not needed." +msgstr "Esegui comando usando 'sudo'. Lasciare vuoto se 'sudo' non è necessario." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Run in wizard" +msgstr "Esegui procedura guidata" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan__on_error_action__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_plan_line_action__action__n +msgid "Run next command" +msgstr "Esegui comando successivo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Run on" +msgstr "Esegui su" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Run scheduled tasks" +msgstr "Esegui lavori schedulati" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "Running" +msgstr "In esecuzione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Running Now" +msgstr "In esecuzione ora" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_auth_mode +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_auth_mode +msgid "SSH Auth Mode" +msgstr "Metodo autorizzazione SSH" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH Client is not defined." +msgstr "Il client SSH non è definito." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "SSH Command" +msgstr "Comando SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__k +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key" +msgstr "Chiave SSH" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_key +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "SSH Key / Secret" +msgstr "Chiave / segreto SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_password +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_password +msgid "SSH Password" +msgstr "Password SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__secret_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_key_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_key_id +msgid "SSH Private Key" +msgstr "Chiave privata SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_username +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "SSH Username" +msgstr "Nome utente SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_run_wizard__action__ssh_command +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "SSH command" +msgstr "Comando SSH" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH connection error %(err)s" +msgstr "Errore connessione SSH %(err)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__ssh_port +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_port +msgid "SSH port" +msgstr "Porta SSH" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "SSH run command error %(err)s" +msgstr "Errore comando esecuzione SSH %(err)s" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_scheduled_task +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__scheduled_task_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__scheduled_task_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__scheduled_task_id +msgid "Scheduled Task" +msgstr "Lavoro schedulato" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_scheduled_task +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__scheduled_task_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__scheduled_task_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_scheduled_task +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Scheduled Tasks" +msgstr "LAvori schedulati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__scheduled_task_id +msgid "Scheduled task that triggered this command" +msgstr "Lavoro schedulato che ha attivato questo comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__scheduled_task_id +msgid "Scheduled task that triggered this flight plan" +msgstr "Lavoro schedulato che ha attivato questo piano di volo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +#, python-format +msgid "Scheduled tasks run successfully." +msgstr "Lavori schedulati eseguiti con successo." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.res_config_settings_view_form +msgid "Scheduled tasks will be run automatically using cron job." +msgstr "I lavori schedulati verranno eseguiti automaticamente usando un lavoro cron." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +msgid "Search Command Log" +msgstr "Ricerca registro comando" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +msgid "Search Commands" +msgstr "Ricerca comandi" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +msgid "Search File Templates" +msgstr "Ricerca modelli file" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Search Files" +msgstr "Ricerca file" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Search Flight Plan Log" +msgstr "Ricerca registro piano di volo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Search Flight Plans" +msgstr "Ricerca piani di volo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Search Keys/Secrets" +msgstr "Ricerca kiavi/segreti" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_os_search_view +msgid "Search OS" +msgstr "Ricerca SO" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_scheduled_task_search_view +msgid "Search Scheduled Tasks" +msgstr "Ricerca lavori schedulati" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_search_view +msgid "Search Server Templates" +msgstr "Ricerca modelli server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Search Servers" +msgstr "Ricerca server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Search Shortcuts" +msgstr "Scorciatoie ricerca " + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_tag_search_view +msgid "Search Tags" +msgstr "Ricerca etichette" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Search Values" +msgstr "Ricerca valori" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_key__key_type__s +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_search_view +msgid "Secret" +msgstr "Segreto" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_vault__data +msgid "Secret Data" +msgstr "Dati segreti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__secret_value +msgid "Secret Value" +msgstr "Valore segreto" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Secret Values" +msgstr "Valori segreti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_mixin__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_partner__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__secret_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_form_inherit_cetmix_tower +msgid "Secrets" +msgstr "Segreti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__applicability +msgid "" +"Selected server(s): only Commands that are specific to the selected server(s)\n" +"Non server restricted: all Commands that are not specific to any server" +msgstr "" +"Server selezionati: solo comandi specifici per il/i server selezionato/i\n" +"Non limitato al server: tutti i comandi che non sono specifici per alcun server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_run_wizard__applicability +msgid "" +"Selected server(s): only Flight Plans that are specific to the selected server(s)\n" +"Non server restricted: all Flight Plans that are not specific to any server" +msgstr "" +"Server selezionati: solo i piani di volo specifici per il/i server selezionato/i\n" +"Non limitato al server: tutti i piani di volo non specifici per alcun server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__sequence +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__sequence +msgid "Sequence" +msgstr "Sequenza" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key_value__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_host_key_wizard__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_id +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Server" +msgstr "Server" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_ir_actions_server +msgid "Server Action" +msgstr "Azione server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_partner__server_count +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__server_count +msgid "Server Count" +msgstr "Conteggio server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__server_log_ids +msgid "Server Log" +msgstr "Registro server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_log_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server Logs" +msgstr "Registri server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__name +msgid "Server Name" +msgstr "Nome server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__server_response +msgid "Server Response" +msgstr "Risposta server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_status +msgid "Server Status" +msgstr "Stato server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__server_template_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__server_template_id +msgid "Server Template" +msgstr "Modello server" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server_template +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__server_template_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__server_template_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_template_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +msgid "Server Templates" +msgstr "Modelli server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Server URL, eg 'https://meme.example.com'" +msgstr "URL server, es. 'https://meme.example.com'" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_form +msgid "Server Version" +msgstr "Versione server" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Server not found" +msgstr "Server non trovato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__server_response +msgid "" +"Server response received during the last operation.\n" +"Default value if no error happened is 'ok'.\n" +"Otherwise there will be a server error message logged." +msgstr "" +"Risposta del server ricevuta durante l'ultima operazione.\n" +"Il valore predefinito se non si è verificato alcun errore è 'ok'.\n" +"In caso contrario, verrà registrato un messaggio di errore del server." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Server tight" +msgstr "Server ridotto" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__server_id +msgid "Server to which the key belongs" +msgstr "Server a cui appartiene la chiave" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__url +msgid "Server web interface, eg 'https://doge.example.com'" +msgstr "Interfaccia server web, es. 'https://doge.example.com'" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_tag__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_partner__server_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_res_users__server_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_root +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_res_partner_form_inherit_cetmix_tower +msgid "Servers" +msgstr "Server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_ids +msgid "" +"Servers on which the command will be run.\n" +"If empty, command can be run on all servers" +msgstr "" +"Server in cui il comando verrà eseguito.\n" +"Se vuoto, il comando può essere eseguito su tutti i server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Set Variable Values" +msgstr "Imposta valori veriabile" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__server_status +msgid "Set the following status if command finishes with success. Leave 'Undefined' if you don't need to update the status" +msgstr "Impostare lo stato seguente se il comando è eseguito con successo. Lasciare 'Non definito' se non serve aggiornare lo stato" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_settings +msgid "Settings" +msgstr "Impostazioni" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_shortcut_search_view +msgid "Shortcut" +msgstr "Scorciatoia" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_shortcut +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__shortcut_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__shortcut_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_shortcut +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Shortcuts" +msgstr "Scorciatoie" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "Show Commands" +msgstr "Visualizza comandi" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "Show Flight Plans" +msgstr "Visualizza piani di volo" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_server_host_key_wizard +msgid "Show Host Key" +msgstr "Visualizza chiave SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__show_servers +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__show_servers +msgid "Show Servers" +msgstr "Visualizza server" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command__if_file_exists__skip +msgid "Skip" +msgstr "Salta" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__skip_host_key +msgid "Skip Host Key" +msgstr "Salta chiave SSH" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Skipped" +msgstr "Saltato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "Some servers don't support this command" +msgstr "Alcuni server non supportano questo comando" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "" +"Some variable options are invalid:\n" +"%(detailed_message)s" +msgstr "" +"Alcune opzioni variabile non sono valide:\n" +"%(detailed_message)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__source +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__source +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Source" +msgstr "Origine" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +msgid "Start date" +msgstr "Data partenza" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__start_date +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__start_date +msgid "Started" +msgstr "Partito" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__plan_status +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__status +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_search_view +msgid "Status" +msgstr "Stato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_state +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"Stato basato sulle attività\n" +"Scaduto: la data di scadenza è già passata\n" +"Oggi: la data dell'attività è oggi\n" +"Pianificato: attività future." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Stop" +msgstr "Ferma" + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.action_stop_cx_tower_plan_log +msgid "Stop Flight Plan" +msgstr "Ferma piano di volo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__is_stopped +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_view_form +msgid "Stopped" +msgstr "Fermato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command_log.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_plan_log.py:0 +#, python-format +msgid "Stopped by user %(user)s" +msgstr "Fermato dall'utente %(user)s" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_variable__variable_type__s +msgid "String" +msgstr "Stringa" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_log_search_view +#, python-format +msgid "Success" +msgstr "Successo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Sync Error" +msgstr "Errore sincronizzazione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Synced" +msgstr "Sincronizzato" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_tag +msgid "Tag" +msgstr "Etichetta" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_search_view +msgid "Tagged" +msgstr "Etichettato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__tag_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__tag_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_tag +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Tags" +msgstr "Etichette" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__template_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Template" +msgstr "Modello" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__template_code +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Template Code" +msgstr "Codice modello" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.cx_tower_file_template_action +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_file_template +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_server_template +msgid "Templates" +msgstr "Modelli" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Test Connection" +msgstr "Test connessione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Text" +msgstr "Testo" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_option.py:0 +#, python-format +msgid "" +"The access level for Variable Option '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Option Access Level: %(val_level)s" +msgstr "" +"Il livello di accesso per l'opzione variabile '%(value)s' non può essere inferiore al livello di accesso della sua variabile '%(variable)s'.\n" +"Livello di accesso variabile: %(var_level)s\n" +"Livello di accesso opzione variabile: %(val_level)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "" +"The access level for Variable Value '%(value)s' cannot be lower than the access level of its Variable '%(variable)s'.\n" +"Variable Access Level: %(var_level)s\n" +"Variable Value Access Level: %(val_level)s" +msgstr "" +"Il livello di accesso per il valore variabile '%(value)s' non può essere inferiore al livello di accesso della sua variabile '%(variable)s'.\n" +"Livello di accesso variabile: %(var_level)s\n" +"Livello di accesso valore variabile: %(val_level)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan.py:0 +#, python-format +msgid "The access level of command(s) '%(command_names)s' included in the current Flight plan is higher than the access level of the Flight plan itself. Please ensure that you want to allow those commands to be run anyway." +msgstr "Il livello di accesso dei comandi '%(command_names)s' inclusi nel piano di volo corrente è superiore al livello di accesso del piano di volo stesso. Assicurati di voler davvero consentire a tutti quei comandi di essere eseguiti comunque." + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option_name +msgid "The combination of Name and Variable must be unique." +msgstr "La combinazione di nome e variabile deve essere univoca." + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_option_unique_variable_option +msgid "The combination of Value and Variable must be unique." +msgstr "La combinazione di valore e variabile deve essere univoca." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server.py:0 +#, python-format +msgid "The file %(f_path)s not found." +msgstr "File %(f_path)s non trovato." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_vault__data +msgid "The secret data to be stored in the vault" +msgstr "Il dato segreto da salvare nell'archivio" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +#, python-format +msgid "The selected task interval is too low in relation to the general system settings. This may lead to task execution delays." +msgstr "L'intervallo del lavoro selezionato è troppo piccolo in relazione alle impostazioni generali di sistema. Questo può portare a ritardi di esecuzione del lavoro." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_action_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "Then" +msgstr "Quindi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__plan_delete_id +msgid "This Flightplan will be executed when the server is deleted" +msgstr "Questo piano di volo verrà eseguito quando il server viene cancellato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__plan_delete_id +msgid "This Flightplan will be run when the server is deleted" +msgstr "Questo piano di volo verrà eseguito quando il server viene cancellato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__on_error_action +msgid "This action will be triggered on error if no command action can be applied" +msgstr "Questa azione verrà eseguita in caso di errore se non è possibile applicare alcuna azione di comando" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "This command can be used only in Flight Plans." +msgstr "Questo comando può essere usato solo nei piani di volo." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__note +msgid "This field is used to put some notes regarding template." +msgstr "Questo file è usato per inserire alcune note relative al modello." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__command_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_template_mixin__code +msgid "This field will be rendered using variables" +msgstr "Questo campo verrà elaborato usando le variabili" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__file_template_id +msgid "This file template will be used to create log files when server is created from a template" +msgstr "Questo modello di file verrà utilizzato per creare file di registro quando il server viene creato da un modello" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__flight_plan_id +msgid "This flight plan will be run upon server creation" +msgstr "Questo piano di volo verrà eseguito durante la creazione del server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template_create_wizard__ssh_username +msgid "This is required, however you can change this later in the server settings" +msgstr "Questo è richiesto, comunque lo si può modificare dopo nelle impostazioni del server" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__file_template_id +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__file_template_id +msgid "This template will be used to create or update the pushed file" +msgstr "Questo modello verrà utilizzato per creare o aggiornare il file inviato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key_value__is_global +msgid "This value is applicable to all servers and partners" +msgstr "Questo valore è applicabile a tutti i server e partner" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__duration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_log__duration +msgid "Time consumed for execution, seconds" +msgstr "Tempo utilizzato per l'esecuzione, secondi" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_res_config_settings__cetmix_tower_command_timeout +msgid "Timeout for commands in seconds after which the command will be terminated" +msgstr "Timeout in secondi per i comandi dopo i quali il comando verrà terminato" + +#. module: cetmix_tower_server +#: model:ir.ui.menu,name:cetmix_tower_server.menu_tools +msgid "Tools" +msgstr "Strumenti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__file_count +msgid "Total Files" +msgstr "File totali" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file__source__tower +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_file_template__source__tower +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_view_search +msgid "Tower" +msgstr "Tower" + +#. module: cetmix_tower_server +#: model:ir.model,name:cetmix_tower_server.model_cx_tower_variable_mixin +msgid "Tower Variables mixin" +msgstr "Mixin variabili Tower" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "Trigger shortcut" +msgstr "Scorciatoia attivazione" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_log_view_form +msgid "Triggered Commands" +msgstr "Comandi attivati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_command_log_ids +msgid "Triggered Flight Plan Commands" +msgstr "Comandi piano di volo attivati" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__triggered_plan_log_id +msgid "Triggered Plan Log" +msgstr "Registro piano attivato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_type +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_type +msgid "Type" +msgstr "Tipo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__activity_exception_decoration +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "Tipo attività eccezione sul record." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__url +msgid "URL" +msgstr "URL" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to delete file '%(f)s'.\n" +"Delete operation is not supported for 'server' type files." +msgstr "" +"Impossibile eliminare il file '%(f)s'. \n" +"L'operazione di eliminazione non è supportata per i file di tipo 'server'." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_scheduled_task.py:0 +#, python-format +msgid "Unable to run scheduled task '%(f)s'. Error: %(e)s" +msgstr "Impossibile eseguire il lavoro schedulato'%(f)s'. Errore: %(e)s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" +"Impossibile caricare il file '%(f)s'. \n" +"L'operazione di caricamento non è supportata per i file di tipo 'server'." + +#. module: cetmix_tower_server +#: model:ir.actions.server,name:cetmix_tower_server.cetmix_tower_file_upload_action +msgid "Upload" +msgstr "Carica" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_shortcut__use_sudo +msgid "Use Sudo" +msgstr "Utilizzare sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__use_sudo +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard__use_sudo +msgid "Use sudo" +msgstr "Utilizzare sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH Key" +msgstr "Utilizzare una chiave SSH" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__server_ssh_ids +msgid "Used as SSH key in the following servers" +msgstr "Utilizzata una chiave SSH nei seguenti server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +msgid "Used for" +msgstr "Utilizzato per" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_search_view +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "Used in Plans" +msgstr "Usato nei piani" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Used in Values" +msgstr "Usato nei valori" + +#. module: cetmix_tower_server +#: model:res.groups,name:cetmix_tower_server.group_user +msgid "User" +msgstr "Utente" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_command.py:0 +#, python-format +msgid "UserError. Helper to raise UserError." +msgstr "UserError. Aiuto per generare UserError." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users" +msgstr "Utenti" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_access_role_mixin__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_file_template__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_key__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_scheduled_task__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__user_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_template__user_ids +msgid "Users who can view this record" +msgstr "Utenti che possono vedere questo record" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__validation_message +msgid "Validation Message" +msgstr "Messaggio di validazione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__validation_pattern +msgid "Validation Pattern" +msgstr "Schema di validazione" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__value_char +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__value_char +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Value" +msgstr "Valore" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids_count +msgid "Value Count" +msgstr "Conteggio valori" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Value Modifier" +msgstr "Modificatore valore" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#: code:addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py:0 +#, python-format +msgid "Value is invalid" +msgstr "Il valore è valido" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_key__value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__value_ids +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "Values" +msgstr "Valori" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_option__variable_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_value_search_view +msgid "Variable" +msgstr "Variabile" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable_value.py:0 +#, python-format +msgid "Variable '%(var)s' can only be assigned to one of the models at a time: Server, Server Template, or Plan Line Action." +msgstr "La variabile '%(var)s' può essere assegnata solo ad un modello alla volta: server, modello server, o azione riga piano." + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_reference +msgid "Variable Reference" +msgstr "Riferimento variabile" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_custom_variable_value_mixin__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task_cv__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__variable_value_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_value_ids +msgid "Variable Value" +msgstr "Valore variabile" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable__variable_value_ids_count +msgid "Variable Value Count" +msgstr "Conteggio valori variabile" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable_value +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_log__variable_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_log__variable_values +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template__variable_value_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_value +#, python-format +msgid "Variable Values" +msgstr "Valori variabile" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_value_tower_variable_value_uniq +msgid "Variable can be declared only once for the same record!" +msgstr "La variabile può essere dichiarata solo una volta per lo stesso record!" + +#. module: cetmix_tower_server +#: model:ir.model.constraint,message:cetmix_tower_server.constraint_cx_tower_variable_name_uniq +msgid "Variable names must be unique" +msgstr "Il nome variabile deve essere univoco" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable not found" +msgstr "Variabile non trovato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_template.py:0 +#, python-format +msgid "Variable reference '%(var_ref)s' has an invalid option reference '%(opt_ref)s'." +msgstr "Il riferimento variabile '%(var_ref)s' ha un riferimento opzione non valido '%(opt_ref)s'." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_template_mixin.py:0 +#, python-format +msgid "Variable syntax error: %s" +msgstr "Errore sintassi variabile: %s" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value created" +msgstr "Valore variabile creato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cetmix_tower.py:0 +#, python-format +msgid "Variable value updated" +msgstr "Valore variabile aggiornato" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server__variable_value_ids +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_variable_mixin__variable_value_ids +msgid "Variable values for selected record" +msgstr "Valori variabile per il record selezionato" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_variable.py:0 +#, python-format +msgid "" +"Variable: %(var)s, Value: %(val)s\n" +"%(msg)s" +msgstr "" +"Variabile: %(var)s, Valore: %(val)s\n" +"%(msg)s" + +#. module: cetmix_tower_server +#: model:ir.actions.act_window,name:cetmix_tower_server.action_cx_tower_variable +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_file_template__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_line__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_template_mixin__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_variable_value__variable_ids +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable +#: model:ir.ui.menu,name:cetmix_tower_server.menu_cx_tower_variable_root +msgid "Variables" +msgstr "Variabili" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_scheduled_task__warning_message +msgid "Warning Message" +msgstr "Messaggio di avviso" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__os_compatibility_warning +msgid "Warning about OS compatibility of the command" +msgstr "Avviso di compatibilità SO del comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_scheduled_task__interval_type__weeks +msgid "Weeks" +msgstr "Settimane" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command__if_file_exists +msgid "" +"What to do if file already exists on the server.\n" +"- Skip: Do not create or update the file.\n" +"- Overwrite: Replace the existing file with the new one.\n" +"- Raise Error: Raise an error if the file already exists." +msgstr "Cosa fare se il file esiste già sul server.- Salta: non creare o aggiornare il file.- Sovrascrivi: sostituisci il file esistente con quello nuovo.- Genera errore: genera un errore se il file esiste già." + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_log__path +msgid "Where command was executed" +msgstr "Dove è stato eseguito il comando" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan__custom_exit_code +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line_action__custom_exit_code +msgid "Will be used instead of the command exit code" +msgstr "Può essere usato al posto del comando codice uscita" + +#. module: cetmix_tower_server +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_command_run_wizard__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_plan_line__use_sudo +#: model:ir.model.fields,help:cetmix_tower_server.field_cx_tower_server_log__use_sudo +msgid "Will use sudo based on server settings.If no sudo is configured will run without sudo" +msgstr "Utilizzerà sudo in base alle impostazioni del server. Se non è configurato sudo verrà eseguito senza sudo" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__p +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__use_sudo__p +msgid "With password" +msgstr "Con password" + +#. module: cetmix_tower_server +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_command_log__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template__use_sudo__n +#: model:ir.model.fields.selection,name:cetmix_tower_server.selection__cx_tower_server_template_create_wizard__use_sudo__n +msgid "Without password" +msgstr "Senza password" + +#. module: cetmix_tower_server +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_command_run_wizard_variable_value__wizard_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_plan_run_wizard_variable_value__wizard_id +#: model:ir.model.fields,field_description:cetmix_tower_server.field_cx_tower_server_template_create_wizard_line__wizard_id +msgid "Wizard" +msgstr "Procedura guidata" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "Wrong action" +msgstr "Azione errata" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "You are not allowed to execute commands in wizard" +msgstr "Non si è autorizzati ad eseguire comandi nella procedura guidata" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_server_log.py:0 +#, python-format +msgid "You are not allowed to modify the server log output." +msgstr "Non si è abilitati a modificare l'uscita del log del server." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "You cannot execute an empty command" +msgstr "Non si può eseguire un comando vuoto" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py:0 +#, python-format +msgid "You cannot run custom code on multiple servers at once." +msgstr "Non è possibile eseguire codice personalizzato su server multipli contemporaneamente." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "" +"You need 'Manager' access to the server to override the default configuration values.\n" +" Without this access, the server's configured values will be used." +msgstr "" +"Per sovrascrivere i valori di configurazione predefiniti, è necessario l'accesso \"Responsabile\" al server. \n" +" Senza questo accesso, verranno utilizzati i valori configurati del server." + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/ir_actions_server.py:0 +#, python-format +msgid "You need to have 'write' access to all servers you want to run this action on." +msgstr "È necessario disporre dell'accesso in scrittura a tutti i server su cui si desidera eseguire questa azione." + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +msgid "e.g. /home/user This field does NOT support variables" +msgstr "es. /home/user Questo file NON supporta variabili" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_line_view_form +msgid "e.g. /such/much/{{ path }}, overrides command path" +msgstr "es. /such/much/{{ path }}, forza il percorso comando" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "general python functions (eg. lower(), replace(), etc.)" +msgstr "funzioni Python generali (es. lower(), replace(), ecc.)" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/tests/common.py:0 +#, python-format +msgid "groups_ref must be string or list of strings!" +msgstr "groups_ref deve essere stringa o lista di stringhe!" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "managers who can modify this record" +msgstr "responsabili che possono modificare questo record" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "managers who can modify this server" +msgstr "responsabili che possono modificare questo server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "managers who can modify this template" +msgstr "responsabili che possono modificare questo modello" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "new server name" +msgstr "nome server nuovo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +msgid "optional, eg /home/{{ tower.server.username }}" +msgstr "opzionale, es. /home/{{ tower.server.username }}" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "re: regex operations (eg. re.sub, re.match, etc.)" +msgstr "re: operarioni regex (es. re.sub, re.match, ecc.)" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "result: final result that will be used as a variable value" +msgstr "result: risultato finale che verrà usato come valore della variabile" + +#. module: cetmix_tower_server +#. odoo-python +#: code:addons/cetmix_tower_server/models/cx_tower_plan_line_action.py:0 +#, python-format +msgid "then" +msgstr "quindi" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_create_wizard_view_form +msgid "this can be changed later" +msgstr "questo può essere modificato dopo" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "undefined" +msgstr "non definito" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_view_form +msgid "users who can access this server" +msgstr "utenti che possono acceder a questo server" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_server_template_view_form +msgid "users who can access this template" +msgstr "utenti che possono accedere a questo modello" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_file_template_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_key_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.view_cx_tower_scheduled_task_view_form +msgid "users who can view this record" +msgstr "utenti che possono accedere a questo record" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_variable_view_form +msgid "value: variable value" +msgstr "value: valore della variabile" + +#. module: cetmix_tower_server +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_command_run_wizard_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server.cx_tower_plan_run_wizard_view_form +msgid "with tags" +msgstr "quali etichette" + +#~ msgid "OSs" +#~ msgstr "SO" + +#~ msgid "" +#~ "\n" +#~ "

Help with Python expressions

\n" +#~ "
\n" +#~ "

\n" +#~ " Each Python code command returns the result value " +#~ "which is a dictionary.\n" +#~ "
There are two keys in the dictionary:\n" +#~ "

    \n" +#~ "
  • exit_code: Integer. Exit code of the command. \"0\" " +#~ "means success, any other value means failure. Default value is \"0\".\n" +#~ "
  • message: String. Message to be logged. Default value " +#~ "is \"None\".
  • \n" +#~ "
\n" +#~ "Here is an example of a python code command:\n" +#~ "\n" +#~ " server_name = server.name\n" +#~ " result = {\"exit_code\": 0, \"message\": \"Server name is \" + " +#~ "server_name}\n" +#~ "\n" +#~ "

\n" +#~ "
\n" +#~ "Please refer to the official documentation for more information and examples.\n" +#~ "
\n" +#~ "Various fields may use Python code or Python expressions. The\n" +#~ " following variables can be used:

\n" +#~ "
    \n" +#~ "
  • user: Current Odoo user
  • \n" +#~ "
  • env: Odoo Environment on which the action is\n" +#~ " triggered
  • \n" +#~ "
  • server: Server on which the command is run
  • \n" +#~ "
  • tower: 'cetmix.tower' helper class shortcut
  • \n" +#~ "
  • time, datetime, dateutil\n" +#~ " , timezone: useful Python libraries
  • \n" +#~ "
  • requests: Python 'requests' library. Available methods: " +#~ "'post', 'get', 'delete', 'request'
  • \n" +#~ "
  • json: Python 'json' library. Available methods: 'dumps'\n" +#~ "
  • hashlib: Python 'hashlib' library.\n" +#~ " Available methods: 'sha1', 'sha224', 'sha256', 'sha384', " +#~ "'sha512', 'sha3_224', 'sha3_256',\n" +#~ " 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', " +#~ "'blake2s', 'md5', 'new'
  • \n" +#~ "
  • hmac: Python 'hmac' library. Use 'new' to create HMAC " +#~ "objects.\n" +#~ " Available methods on the HMAC *object*: 'update', 'copy', " +#~ "'digest', 'hexdigest'.\n" +#~ " Module-level function: 'compare_digest'.
  • \n" +#~ "
  • UserError: Warning Exception to use with \n" +#~ " raise
  • \n" +#~ "
\n" +#~ msgstr "" +#~ "\n" +#~ "

Aiuto con le espressioni Python

\n" +#~ "
\n" +#~ "Diversi campi pssono usare codice o espressioni Python. Possono\n" +#~ " essere usate le seguenti variabili:

\n" +#~ "
    \n" +#~ "
  • user: utente attuale Odoo
  • \n" +#~ "
  • env:ambiente Odoo dove viene attivata \n" +#~ " l'azione
  • \n" +#~ "
  • server: server dove viene eseguito il codice
  • \n" +#~ "
  • tower: scorciatoia alla classe aiuto 'cetmix.tower'
  • \n" +#~ "
  • time, datetime, dateutil\n" +#~ " , timezone: librerie Python utili
  • \n" +#~ "
  • requests: libreria Python 'requests' . Metodi " +#~ "disponibili: 'post', 'get', 'delete', 'request'
  • \n" +#~ "
  • json: libreria Python 'json'. metodi disponibili: " +#~ "'dumps'
  • \n" +#~ "
  • hashlib: libreria Python 'hashlib'.\n" +#~ " metodi disponibili: 'sha1', 'sha224', 'sha256', 'sha384', " +#~ "'sha512', 'sha3_224', 'sha3_256',\n" +#~ " 'sha3_384', 'sha3_512', 'shake_128', 'shake_256', 'blake2b', " +#~ "'blake2s', 'md5', 'new'
  • \n" +#~ "
  • hmac: libreria Python 'hmac'. Usare 'new' per creare un " +#~ "oggetto HMAC.\n" +#~ " Metodi disponibili nell'*oggetto* HMAC: 'update', 'copy', " +#~ "'digest', 'hexdigest'.\n" +#~ " Funzione a livello modulo: 'compare_digest'.
  • \n" +#~ "
  • UserError: eccezione avviso da utilizzare con \n" +#~ " raise
  • \n" +#~ "
\n" + +#~ msgid "" +#~ "# Please refer to the 'Help' tab and documentation for more information.\n" +#~ "#\n" +#~ "# You can return command result in the 'result' variable which is a " +#~ "dictionary:\n" +#~ "# result = {\"exit_code\": 0, \"message\": \"Some message\"}\n" +#~ "# default value is {\"exit_code\": 0, \"message\": None}\n" +#~ "#\n" +#~ "# Available variables:\n" +#~ "# - user: Current Odoo User\n" +#~ "# - env: Odoo Environment on which the action is triggered\n" +#~ "# - server: server on which the command is run\n" +#~ "# - tower: 'cetmix.tower' helper class\n" +#~ "# - time, datetime, dateutil, timezone: useful Python libraries\n" +#~ "# - requests: Python 'requests' library. Available methods: 'post', " +#~ "'get', 'delete', 'request'\n" +#~ "# - json: Python 'json' library. Available methods: 'dumps'\n" +#~ "# - hashlib: Python 'hashlib' library. Available methods: 'sha1', " +#~ "'sha224', 'sha256',\n" +#~ "# 'sha384', 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', " +#~ "'shake_128',\n" +#~ "# 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'\n" +#~ "# - hmac: Python 'hmac' library. Use 'new' to create HMAC objects.\n" +#~ "# Available methods on the HMAC *object*: 'update', 'copy', 'digest', " +#~ "'hexdigest'.\n" +#~ "# Module-level function: 'compare_digest'.\n" +#~ "# - float_compare: Odoo function to compare floats based on specific " +#~ "precisions\n" +#~ "# - UserError: Warning Exception to use with raise\n" +#~ msgstr "" +#~ "# Fare riferimento alla linguetta 'Aiuto' e alla documentazione per " +#~ "ulteriori informazioni.\n" +#~ "#\n" +#~ "# Si può restituire un risultato del comando nella variabile 'result' che " +#~ "è un dizionario:\n" +#~ "# result = {\"exit_code\": 0, \"message\": \"Un messaggio\"}\n" +#~ "# il valore predefinito è {\"exit_code\": 0, \"message\": None}\n" +#~ "#\n" +#~ "# Variabili disponibili:\n" +#~ "# - user: utente Odoo attuale\n" +#~ "# - env: Ambiente Odoo nel quale l'azione è attivata\n" +#~ "# - server: il server sul quale viene eseguito il comando\n" +#~ "# - tower: classe aiuto 'cetmix.tower'\n" +#~ "# - time, datetime, dateutil, timezone: librerie Python utili\n" +#~ "# - requests: libreria Python 'requests'. Metodi disponibili: 'post', " +#~ "'get', 'delete', 'request'\n" +#~ "# - json: libreria Python 'json'. Metodi disponibili: 'dumps'\n" +#~ "# - hashlib: libreria Python 'hashlib'. Metodi disponibili: 'sha1', " +#~ "'sha224', 'sha256',\n" +#~ "# 'sha384', 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', " +#~ "'shake_128',\n" +#~ "# 'shake_256', 'blake2b', 'blake2s', 'md5', 'new'\n" +#~ "# - hmac: libreria Python 'hmac'. Usare 'new' per creare aun nuovo " +#~ "oggetto HMAC.\n" +#~ "# Metodi disponibili nell *oggetto* HMAC: 'update', 'copy', 'digest', " +#~ "'hexdigest'.\n" +#~ "# Funzione livello modulo: 'compare_digest'.\n" +#~ "# - float_compare: funzione Odoo per confrontare floats in base ad una " +#~ "precisione specifica\n" +#~ "# - UserError: eccezione avviso da usare con raise\n" + +#~ msgid "" +#~ "# Run any SSH command on the target system\n" +#~ "# Examples: ls, cd, pwd, mkdir, rm\n" +#~ "# Adapt commands to your specific OS.\n" +#~ msgstr "" +#~ "# Eseguire qualsiasi comando SSH nel sistema destinazione\n" +#~ "# Esempi: ls, cd, pwd, mkdir, rm\n" +#~ "# Adattare i comandi al proprio SO.\n" + +#~ msgid "1 day" +#~ msgstr "1 giorno" + +#~ msgid "1 hour" +#~ msgstr "1 ora" + +#~ msgid "1 week" +#~ msgstr "1 settimana" + +#~ msgid "1 year" +#~ msgstr "1 anno" + +#~ msgid "10 min" +#~ msgstr "10 minuti" + +#~ msgid "12 hour" +#~ msgstr "12 ore" + +#~ msgid "2 hour" +#~ msgstr "2 ore" + +#~ msgid "30 min" +#~ msgstr "30 minuti" + +#~ msgid "6 hour" +#~ msgstr "6 ore" + +#~ msgid "" +#~ "\n" +#~ " x = 2*10\n" +#~ " COMMAND_RESULT = {\"exit_code\": x, " +#~ "\"message\": \"This will be\n" +#~ " logged as an error message because " +#~ "exit code !=0\"}\n" +#~ "" +#~ msgstr "" +#~ "\n" +#~ " x = 2*10\n" +#~ " COMMAND_RESULT = {\"exit_code\": x, " +#~ "\"message\": \"Questo verrà\n" +#~ " registrato come un messaggio di " +#~ "errore perché il codice di uscita è diverso da 0\"}\n" +#~ "" + +#~ msgid "" +#~ "UserError: Warning Exception to use with \n" +#~ " raise" +#~ msgstr "" +#~ "UserError: Avviso eccezione da utilizzare con \n" +#~ " raise" + +#~ msgid "" +#~ "env: Odoo Environment on which the action is\n" +#~ " triggered" +#~ msgstr "" +#~ "env: ambiente Odoo in cui è attivata\n" +#~ " l'azione" + +#~ msgid "server: Server on which the command is run" +#~ msgstr "server: Server in cui è eseguito il comando" + +#~ msgid "" +#~ "time, datetime, dateutil\n" +#~ " , timezone: useful " +#~ "Python libraries" +#~ msgstr "" +#~ "time, datetime, dateutil\n" +#~ " , timezone: librarie " +#~ "Python utili" + +#~ msgid "tower: 'cetmix.tower' helper class shortcut" +#~ msgstr "tower: scorciatoia classe aiuto 'cetmix.tower'" + +#~ msgid "" +#~ "" +#~ msgstr "" +#~ "" + +#~ msgid "Any Server" +#~ msgstr "Qualsiasi server" + +#~ msgid "Command Preview" +#~ msgstr "Anteprima comando" + +#~ msgid "" +#~ "Each python code command returns the COMMAND_RESULT value\n" +#~ " which is a dictionary." +#~ msgstr "" +#~ "Ogni comando in codice Python restituisce il valore COMMAND_RESULT \n" +#~ " che è un dizionario." + +#~ msgid "" +#~ "Error loading a private key. Unsupported key format or incorrect key." +#~ msgstr "" +#~ "Errore durante il caricamento di una chiave privata. Formato chiave non " +#~ "supportato o chiave errata." + +#~ msgid "Execute Command" +#~ msgstr "Eseguire comando" + +#~ msgid "Execute Result" +#~ msgstr "Esegui risultato" + +#~ msgid "File name" +#~ msgstr "Nome file" + +#~ msgid "Followers (Channels)" +#~ msgstr "Chi segue (canali)" + +#~ msgid "Help with Python expressions" +#~ msgstr "Aiuto con le espressioni Python" + +#~ msgid "" +#~ "If enabled command can be run on the same server while the same command " +#~ "is still running.\n" +#~ "Returns ANOTHER_COMMAND_RUNNING if execution is blocked" +#~ msgstr "" +#~ "Se abilitata, il comando può essere eseguito sullo stesso server mentre " +#~ "lo stesso comando è ancora in esecuzione. \n" +#~ "Restituisce ANOTHER_COMMAND_RUNNING se l'esecuzione è bloccata" + +#~ msgid "" +#~ "If enabled flightplan can be run on the same server while the same " +#~ "flightplan is still running.\n" +#~ "Returns -5 status is execution is blocked" +#~ msgstr "" +#~ "Se abilitata, il piano di volo può essere eseguito sullo stesso server " +#~ "mentre lo stesso piano di volo è ancora in esecuzione.\n" +#~ "Restituisce -5 stato se l'esecuzione è bloccata" + +#~ msgid "Leave blank to use for any partner" +#~ msgstr "Lasciare vuoto per usare per tutti i partner" + +#~ msgid "Leave blank to use with any partner" +#~ msgstr "Lasciare vuoto per usare con ogni partner" + +#~ msgid "Leave blank to use with any server" +#~ msgstr "Lasciare vuoto per usare con ogni server" + +#~ msgid "Main Attachment" +#~ msgstr "Allegato principale" + +#~ msgid "Mixin for managing secrets" +#~ msgstr "Mixin per la gestione dei segreti" + +#~ msgid "No sudo" +#~ msgstr "Nessun sudo" + +#~ msgid "Number of unread messages" +#~ msgstr "Numero di messaggi non letti" + +#~ msgid "Reference must be unique for the combination of partner and server" +#~ msgstr "" +#~ "Il riferimento deve essere univoco per la combinazione partner e server" + +#~ msgid "" +#~ "Run\n" +#~ " Command" +#~ msgstr "" +#~ "Esegui\n" +#~ " comando" + +#~ msgid "" +#~ "Run\n" +#~ " Flight Plan" +#~ msgstr "" +#~ "Esegui\n" +#~ " piano di volo" + +#~ msgid "Select tags to filter Commands" +#~ msgstr "Selezionare etichette per filtrare i comandi" + +#~ msgid "Sudo with password" +#~ msgstr "Sudo con password" + +#~ msgid "Sudo without password" +#~ msgstr "Sudo senza password" + +#~ msgid "There are two default keys in the dictionary, e.g.:" +#~ msgstr "Ci sono due chiavi predefinite nel dizionario, es.:" + +#~ msgid "Tower automation helper model" +#~ msgstr "Modello aiuto automazione Tower" + +#~ msgid "Unread Messages Counter" +#~ msgstr "Contatore messaggi non letti" + +#~ msgid "Used for selected server only. Leave blank to use globally" +#~ msgstr "" +#~ "Utilizza solo per i server selezionati. Lasciare vuoto per usare " +#~ "globalmente" + +#~ msgid "" +#~ "Various fields may use Python code or Python expressions. The\n" +#~ " following variables can be used:" +#~ msgstr "" +#~ "Vari campi possono utilizzare codice Python o espressioni Python. \n" +#~ " Possono essere utilizzate le seguenti " +#~ "variabili:" + +#~ msgid "_execute() function takes single server record only!" +#~ msgstr "La funzione _execute() utilizza solo record server singolo!" + +#~ msgid "sudo password was not provided!" +#~ msgstr "la password sudo non è stata fornita!" + +#~ msgid "SSH execute command error" +#~ msgstr "Errore comando esecuzione SSH" + +#~ msgid "" +#~ "\n" +#~ " Server monitoring and basic actions.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Monitoraggio server e azioni di base.\n" +#~ " " + +#~ msgid "Cetmix Tower Execute Flight Plan" +#~ msgstr "Esegui piano di volo Cetmix Tower" + +#~ msgid "Execute Flight Plan" +#~ msgstr "Eseguire piano di volo" + +#~ msgid "Execute New Command" +#~ msgstr "Esegui nuovo comando" + +#~ msgid "Execute Plan" +#~ msgstr "Esegui piano" + +#~ msgid "Parent Flight Plan Log" +#~ msgstr "Registro piano di volo padre" + +#~ msgid "Rendered code is shown as for the first selected server!" +#~ msgstr "" +#~ "Il codice realizzato viene mostrato come per il primo server selezionato!" diff --git a/addons/cetmix_tower_server/migrations/18.0.3.0.0/pre-migration.py b/addons/cetmix_tower_server/migrations/18.0.3.0.0/pre-migration.py new file mode 100644 index 0000000..8c387d3 --- /dev/null +++ b/addons/cetmix_tower_server/migrations/18.0.3.0.0/pre-migration.py @@ -0,0 +1,17 @@ +# Remove the "unique_variable_value_server" constraint for cx_tower_variable_value model +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + """Remove the unique_variable_value_server constraint before migration.""" + cr.execute( + """ + ALTER TABLE cx_tower_variable_value + DROP CONSTRAINT IF EXISTS unique_variable_value_server + """ + ) + _logger.info( + "Removed unique_variable_value_server constraint from cx_tower_variable_value" + ) diff --git a/addons/cetmix_tower_server/models/__init__.py b/addons/cetmix_tower_server/models/__init__.py new file mode 100644 index 0000000..7b0739b --- /dev/null +++ b/addons/cetmix_tower_server/models/__init__.py @@ -0,0 +1,52 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import cx_tower_variable_mixin +from . import cx_tower_template_mixin +from . import cx_tower_access_mixin +from . import cx_tower_access_role_mixin +from . import cx_tower_reference_mixin +from . import cx_tower_tag_mixin +from . import cx_tower_key_mixin +from . import cx_tower_vault_mixin +from . import cx_tower_metadata_mixin +from . import cx_tower_vault +from . import cx_tower_variable +from . import cx_tower_variable_value +from . import cx_tower_file +from . import cx_tower_file_template +from . import cx_tower_server +from . import cx_tower_os +from . import cx_tower_tag +from . import cx_tower_command +from . import cx_tower_custom_variable_value_mixin +from . import cx_tower_key +from . import cx_tower_key_value +from . import cx_tower_command_log +from . import cx_tower_plan +from . import cx_tower_plan_line +from . import cx_tower_plan_line_action +from . import cx_tower_plan_log +from . import cx_tower_server_log +from . import cx_tower_server_template +from . import cx_tower_shortcut +from . import cx_tower_scheduled_task +from . import cx_tower_scheduled_task_cv +from . import cetmix_tower +from . import cx_tower_variable_option +from . import ir_actions_server +from . import res_config_settings +from . import res_partner +from . import res_users + +# Jets +from . import cx_tower_jet_template_dependency +from . import cx_tower_jet_dependency +from . import cx_tower_jet_state +from . import cx_tower_jet_action +from . import cx_tower_jet_template +from . import cx_tower_jet_template_install +from . import cx_tower_jet_template_install_line +from . import cx_tower_jet +from . import cx_tower_jet_request +from . import cx_tower_jet_waypoint_template +from . import cx_tower_jet_waypoint diff --git a/addons/cetmix_tower_server/models/cetmix_tower.py b/addons/cetmix_tower_server/models/cetmix_tower.py new file mode 100644 index 0000000..dce5615 --- /dev/null +++ b/addons/cetmix_tower_server/models/cetmix_tower.py @@ -0,0 +1,313 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +import time +import warnings + +from odoo import _, api, models +from odoo.exceptions import ValidationError + +from . import tools +from .constants import NOT_FOUND, SSH_CONNECTION_ERROR + +_logger = logging.getLogger(__name__) + + +class CetmixTower(models.AbstractModel): + """Generic model used to simplify Odoo automation. + Used to keep main integration function in a single place. + """ + + _name = "cetmix.tower" + _description = "Cetmix Tower Odoo Automation" + + @api.model + def server_create_from_template(self, template_reference, server_name, **kwargs): + """ + THIS METHOD IS DEPRECATED. USE THE 'cx.tower.server.template' MODEL DIRECTLY. + """ + _logger.warning( + "server_create_from_template: This method is deprecated " + "and will be removed in the future. " + "Use the 'cx.tower.server.template' model directly instead." + ) + return self.env["cx.tower.server.template"].create_server_from_template( + template_reference=template_reference, server_name=server_name, **kwargs + ) + + @api.model + def server_run_command( + self, server_reference, command_reference, get_result=True, **variable_values + ): + """ + THIS METHOD IS DEPRECATED. USE THE 'cx.tower.server' MODEL DIRECTLY. + """ + + _logger.warning( + "server_run_command: This method is deprecated and " + "will be removed in the future. " + "Use the 'cx.tower.server' model directly instead." + ) + server = self.env["cx.tower.server"].get_by_reference(server_reference) + if not server: + return {"exit_code": NOT_FOUND, "message": _("Server not found")} + command = self.env["cx.tower.command"].get_by_reference(command_reference) + if not command: + return {"exit_code": NOT_FOUND, "message": _("Command not found")} + + # Will return command result if get_result is True + # Otherwise will save to log and return None + command_result = server.with_context(no_command_log=get_result).run_command( + command, **{"variable_values": variable_values} if variable_values else {} + ) + + # Return command result if get_result is True + if command_result: + status = command_result.get("status") + response = command_result.get("response", "") + error = command_result.get("error", "") + return { + "exit_code": status, + "message": response or error, + } + + def server_run_flight_plan( + self, server_reference, flight_plan_reference, **variable_values + ): + """THIS METHOD IS DEPRECATED. USE THE 'cx.tower.server' MODEL DIRECTLY.""" + _logger.warning( + "server_run_flight_plan: This method is deprecated and " + "will be removed in the future. " + "Use the 'cx.tower.server' model directly instead." + ) + server = self.env["cx.tower.server"].get_by_reference(server_reference) + if not server: + # This is not the best way to handle this, but it's the only way to + # avoid complex response handling + return False + flight_plan = self.env["cx.tower.plan"].get_by_reference(flight_plan_reference) + if not flight_plan: + # This is not the best way to handle this, but it's the only way to + # avoid complex response handling + return False + return server.run_flight_plan( + flight_plan, + **{"variable_values": variable_values} if variable_values else {}, + ) + + @api.model + def server_set_variable_value(self, server_reference, variable_reference, value): + """THIS METHOD IS DEPRECATED. USE THE 'cx.tower.server' MODEL DIRECTLY.""" + _logger.warning( + "server_set_variable_value: This method is deprecated and " + "will be removed in the future. " + "Use the 'cx.tower.server' model directly instead." + ) + server = self.env["cx.tower.server"].get_by_reference(server_reference) + if not server: + return {"exit_code": NOT_FOUND, "message": _("Server not found")} + variable = self.env["cx.tower.variable"].get_by_reference(variable_reference) + if not variable: + return {"exit_code": NOT_FOUND, "message": _("Variable not found")} + + # Check if variable is already defined for the server + variable_value_record = variable.value_ids.filtered( + lambda v: v.server_id == server + ) + if variable_value_record: + variable_value_record.value_char = value + result = {"exit_code": 0, "message": _("Variable value updated")} + + else: + self.env["cx.tower.variable.value"].create( + { + "variable_id": variable.id, + "server_id": server.id, + "value_char": value, + } + ) + result = {"exit_code": 0, "message": _("Variable value created")} + return result + + @api.model + def server_get_variable_value( + self, server_reference, variable_reference, check_global=True + ): + """THIS METHOD IS DEPRECATED. USE THE 'cx.tower.server' MODEL DIRECTLY.""" + _logger.warning( + "server_get_variable_value: This method is deprecated and " + "will be removed in the future. " + "Use the 'cx.tower.server' model directly instead." + ) + if not check_global: + warnings.warn( + "server_get_variable_value: 'check_global' is deprecated and " + "will be removed in the future. " + "Global values are always checked.", + DeprecationWarning, + stacklevel=2, + ) + + # Get server by reference + server = self.env["cx.tower.server"].get_by_reference(server_reference) + if not server: + _logger.warning( + "server_get_variable_value: Server not found for reference '%s'", + server_reference, + ) + return None + return ( + self.env["cx.tower.variable"] + ._get_variable_values_by_references( + variable_references=[variable_reference], server=server + ) + .get(variable_reference) + ) + + @api.model + def server_check_ssh_connection( + self, + server_reference, + attempts=5, + wait_time=10, + try_command=True, + try_file=True, + ): + """ + Check if SSH connection to the server is available. + This method uses the `test_ssh_connection` method + of the 'cx.tower.server' model. + It tries to connect to the server multiple times + and is designed to be used in the Python commands or + Odoo automated actions. + + Args: + server_reference (Char): Server reference. + attempts (int): Number of attempts to try the connection. + Default is 5. + wait_time (int): Wait time in seconds between connection attempts. + Default is 10 seconds. + try_command (bool): Try to execute a simple command for verification. + Default is True. Set to False to skip command execution. + try_file (bool): Try file operations for verification. + Default is True. Set to False to skip file operations. + Raises: + ValidationError: + If the provided server reference is invalid or + the server cannot be found. + Returns: + dict: { + "exit_code": int, + 0 for success, + error code for failure + "message": str # Description of the result + } + """ + server = self.env["cx.tower.server"].get_by_reference(server_reference) + if not server: + raise ValidationError(_("No server found for the provided reference.")) + + # Try connecting multiple times + for attempt in range(1, attempts + 1): + try: + _logger.info( + "Attempt %s of %s to connect to server %s", + attempt, + attempts, + server_reference, + ) + result = server.test_ssh_connection( + raise_on_error=True, + return_notification=False, + try_command=try_command, + try_file=try_file, + ) + if result.get("status") == 0: + return { + "exit_code": 0, + "message": _("Connection successful."), + } + if attempt == attempts: + return { + "exit_code": SSH_CONNECTION_ERROR, + "message": _( + "Failed to connect after %(attempts)s attempts. " + "Error: %(err)s", + attempts=attempts, + err=result.get("error", ""), + ), + } + except Exception as e: # pylint: disable=broad-except + if attempt == attempts: + return { + "exit_code": SSH_CONNECTION_ERROR, + "message": _("Failed to connect. Error: %(err)s", err=e), + } + time.sleep(wait_time) + + @api.model + def server_validate_secret( + self, secret_value, secret_reference, server_reference=None + ): + """ + Validates the provided secret value against the actual secret. + + Accepts either a full inline reference (e.g. #!cxtower.secret.!#) + or just a . + + Args: + secret_value (Char): Value to validate + secret_reference (Char): Reference code or inline reference + server_reference (Char, optional): Reference code of the server + Returns: + Bool: True if the value matches the secret, False otherwise + """ + server = self.env["cx.tower.server"] + if server_reference: + server = server.get_by_reference(server_reference) + + # Try to extract reference from inline format using _extract_key_parts + key_parts = self.env["cx.tower.key"]._extract_key_parts(secret_reference) + if key_parts: + # _extract_key_parts returns a tuple: (key_type, reference). + # We only need the reference part here. + secret_reference = key_parts[1] + + value = self.env["cx.tower.key"]._resolve_key_type_secret( + secret_reference, server_id=server.id + ) + return value == secret_value + + @api.model + def generate_random_id(self, sections=1, population=4, separator="-"): + """ + Helper method that allows to generate a random id + with customizable sections and population. + Such ids are more human readable and less likely to collide. + + + Args: + sections (int): Number of sections to generate. + population (int): Population of the sections. + separator (str): Separator between sections. + Returns: + str: Random id + """ + return tools.generate_random_id( + sections=sections, population=population, separator=separator + ) + + @api.model + def is_valid_url(self, url, no_scheme_check=False): + """ + Check if the provided URL is a valid URL. + The `urlparse` function from the `urllib.parse` module is used. + + Args: + url (str): URL to check + no_scheme_check (bool): If True, the scheme check will be skipped. + Defaults to False. + Returns: + bool: True if the URL is valid, False otherwise + """ + return tools.is_valid_url(url=url, no_scheme_check=no_scheme_check) diff --git a/addons/cetmix_tower_server/models/constants.py b/addons/cetmix_tower_server/models/constants.py new file mode 100644 index 0000000..1eb055f --- /dev/null +++ b/addons/cetmix_tower_server/models/constants.py @@ -0,0 +1,152 @@ +from odoo.tools import LazyTranslate + +_lt = LazyTranslate(__name__, default_lang="en_US") + +# *** +# This file is used to define commonly used constants +# *** + +# Returned when a general error occurs +GENERAL_ERROR = -100 + +# Returned when a resource is not found +NOT_FOUND = -101 + +# -- SSH + +# Returned when an SSH connection error occurs +SSH_CONNECTION_ERROR = 503 + +# -- Command: -200 > -299 + +# Returned when trying to execute another instance of a command on the same server +# and this command doesn't allow parallel run +ANOTHER_COMMAND_RUNNING = -201 + +# Returned when no runner is found for command action +NO_COMMAND_RUNNER_FOUND = -202 + +# Returned when the command failed to execute due to a python code execution error +PYTHON_COMMAND_ERROR = -203 + +# Returned when the command failed to execute because the condition was not met +PLAN_LINE_CONDITION_CHECK_FAILED = -205 + +# Returned when the command timed out +COMMAND_TIMED_OUT = -206 +COMMAND_TIMED_OUT_MESSAGE = _lt("Command timed out and was terminated") + +# Returned when the command is not compatible with the server +COMMAND_NOT_COMPATIBLE_WITH_SERVER = -207 + +# Returned when the command was stopped by user +COMMAND_STOPPED = -208 + +# -- Plan: -300 > -399 + +# Returned when trying to execute another instance of a flightplan on the same server +# and this flightplan doesn't allow parallel run +ANOTHER_PLAN_RUNNING = -301 + +# Returned when trying to start plan without lines +PLAN_IS_EMPTY = -302 + +# Returned when a plan tries to parse a command log record which doesn't have +# a valid plan reference in it +PLAN_NOT_ASSIGNED = -303 + +# Returned when a plan tries to parse a command log record which doesn't have +# a valid plan line reference in it +PLAN_LINE_NOT_ASSIGNED = -304 + +# Returned when any of the commands in the plan is not compatible with the server +PLAN_NOT_COMPATIBLE_WITH_SERVER = -306 + +# Returned when the flight plan was stopped by user +PLAN_STOPPED = -308 + +# -- File: -400 > -499 + +# Returned when the file could not be created on the server +FILE_CREATION_FAILED = -400 + +# Returned when the file could not be uploaded to the server +FILE_UPLOAD_FAILED = -401 + +# Returned when the file could not be downloaded from the server +FILE_DOWNLOAD_FAILED = -402 + + +# -- Jet: -500 > -599 + +# Returned when the jet action is not found +JET_ACTION_NOT_FOUND = -501 + +# Returned when the jet template is not found +JET_TEMPLATE_NOT_FOUND = -502 + +# Returned when the jet is not found +JET_NOT_FOUND = -503 + +# Returned when a jet state error occurs +JET_STATE_ERROR = -504 + +# Returned when the jet action is not available +JET_ACTION_NOT_AVAILABLE = -505 + +# Returned when the jet dependencies are not satisfied +JET_DEPENDENCIES_NOT_SATISFIED = -506 + +# Returned when the waypoint template is not found or not set +WAYPOINT_TEMPLATE_NOT_FOUND = -507 + +# Returned when waypoint creation fails (e.g. template not for jet, jet busy) +WAYPOINT_CREATE_FAILED = -508 + + +# -- Default values + +# Default Python code used in Python code command +DEFAULT_PYTHON_CODE = _lt(""" +# Please refer to the 'Help' tab and documentation for more information. +# +# You can return command result in the 'result' variable which is a dictionary: +# result = {"exit_code": 0, "message": "Some message"} +# default value is {"exit_code": 0, "message": None} +""") # noqa: E501 + + +# Default Python code help displayed in the "Help" tab +DEFAULT_PYTHON_CODE_HELP = _lt(""" +

Help with Python expressions

+
+

+ Each Python code command returns the result value which is a dictionary. +
There are two keys in the dictionary: +

    +
  • exit_code: Integer. Exit code of the command. "0" means success, any other value means failure. Default value is "0".
  • +
  • message: String. Message to be logged. Default value is "None".
  • +
+You can also access the custom_values dictionary that contains custom values provided to the command or flight plan. +Custom values can be modified, thus can be used to pass data between commands in a flight plan. +Please keep in mind that custom values are persistent only between commands in a flight plan and are not saved to the database. +
+Here is an example of a python code command: + + + server_name = server.name + build_name = custom_values.get("build_name") + if build_name: + result = {"exit_code": 0, "message": "Build name for " + server_name + " is " + build_name} + else: + result = {"exit_code": 0, "message": "No build name provided for " + server_name} + custom_values["build_name"] = "New build name" + +

+
+Please refer to the official documentation for more information and examples. +
+

Various fields may use Python code or Python expressions. The + following variables can be used:

+""") # noqa: E501 diff --git a/addons/cetmix_tower_server/models/cx_tower_access_mixin.py b/addons/cetmix_tower_server/models/cx_tower_access_mixin.py new file mode 100644 index 0000000..c2d50db --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_access_mixin.py @@ -0,0 +1,37 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, fields, models + + +class CxTowerAccessMixin(models.AbstractModel): + """Used to implement template access levels in models.""" + + _name = "cx.tower.access.mixin" + _description = "Cetmix Tower access mixin" + + access_level = fields.Selection( + lambda self: self._selection_access_level(), + default=lambda self: self._default_access_level(), + required=True, + index=True, + ) + + def _selection_access_level(self): + """Available access levels + + Returns: + List of tuples: available options. + """ + return [ + ("1", _("User")), + ("2", _("Manager")), + ("3", _("Root")), + ] + + def _default_access_level(self): + """Default access level + + Returns: + Char: `access_level` field selection value + """ + return "2" diff --git a/addons/cetmix_tower_server/models/cx_tower_access_role_mixin.py b/addons/cetmix_tower_server/models/cx_tower_access_role_mixin.py new file mode 100644 index 0000000..37c3b9e --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_access_role_mixin.py @@ -0,0 +1,99 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class CxTowerAccessRoleMixin(models.AbstractModel): + """Used to implement access roles in models.""" + + _name = "cx.tower.access.role.mixin" + _description = "Cetmix Tower access role mixin" + + # IMPORTANT: inherit these fields in your model + # add 'relation' key explicitly to the field. + # Use 'cx.tower.server' as model as a reference. + user_ids = fields.Many2many( + comodel_name="res.users", + column1="record_id", + column2="user_id", + string="Users", + domain=lambda self: [ + ("groups_id", "in", [self.env.ref("cetmix_tower_server.group_user").id]) + ], + default=lambda self: self._default_user_ids(), + help="Users who can view this record", + copy=False, + ) + + manager_ids = fields.Many2many( + comodel_name="res.users", + column1="record_id", + column2="manager_id", + string="Managers", + groups="cetmix_tower_server.group_manager", + domain=lambda self: [ + ("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id]) + ], + default=lambda self: self._default_manager_ids(), + help="Managers who can modify this record", + copy=False, + ) + + def _default_user_ids(self): + """ + Default Users for new Records. + """ + # If user is in group_user, add them to the list + if self.env.user.has_group("cetmix_tower_server.group_user"): + return [self.env.user.id] + # Otherwise, return an empty list. Eg if created using sudo() + return [] + + def _default_manager_ids(self): + """ + Default Managers for new Records. + """ + # If user is manager, add them to the list + if self.env.user.has_group("cetmix_tower_server.group_manager"): + return [self.env.user.id] + # Otherwise, return an empty list. Eg if created using sudo() + return [] + + @api.model_create_multi + def create(self, vals_list): + """ + Create records with post-create fields. + """ + post_create_fields = self._get_post_create_fields() + post_create_vals_list = [] + for vals in vals_list: + post_create_vals = {} + for key in post_create_fields: + if key in vals: + post_create_vals[key] = vals.pop(key) + post_create_vals_list.append(post_create_vals) + + # Create records without post-create fields + res = super().create(vals_list) + if post_create_vals_list: + # Create related records with post-create field + for post_create_vals, record in zip(post_create_vals_list, res): # noqa: B905 we need to run on Python 3.10 + if post_create_vals: + record.write(post_create_vals) + + return res + + def _get_post_create_fields(self): + """ + Get post-create fields. + + Some records may create related records which use rules + that depend on `user_ids` and `manager_ids` fields. + However at the moment of record creation, these fields are not yet set. + So first we create the record without these fields, then we create + the related records to avoid access violations. + + Returns: + list: List of fields to be set after record creation. + """ + return [] diff --git a/addons/cetmix_tower_server/models/cx_tower_command.py b/addons/cetmix_tower_server/models/cx_tower_command.py new file mode 100644 index 0000000..32469af --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_command.py @@ -0,0 +1,657 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +from types import SimpleNamespace +from urllib import parse + +from dns import exception, resolver, reversename +from pytz import timezone + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError +from odoo.tools import ormcache_context +from odoo.tools.float_utils import float_compare +from odoo.tools.safe_eval import wrap_module + +from .constants import DEFAULT_PYTHON_CODE, DEFAULT_PYTHON_CODE_HELP + +_logger = logging.getLogger(__name__) + +requests = wrap_module(__import__("requests"), ["post", "get", "delete", "request"]) +json = wrap_module(__import__("json"), ["dumps"]) +hashlib = wrap_module( + __import__("hashlib"), + [ + "sha1", + "sha224", + "sha256", + "sha384", + "sha512", + "sha3_224", + "sha3_256", + "sha3_384", + "sha3_512", + "shake_128", + "shake_256", + "blake2b", + "blake2s", + "md5", + "new", + ], +) +re = wrap_module( + __import__("re"), + [ + "match", + "fullmatch", + "search", + "sub", + "subn", + "split", + "findall", + "finditer", + "compile", + "template", + "escape", + "error", + ], +) +hmac = wrap_module( + __import__("hmac"), + ["new", "compare_digest"], +) +urllib_parse = wrap_module( + parse, + [ + "urlparse", + "urljoin", + "urlunparse", + "urlencode", + "urlsplit", + "urlunsplit", + "parse_qs", + "parse_qsl", + "quote", + "quote_plus", + "quote_from_bytes", + "unquote", + "unquote_plus", + "unquote_to_bytes", + ], +) +tldextract = wrap_module(__import__("tldextract"), ["extract"]) +dns_resolver = wrap_module(resolver, ["resolve", "query"]) +dns_reversename = wrap_module(reversename, ["from_address", "to_address"]) +dns_exception = wrap_module(exception, ["DNSException"]) + + +dns = SimpleNamespace( + resolver=dns_resolver, + reversename=dns_reversename, + exception=dns_exception, +) + + +class CxTowerCommand(models.Model): + """Command to run on a server""" + + _name = "cx.tower.command" + _inherit = [ + "cx.tower.template.mixin", + "cx.tower.reference.mixin", + "cx.tower.access.mixin", + "cx.tower.access.role.mixin", + "cx.tower.key.mixin", + "cx.tower.tag.mixin", + ] + _description = "Cetmix Tower Command" + _order = "name" + + active = fields.Boolean(default=True) + allow_parallel_run = fields.Boolean( + help="If enabled, multiple instances of the same command " + "can be run on the same server at the same time.\n" + "Otherwise, ANOTHER_COMMAND_RUNNING status will be returned if another" + " instance of the same command is already running" + ) + server_ids = fields.Many2many( + comodel_name="cx.tower.server", + relation="cx_tower_server_command_rel", + column1="command_id", + column2="server_id", + string="Servers", + help="Servers on which the command will be run.\n" + "If empty, command can be run on all servers", + ) + tag_ids = fields.Many2many( + relation="cx_tower_command_tag_rel", + column1="command_id", + column2="tag_id", + ) + os_ids = fields.Many2many( + comodel_name="cx.tower.os", + relation="cx_tower_os_command_rel", + column1="command_id", + column2="os_id", + string="OSes", + ) + note = fields.Text() + + action = fields.Selection( + selection=lambda self: self._selection_action(), + required=True, + default=lambda self: self._selection_action()[0][0], + ) + path = fields.Char( + string="Default Path", + help="Location where command will be run. " + "You can use {{ variables }} in path", + ) + file_template_id = fields.Many2one( + comodel_name="cx.tower.file.template", + help="This template will be used to create or update the pushed file", + ) + template_code = fields.Text( + string="Template Code", + related="file_template_id.code", + readonly=True, + help="Code of the associated file template", + ) + flight_plan_line_ids = fields.One2many( + comodel_name="cx.tower.plan.line", + related="flight_plan_id.line_ids", + readonly=True, + help="Lines of the associated flight plan", + ) + code = fields.Text( + compute="_compute_code", + store=True, + readonly=False, + ) + command_help = fields.Html( + compute="_compute_command_help", + compute_sudo=True, + ) + flight_plan_id = fields.Many2one( + comodel_name="cx.tower.plan", + help="Flight plan run by the command", + ) + flight_plan_used_ids = fields.Many2many( + comodel_name="cx.tower.plan", + help="Flight plan this command is used in", + relation="cx_tower_command_flight_plan_used_id_rel", + column1="command_id", + column2="plan_id", + copy=False, + ) + flight_plan_used_ids_count = fields.Integer( + compute="_compute_flight_plan_used_ids_count", + help="Flight plan this command is used in", + ) + server_status = fields.Selection( + selection=lambda self: self.env["cx.tower.server"]._selection_status(), + help="Set the following status if command finishes with success. " + "Leave 'Undefined' if you don't need to update the status", + ) + no_split_for_sudo = fields.Boolean( + string="No Split for sudo", + help="If enabled, do not split command on '&&' when using sudo." + "Prepend sudo once to the whole command.", + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_command_variable_rel", + column1="command_id", + column2="variable_id", + ) + + if_file_exists = fields.Selection( + selection=[ + ("skip", "Skip"), + ("overwrite", "Overwrite"), + ("raise", "Raise Error"), + ], + default="skip", + help="What to do if file already exists on the server.\n" + "- Skip: Do not create or update the file.\n" + "- Overwrite: Replace the existing file with the new one.\n" + "- Raise Error: Raise an error if the file already exists.", + ) + disconnect_file = fields.Boolean( + string="Disconnect from Template", + help=( + "If enabled, disconnects the file from its template " + "after running the command.\n" + ), + ) + # -- Jets + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + help="Action will be triggered for all dependent jets" " of this template", + ) + jet_action_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + help="Action to trigger", + domain="[('jet_template_id', '=', jet_template_id)]", + ) + # -- Waypoints + waypoint_template_id = fields.Many2one( + comodel_name="cx.tower.jet.waypoint.template", + string="Waypoint Template", + help="Waypoint template to create the waypoint from. Used when action is " + "Create a Waypoint.", + ) + fly_here = fields.Boolean( + default=False, + help="When enabled, the created waypoint is set as current (fly to) " + "after creation.", + ) + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_command_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_command_manager_rel", + ) + + @classmethod + def _get_depends_fields(cls): + """ + Define dependent fields for computing `variable_ids` in command-related models. + + This implementation specifies that the fields `code` and `path` + are used to determine the variables associated with a command. + + Returns: + list: A list of field names (str) representing the dependencies. + + Example: + The following fields trigger recomputation of `variable_ids`: + - `code`: The command's script or running logic. + - `path`: The default running path for the command. + """ + return ["code", "path"] + + # -- Selection + def _selection_action(self): + """Actions that can be run by a command. + + Returns: + List of tuples: available options. + """ + return [ + ("ssh_command", "SSH Command"), + ("python_code", "Python Code"), + ("file_using_template", "Create/Update File"), + ("plan", "Run Flight Plan"), + ("jet_action", "Trigger Jet Action"), + ("create_waypoint", "Create Waypoint"), + ] + + # -- Defaults + def _get_default_python_code(self): + """ + Default python command code + """ + return DEFAULT_PYTHON_CODE + + def _get_default_python_code_help(self): + """ + Default python code help + """ + + # Available libraries are Odoo objects + Python libraries + available_libraries = self._get_python_command_odoo_objects() + available_libraries.update(self._get_python_command_libraries()) + help_text_fragments = [] + for key, value in available_libraries.items(): + help_text_fragments.append(f"
  • {key}: {value['help']}
  • ") + + help_text_fragments.append( + f"
  • custom_values: {_('Flight plan custom values')}
  • " + ) + + help_text = "
      " + "".join(help_text_fragments) + "
    " + return f"{DEFAULT_PYTHON_CODE_HELP}{help_text}" + + # -- Computes + @api.depends("action") + def _compute_code(self): + """ + Compute default code + """ + default_python_code = self._get_default_python_code() + for command in self: + if command.action == "python_code": + command.code = default_python_code + continue + command.code = False + + @api.depends("action") + def _compute_command_help(self): + """ + Compute command help + """ + default_python_code_help = self._get_default_python_code_help() + for command in self: + if command.action == "python_code": + command.command_help = default_python_code_help + else: + command.command_help = False + + @api.depends("flight_plan_used_ids") + def _compute_flight_plan_used_ids_count(self): + """ + Compute flight plan ids count + """ + for command in self: + command.flight_plan_used_ids_count = len(command.flight_plan_used_ids) + + def action_open_command_logs(self): + """ + Open current command log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + action["domain"] = [("command_id", "=", self.id)] + return action + + def action_open_plans(self): + """ + Open plans this command is used in + """ + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan" + ) + action["domain"] = [("id", "in", self.flight_plan_used_ids.ids)] + return action + + def _check_server_compatibility(self, server): + """Check if the command is compatible with the server + Args: + server (cx.tower.server()): Server object + + Returns: + bool: True if the command is compatible with the server, False otherwise + """ + self.ensure_one() + return not self.server_ids or server.id in self.server_ids.ids + + # -- Business logic + @ormcache_context(keys=("lang",)) + @api.model + def _get_python_command_libraries(self): + """ + Get available python imports. Use this method to import python libraries. + Please be advised, that this method is cached. + If you need to use a non-cached import, eg for Odoo objects, + use the `_get_python_command_odoo_objects` method instead. + + + Returns: + dict: Available libraries: + {"": { + "import": , + "help": + }} + """ + python_libraries = { + "_logger": { + "import": _logger, + "help": _( + "Logger object. Use with caution! Only for debugging purposes." + ), + }, + "re": { + "import": re, + "help": _("Python 're' library for regex operations"), + }, + "time": { + "import": tools.safe_eval.time, + "help": _("Python 'time' library"), + }, + "datetime": { + "import": tools.safe_eval.datetime, + "help": _("Python 'datetime' library"), + }, + "dateutil": { + "import": tools.safe_eval.dateutil, + "help": _("Python 'dateutil' library"), + }, + "timezone": { + "import": timezone, + "help": _("Python 'timezone' library"), + }, + "requests": { + "import": requests, + "help": _( + "Python 'requests' library. Available methods: 'post', 'get'," + " 'delete', 'request'" + ), + }, + "urllib_parse": { + "import": urllib_parse, + "help": _("Python 'urllib.parse' library methods."), + }, + "json": { + "import": json, + "help": _("Python 'json' library. Available methods: 'dumps'"), + }, + "float_compare": { + "import": float_compare, + "help": _("Float compare. Odoo helper function to compare floats."), + }, + "UserError": { + "import": UserError, + "help": _("UserError. Helper to raise UserError."), + }, + "hashlib": { + "import": hashlib, + "help": _( + "Python 'hashlib' library. " + "Documentation. " + "Available methods: 'sha1', 'sha224', " + "'sha256', 'sha384'," + " 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', " + "'shake_128', 'shake_256'," + " 'blake2b', 'blake2s', 'md5', 'new'" + ), + }, + "hmac": { + "import": hmac, + "help": _( + "Python 'hmac' library. " + "Documentation. " + "Use 'new' to create HMAC objects. " + "Available methods on the HMAC *object*: 'update', 'copy'," + " 'digest', 'hexdigest'. " + " Module-level function: 'compare_digest'." + ), + }, + "tldextract": { + "import": tldextract, + "help": _( + "Python 'tldextract' library. Use " + "tldextract.extract() to parse domains. " + "Check tldextract for more information." + ), + }, + "dns": { + "import": dns, + "help": _( + "Python 'dnspython' library. " + "Documentation." + "
    • dns.resolver: " + "wrapped dnspython. Use " + 'dns.resolver.resolve(hostname, "A") for ' + "DNS lookups.
    • " + "
    • dns.reversename: wrapped dnspython. " + 'Use dns.reversename.from_address("8.8.8.8")' + " to build and reverse PTR records.
    • " + "
    • dns.exception: wrapped dnspython. " + "Catch " + "dns.exception.DNSException to handle " + "DNS-related errors.
    • " + "
    " + ), + }, + } + custom_python_libraries = self._custom_python_libraries() + for libraries in custom_python_libraries.values(): + python_libraries.update(libraries) + return python_libraries + + def _get_python_command_odoo_objects( + self, server=None, jet_template=None, jet=None, waypoint=None + ): + """ + This method is used to import Odoo objects. + Because Odoo objects can be records, this method is not cached. + Use this method to import Odoo objects that are not cached. + If you need to import some static objects, use the + `_get_python_command_libraries` method instead. + + Args: + server: Server to get the Odoo objects for. + jet_template: Jet template to get the Odoo objects for. + jet: Jet to get the Odoo objects for. + waypoint: Waypoint to get the Odoo objects for. + + Returns: + dict: Available Odoo objects: + {"": { + "import": , + "help": + }} + """ + return { + "uid": {"import": self._uid, "help": _("Current Odoo user ID")}, + "user": {"import": self.env.user, "help": _("Current Odoo user")}, + "env": {"import": self.env, "help": _("Odoo Environment")}, + "server": { + "import": server, + "help": _("Current Cetmix Tower server this command is running on"), + }, + "jet_template": { + "import": jet_template, + "help": _( + "Current Cetmix Tower jet template this command is running on" + ), + }, + "jet": { + "import": jet, + "help": _("Current Cetmix Tower jet this command is running on"), + }, + "waypoint": { + "import": waypoint, + "help": _( + "Current Cetmix Tower Jet waypoint this command is running on" + ), + }, + "tower": { + "import": self.env["cetmix.tower"], + "help": _( + "Cetmix Tower " + "helper class shortcut" + ), + }, + "tower_servers": { + "import": self.env["cx.tower.server"], + "help": _("A helper shortcut to env['cx.tower.server']"), + }, + "tower_jets": { + "import": self.env["cx.tower.jet"], + "help": _("A helper shortcut to env['cx.tower.jet']"), + }, + "tower_commands": { + "import": self.env["cx.tower.command"], + "help": _("A helper shortcut to env['cx.tower.command']"), + }, + "tower_plans": { + "import": self.env["cx.tower.plan"], + "help": _("A helper shortcut to env['cx.tower.plan']"), + }, + "tower_waypoints": { + "import": self.env["cx.tower.jet.waypoint"], + "help": _( + "A helper shortcut to env['cx.tower.jet.waypoint']" + ), + }, + } + + def _custom_python_libraries(self): + """ + This function is designed to be used in custom modules + extending Cetmix Tower to add custom python libraries + to the evaluation context. + + Returns: + Dict: Custom python libraries. + + The following format is used: + { + : {"": { + "import": , + "help": + } + } + + Where: + + Odoo module technical name. + is the name of the library how it will be used in the code. + + : The library object to expose. + : Help text (HTML) shown in the "Help" tab. + """ + return {} + + def _get_python_command_eval_context(self, server=None, **kwargs): + """ + Get the evaluation context for the python command. + This method is used to get the evaluation context for the python command. + + Args: + server: Server to get the evaluation context for. + **kwargs: Additional keyword arguments. + Returns: + dict: Evaluation context for the python command. + """ + + # Get the jet template, jet and waypoint from kwargs + jet_template = kwargs.get("jet_template") + jet = kwargs.get("jet") + waypoint = kwargs.get("waypoint") + + # Get the Odoo objects first + imports = self._get_python_command_odoo_objects( + server=server, + jet_template=jet_template, + jet=jet, + waypoint=waypoint, + ) + + # Update with the libraries + imports.update(self._get_python_command_libraries()) + eval_context = {key: value["import"] for key, value in imports.items()} + + eval_context["custom_values"] = kwargs.get("variable_values") or {} + return eval_context + + def _get_banned_python_code_keywords(self): + """ + Get the banned python code keywords for the python command. + Extend this method to add banned keywords to the list. + + Returns: + list: Banned python code keywords. + """ + return ["_set_secret_values(", "_get_secret_value(", "_get_secret_values("] diff --git a/addons/cetmix_tower_server/models/cx_tower_command_log.py b/addons/cetmix_tower_server/models/cx_tower_command_log.py new file mode 100644 index 0000000..aa9d974 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_command_log.py @@ -0,0 +1,400 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from ansi2html import Ansi2HTMLConverter + +from odoo import _, api, fields, models + +from .constants import COMMAND_STOPPED, GENERAL_ERROR + +html_converter = Ansi2HTMLConverter(inline=True) +_logger = logging.getLogger(__name__) + + +class CxTowerCommandLog(models.Model): + """Command execution log""" + + _name = "cx.tower.command.log" + _description = "Cetmix Tower Command Log" + _order = "start_date desc, id desc" + + active = fields.Boolean(default=True) + name = fields.Char(compute="_compute_name", store=True) + label = fields.Char( + help="Custom label. Can be used for search/tracking", + index="trigram", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", required=True, index=True, ondelete="cascade" + ) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + index=True, + ondelete="cascade", + compute="_compute_jet_id", + store=True, + readonly=False, + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + index=True, + ondelete="cascade", + compute="_compute_jet_id", + store=True, + readonly=False, + ) + waypoint_id = fields.Many2one( + comodel_name="cx.tower.jet.waypoint", + related="plan_log_id.waypoint_id", + readonly=True, + ) + + # -- Time + start_date = fields.Datetime(string="Started") + finish_date = fields.Datetime(string="Finished") + duration = fields.Float( + help="Time consumed for execution, seconds", + compute="_compute_duration", + store=True, + ) + duration_current = fields.Float( + string="Duration, sec", + compute="_compute_duration_current", + compute_sudo=True, + help="For how long a flight plan is already running", + ) + # -- Command + is_running = fields.Boolean( + help="Command is being executed right now", + compute="_compute_duration", + store=True, + ) + command_id = fields.Many2one( + comodel_name="cx.tower.command", required=True, index=True, ondelete="restrict" + ) + access_level = fields.Selection( + related="command_id.access_level", + readonly=True, + store=True, + index=True, + ) + + command_action = fields.Selection(related="command_id.action", store=True) + path = fields.Char(string="Execution Path", help="Where command was executed") + code = fields.Text(string="Command Code", help="Command code that was executed") + command_status = fields.Integer( + string="Exit Code", + help="0 if command finished successfully.\n" + "-100 general error,\n" + "-101 not found,\n" + "-201 another instance of this command is running,\n" + "-202 no runner found for the command action,\n" + "-203 Python code execution failed,\n" + "-205 plan line condition check failed,\n" + "-206 command timed out,\n" + "-207 command is not compatible with server,\n" + "-208 command is stopped by user,\n" + "503 if SSH connection error occurred", + ) + command_response = fields.Text(string="Response") + command_error = fields.Text(string="Error") + command_result_html = fields.Html( + compute="_compute_command_result_html", + help="Result converted to HTML. Used for SSH commands.", + ) + use_sudo = fields.Selection( + string="Use sudo", + selection=[("n", "Without password"), ("p", "With password")], + help="Run commands using 'sudo'", + ) + condition = fields.Char( + readonly=True, + ) + is_skipped = fields.Boolean( + readonly=True, + ) + + # -- Flight Plan + plan_log_id = fields.Many2one(comodel_name="cx.tower.plan.log", ondelete="cascade") + triggered_plan_log_id = fields.Many2one(comodel_name="cx.tower.plan.log") + + triggered_plan_command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", + inverse_name="plan_log_id", + related="triggered_plan_log_id.command_log_ids", + readonly=True, + string="Triggered Flight Plan Commands", + ) + scheduled_task_id = fields.Many2one( + "cx.tower.scheduled.task", + ondelete="set null", + help="Scheduled task that triggered this command", + ) + variable_values = fields.Json( + default={}, + help="Custom variable values passed to the command", + ) + + @api.depends("server_id.name", "command_id.name") + def _compute_name(self): + for rec in self: + rec.name = ": ".join((rec.server_id.name, rec.command_id.name)) # type: ignore + + @api.depends("plan_log_id") + def _compute_jet_id(self): + for command_log in self: + if command_log.plan_log_id: + command_log.update( + { + "jet_id": command_log.plan_log_id.jet_id, + "jet_template_id": command_log.plan_log_id.jet_template_id, + } + ) + + @api.depends("start_date", "finish_date") + def _compute_duration(self): + for command_log in self: + if not command_log.start_date: + command_log.is_running = False + continue + if not command_log.finish_date: + command_log.is_running = True + continue + duration = ( + command_log.finish_date - command_log.start_date + ).total_seconds() + command_log.update( + { + "duration": duration, + "is_running": False, + } + ) + + @api.depends("is_running") + def _compute_duration_current(self): + """Shows relative time between now() and start time for running commands, + and computed duration for finished ones. + """ + now = fields.Datetime.now() + for command_log in self: + if command_log.is_running: + command_log.duration_current = ( + now - command_log.start_date + ).total_seconds() + else: + command_log.duration_current = command_log.duration + + @api.depends("command_response", "command_error") + def _compute_command_result_html(self): + for command_log in self: + command_result = command_log.command_response or command_log.command_error + if command_result: + try: + command_log.command_result_html = html_converter.convert( + command_result + ) + except Exception as e: + _logger.error("Error converting command response to HTML: %s", e) + command_log.command_result_html = _( + "

    Error converting command" + " response to HTML: %(error)s

    ", + error=e, + ) + else: + command_log.command_result_html = False + + def start(self, server_id, command_id, start_date=None, **kwargs): + """Creates initial log record when command is started + + Args: + server_id (int) id of the server. + command_id (int) id of the command. + start_date (datetime) command start date time. + **kwargs (dict): optional values + Returns: + (cx.tower.command.log()) new command log record or False + """ + vals = { + "server_id": server_id, + "command_id": command_id, + "start_date": start_date if start_date else fields.Datetime.now(), + } + # Apply kwargs + vals.update(kwargs) + log_record = self.sudo().create(vals) + return log_record + + def stop(self): + """ + Stop the command execution. + """ + user_name = self.env.user.name + for log in self: + if not log.is_running: + continue + + log.finish( + status=COMMAND_STOPPED, + error=_("Stopped by user %(user)s", user=user_name), + ) + + # Ensure flight plan log is stopped too + if log.plan_log_id and log.plan_log_id.is_running: + log.plan_log_id.stop() + + def finish( + self, finish_date=None, status=None, response=None, error=None, **kwargs + ): + """Save final command result when command is finished. + This method can be called for multiple command logs at once. + + Args: + finish_date (datetime) command finish date time. + status (int, optional): command execution status. Defaults to None. + response (Char, optional): Command response. Defaults to None. + error (Char, optional): Command error. Defaults to None. + **kwargs (dict): optional values + """ + self_with_sudo = self.sudo() + + # Duration + now = fields.Datetime.now() + date_finish = finish_date if finish_date else now + + vals = { + "finish_date": date_finish, + "command_status": GENERAL_ERROR if status is None else status, + "command_response": response, + "command_error": error, + } + + # Apply kwargs and write + vals.update(kwargs) + self_with_sudo.write(vals) + + # Trigger post finish hook + for command_log in self_with_sudo: + command_log._command_finished() + + def record( + self, + server_id, + command_id, + start_date=None, + finish_date=None, + status=0, + response=None, + error=None, + **kwargs, + ): + """Record completed command directly without using start/stop + + Args: + server_id (int) id of the server. + command_id (int) id of the command. + start_date (datetime) command start date time. + finish_date (datetime) command finish date time. + status (int, optional): command execution status. Defaults to 0. + response (list, optional): SSH response. Defaults to None. + error (list, optional): SSH error. Defaults to None. + **kwargs (dict): values to store + Returns: + (cx.tower.command.log()) new command log record + """ + vals = kwargs or {} + now = fields.Datetime.now() + vals.update( + { + "server_id": server_id, + "command_id": command_id, + "start_date": start_date or now, + "finish_date": finish_date or now, + "command_status": status, + "command_response": response, + "command_error": error, + } + ) + rec = self.sudo().create(vals) + rec._command_finished() + return rec + + def _command_finished(self): + """Triggered when command is finished + Inherit to implement your own hooks + + Returns: + bool: True if event was handled + """ + + self.ensure_one() + + # Do not notify if command is run from a Flight Plan. + if self.plan_log_id: # type: ignore + self.plan_log_id._plan_command_finished(self) # type: ignore + return True + + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_success = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_success" + ) + notification_type_error = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_error" + ) + + # Prepare notifications + if not notification_type_success and not notification_type_error: + return True + + # Use context timestamp to avoid timezone issues + context_timestamp = fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ) + + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Log") + context["params"] = params + action.update( + { + "views": [(False, "form")], + "context": context, + "res_id": self.id, + } + ) + + # Send notification + if self.command_status == 0 and notification_type_success: + # Success notification + self.create_uid.notify_success( + message=_( + "%(timestamp)s
    " "Command '%(name)s' finished successfully", + name=self.command_id.name, + timestamp=context_timestamp, + ), + title=self.server_id.name, + sticky=notification_type_success == "sticky", + action=action, + ) + + # Error notification + if self.command_status != 0 and notification_type_error: + self.create_uid.notify_danger( + message=_( + "%(timestamp)s
    " "Command '%(name)s' finished with error", + name=self.command_id.name, + timestamp=context_timestamp, + ), + title=self.server_id.name, + sticky=notification_type_error == "sticky", + action=action, + ) + + return True diff --git a/addons/cetmix_tower_server/models/cx_tower_custom_variable_value_mixin.py b/addons/cetmix_tower_server/models/cx_tower_custom_variable_value_mixin.py new file mode 100644 index 0000000..325bca4 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_custom_variable_value_mixin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class CxTowerCustomVariableValueMixin(models.AbstractModel): + """ + Custom variable values. + """ + + _name = "cx.tower.custom.variable.value.mixin" + _description = "Custom variable values" + + variable_id = fields.Many2one( + "cx.tower.variable", + ) + variable_type = fields.Selection(related="variable_id.variable_type", readonly=True) + value_char = fields.Char( + string="Value", + compute="_compute_value_char", + readonly=False, + store=True, + help="Automatically populated from selected option. " + "Manual edits will be overwritten when option changes.", + ) + option_id = fields.Many2one( + "cx.tower.variable.option", domain="[('variable_id', '=', variable_id)]" + ) + + variable_value_id = fields.Many2one("cx.tower.variable.value") + required = fields.Boolean( + related="variable_value_id.required", + readonly=True, + store=True, + ) + + @api.depends("option_id", "variable_id", "variable_type") + def _compute_value_char(self): + """ + Compute value_char based on selected option for option-type variables. + For non-option variables, value_char is cleared to allow manual input. + """ + for rec in self: + if rec.variable_id and rec.variable_type == "o" and rec.option_id: + rec.value_char = rec.option_id.value_char + else: + rec.value_char = "" + + @api.onchange("variable_id") + def _onchange_variable_id(self): + """ + Reset option_id when variable changes. + """ + self.update({"option_id": None}) diff --git a/addons/cetmix_tower_server/models/cx_tower_file.py b/addons/cetmix_tower_server/models/cx_tower_file.py new file mode 100644 index 0000000..faaf7d8 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_file.py @@ -0,0 +1,783 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from base64 import b64decode, b64encode + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError, ValidationError +from odoo.tools import exception_to_unicode + +# mapping of field names from template and field names from file +TEMPLATE_FILE_FIELD_MAPPING = { + "code": "code", + "file_name": "name", + "file_type": "file_type", + "server_dir": "server_dir", + "keep_when_deleted": "keep_when_deleted", + "auto_sync": "auto_sync", +} + +# to convert to 'relativedelta' object +INTERVAL_TYPES = { + "minutes": lambda interval: relativedelta(minutes=interval), + "hours": lambda interval: relativedelta(hours=interval), + "days": lambda interval: relativedelta(days=interval), + "weeks": lambda interval: relativedelta(days=7 * interval), + "months": lambda interval: relativedelta(months=interval), + "years": lambda interval: relativedelta(years=interval), +} + + +class CxTowerFile(models.Model): + """Files""" + + _name = "cx.tower.file" + _inherit = [ + "cx.tower.template.mixin", + "cx.tower.reference.mixin", + "mail.thread", + "mail.activity.mixin", + "cx.tower.key.mixin", + ] + _description = "Cetmix Tower File" + _order = "name" + + active = fields.Boolean(default=True) + name = fields.Char(help="File name WITHOUT path. Eg 'test.txt'") + rendered_name = fields.Char( + compute="_compute_render", + compute_sudo=True, + ) + template_id = fields.Many2one( + "cx.tower.file.template", + inverse="_inverse_template_id", + index=True, + ) + server_dir = fields.Char( + string="Directory on Server", + required=True, + default="", + help="Eg '/home/user' or '/var/log'", + ) + rendered_server_dir = fields.Char( + compute="_compute_render", + compute_sudo=True, + ) + full_server_path = fields.Char( + string="Full Path", + compute="_compute_render", + compute_sudo=True, + ) + source = fields.Selection( + [ + ("tower", "Tower"), + ("server", "Server"), + ], + help=""" + - Tower: file is pushed from Tower to server. + - Server: file is pulled from server to Tower. + """, + ) + auto_sync = fields.Boolean( + help="If enabled file will be synced automatically using cron", + default=False, + ) + # selection format: interval_number(integer)-interval_type(name of interval) + # it will be parsed as 'relativedelta' object + auto_sync_interval = fields.Selection( + selection=lambda self: self._selection_auto_sync_interval(), + ) + sync_date_next = fields.Datetime( + string="Next Sync Date", + required=True, + default=fields.Datetime.now, + help="Date and time of the next synchronisation", + ) + sync_date_last = fields.Datetime( + string="Last Sync Date", + readonly=True, + tracking=True, + help="Date and time of the latest successful synchronisation", + ) + server_response = fields.Text( + copy=False, + help="Server response received during the last operation.\n" + "Default value if no error happened is 'ok'.\n" + "Otherwise there will be a server error message logged.", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + index=True, + ondelete="cascade", + compute="_compute_server_id", + store=True, + readonly=False, + ) + code_on_server = fields.Text( + readonly=True, + help="Latest version of file content on server", + ) + rendered_code = fields.Char( + compute="_compute_render", + compute_sudo=True, + help="File content with variables rendered", + ) + keep_when_deleted = fields.Boolean( + help="File will be kept on server when deleted in Tower", + ) + file_type = fields.Selection( + selection=lambda self: self._selection_file_type(), + default=lambda self: self._default_file_type(), + required=True, + ) + file = fields.Binary( + string="Binary Content", + attachment=True, + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_file_variable_rel", + column1="file_id", + column2="variable_id", + ) + + # Jets + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + help="Jet template this file belongs to", + index=True, + compute="_compute_server_id", + store=True, + readonly=False, + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + help="Jet this file belongs to", + index=True, + ) + + @classmethod + def _get_depends_fields(cls): + """ + Define dependent fields for computing `variable_ids` in file-related models. + + This implementation specifies that the fields `code`, `server_dir`, + and `name` are used to compute the variables associated with a file. + + Returns: + list: A list of field names (str) representing the dependencies. + + Example: + The following fields trigger recomputation of `variable_ids`: + - `code`: The content of the file. + - `server_dir`: The directory on the server where the file is located. + - `name`: The name of the file. + """ + return ["code", "server_dir", "name"] + + # -- Selection + def _selection_file_type(self): + """Available file types + + Returns: + List of tuples: available options. + """ + return [ + ("text", "Text"), + ("binary", "Binary"), + ] + + def _selection_auto_sync_interval(self): + """ + Selection of auto sync interval + """ + return [ + ("10-minutes", "10 min"), + ("30-minutes", "30 min"), + ("1-hours", "1 hour"), + ("2-hours", "2 hour"), + ("6-hours", "6 hour"), + ("12-hours", "12 hour"), + ("1-days", "1 day"), + ("1-weeks", "1 week"), + ("1-months", "1 month"), + ("1-years", "1 year"), + ] + + # -- Defaults + def _default_file_type(self): + """Default file type + + Returns: + Char: `file_type` field selection value + """ + return "text" + + # -- Computes + + @api.depends("jet_id", "jet_id.server_id", "jet_id.jet_template_id") + def _compute_server_id(self): + for record in self: + if record.jet_id: + record.update( + { + "server_id": record.jet_id.server_id, + "jet_template_id": record.jet_id.jet_template_id, + } + ) + else: + # Reset the jet template id if the jet is removed + if record.jet_template_id: + record.jet_template_id = False + + @api.depends("server_id", "template_id", "name", "server_dir", "code") + def _compute_render(self): + """ + Compute file name, directory and code + """ + variable_obj = self.env["cx.tower.variable"] + for file in self: + if not file.server_id: + file.update( + { + "rendered_name": False, + "rendered_server_dir": False, + "rendered_code": False, + "full_server_path": False, + } + ) + continue + variables = list( + set( + file.get_variables_from_code(file.name) + + file.get_variables_from_code(file.server_dir) + + file.get_variables_from_code(file.code) + ) + ) + render_code_custom = file.render_code_custom + + # Get variable values for the server the file is linked to + var_vals = variable_obj._get_variable_values_by_references( + variables, + server=file.server_id, + jet_template=file.jet_template_id, + jet=file.jet_id, + ) + + rendered_code = "" + if file.file_type == "text" and file.source == "tower": + rendered_code = ( + var_vals + and file.code + and render_code_custom(file.code, **var_vals) + or file.code + ) + rendered_name = ( + var_vals + and file.name + and render_code_custom(file.name, **var_vals) + or file.name + ) + rendered_server_dir = ( + var_vals + and file.server_dir + and render_code_custom(file.server_dir, **var_vals) + or file.server_dir + ) + file.update( + { + "rendered_name": rendered_name, + "rendered_server_dir": rendered_server_dir, + "rendered_code": rendered_code, + "full_server_path": f"{rendered_server_dir}/{rendered_name}", + } + ) + + # -- Onchange + @api.onchange("template_id") + def _onchange_template_id(self): + """ + Update file data by template values + """ + for file in self: + if file.template_id: + file.update(file._get_file_values_from_related_template()) + + @api.onchange("source") + def _onchange_source(self): + """ + Reset file template after change source + """ + self.update({"template_id": False}) + + def _inverse_template_id(self): + """ + Replace file fields values by template values + """ + for file in self: + if file.template_id: + file.write(file._get_file_values_from_related_template()) + + # -- Create/Write/Unlink + @api.model_create_multi + def create(self, vals_list): + """ + Override to sync files + """ + vals_list = [self._sanitize_values(vals) for vals in vals_list] + records = super().create(vals_list) + records._post_create_write("create") + return records + + def write(self, vals): + """ + Override to sync files from tower + """ + vals = self._sanitize_values(vals) + result = super().write(vals) + + # sync tower files after change + sync_fields = self._get_tower_sync_field_names() + files_to_sync = self.filtered( + lambda file: file.auto_sync + and file.source == "tower" + and any(field in vals for field in sync_fields) + ) + if files_to_sync: + files_to_sync._post_create_write("write") + return result + + def unlink(self): + """ + Override to delete from server tower files with + `keep_when_deleted` set to False + """ + self.filtered( + lambda file_: ( + file_.server_id + and file_.source == "tower" + and not file_.keep_when_deleted + ) + ).delete() + return super().unlink() + + # -- Actions + def action_unlink_from_template(self): + """ + Unlink file from template to make it editable + """ + self.ensure_one() + self.template_id = False + + def action_push_to_server(self): + """ + Push the file to server + """ + server_files = self.filtered(lambda file_: file_.source == "server") + if server_files: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Failure"), + "message": _( + "Unable to upload file '%(f)s'.\n" + "Upload operation is not supported for 'server' type files.", + f=server_files[0].rendered_name, + ), + "sticky": False, + }, + } + self.upload(raise_error=True) + single_msg = _("File uploaded!") + plural_msg = _("Files uploaded!") + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": single_msg if len(self) == 1 else plural_msg, + "sticky": False, + }, + } + + def action_pull_from_server(self): + """ + Pull file from server + """ + tower_files = self.filtered(lambda file_: file_.source == "tower") + server_files = self - tower_files + tower_files.action_get_current_server_code() + res = server_files.download(raise_error=True) + if isinstance(res, dict): + return res + + single_msg = _("File downloaded!") + plural_msg = _("Files downloaded!") + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": single_msg if len(self) == 1 else plural_msg, + "sticky": False, + }, + } + + def action_delete_from_server(self): + """ + Delete file from server + """ + server_files = self.filtered(lambda file_: file_.source == "server") + if server_files: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Failure"), + "message": _( + "Unable to delete file '%(f)s'.\n" + "Delete operation is not supported for 'server' type files.", + f=server_files[0].rendered_name, + ), + "sticky": False, + }, + } + self.delete(raise_error=True) + single_msg = _("File deleted!") + plural_msg = _("Files deleted!") + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": single_msg if len(self) == 1 else plural_msg, + "sticky": False, + }, + } + + def action_get_current_server_code(self): + """ + Get actual file code from server + """ + for file in self: + if file.source != "tower": + raise UserError( + _( + "File %(f)s is not 'tower' type. " + "This operation is supported for 'tower' " + "files only", + f=file.name, + ) + ) + + # Calling `_process` directly to get server version of a `tower` file + res = file.with_context(is_server_code_version_process=True)._process( + "download" + ) + # Type check because _process method could return + # a display_notification action dict + if isinstance(res, dict): + return res + file.code_on_server = res + + # -- Business logic + def _post_create_write(self, op_type="write"): + """Helper function that is called after file creation or update. + Use this function to implement custom hooks. + + Args: + op_type (str, optional): Operation type. Defaults to "write". + Possible options: + - "create" + - "write" + """ + + # Pull all `auto_sync` server files + server_files_to_sync = self.filtered( + lambda file: file.auto_sync and file.source == "server" + ) + if server_files_to_sync: + server_files_to_sync.action_pull_from_server() + + # Push all `auto_sync` tower files + tower_files_to_sync = self.filtered( + lambda file: file.auto_sync and file.source == "tower" + ) + if tower_files_to_sync: + tower_files_to_sync.action_push_to_server() + + def _get_file_values_from_related_template(self): + """ + Return file values from related template + """ + self.ensure_one() + if not self.template_id: + return {} + + values = self.template_id.read(list(TEMPLATE_FILE_FIELD_MAPPING), load=False)[0] + if ( + self.env.context.get("is_custom_server_dir") + and self.server_dir + and "server_dir" in values + ): + del values["server_dir"] + + return { + key: values[name] + for name, key in TEMPLATE_FILE_FIELD_MAPPING.items() + if name in values + } + + @api.model + def _sanitize_values(self, values): + """ + Check the values and reformat if necessary + """ + if "server_dir" in values: + server_dir = (values.get("server_dir") or "").strip() + if server_dir.endswith("/") and server_dir != "/": + server_dir = server_dir[:-1] + values.update( + { + "server_dir": server_dir, + } + ) + return values + + def download(self, raise_error=False): + """Wrapper function for file download. + Use it for custom hooks implementation. + + Args: + raise_error (bool, optional): + Will raise and exception on error if set to 'True'. + Defaults to False. + """ + return self._process("download", raise_error) + + def upload(self, raise_error=False): + """Wrapper function for file upload. + Use it for custom hooks implementation. + + Args: + raise_error (bool, optional): + Will raise and exception on error if set to 'True'. + Defaults to False. + """ + self._process("upload", raise_error) + + def delete(self, raise_error=False): + """Wrapper function for file removal. + Use it for custom hooks implementation. + + Args: + raise_error (bool, optional): + Will raise and exception on error if set to 'True'. + Defaults to False. + """ + self._process("delete", raise_error) + + def _process_download( + self, + tower_key_obj, + is_server_code_version_process=False, + ): + """ + Processing of file download. + Note: moved this functionality to a separate function from + the general `_process` method because it is already too complex. + + Args: + tower_key_obj (RecordSet): `cx.tower.key` + recordset to parse file path. + is_server_code_version_process (bool): + Flag to fetch actual file content from server + for a `tower` type file. + + Returns: + [dict|str|None]: + display_notification action dict if there was an error + during the operation. + file content if `is_server_code_version_process` is True. + None otherwise. + """ + self.ensure_one() + code = self.server_id.download_file( + tower_key_obj._parse_code(self.full_server_path), + ) + if self.file_type == "text" and b"\x00" in code: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Failure"), + "message": _( + "Cannot download %(f)s from server: " + "Binary content is not supported " + "for 'Text' file type", + ) + % {"f": self.rendered_name}, + "sticky": True, + }, + } + # In case server version of a 'tower' file is requested + if is_server_code_version_process: + return code + if self.file_type == "binary": + self.file = b64encode(code) + else: + self.code = code + + def _process(self, action, raise_error=False): + """Upload or download file to/from server. + Important! + This function will return a value only in case `is_server_code_version_process` + key is present in context. + This key is used to fetch actual file content from server + for a `tower` type file. + In all other cases it will update the file content and save + server response into the `server_response` field. + + + + Args: + action (Selection): Action to process. + Possible options: + - "upload": Upload file. + - "download": Download file. + - "delete": Delete file. + raise_error (bool, optional): Raise exception if there was an error + during the operation. Defaults to False. + + Raises: + UserError: In case file format doesn't match the requested operation. + Eg if trying to upload 'server' type file. + ValidationError: In case there is an error while performing + an action with a file. + + Returns: + Char: file content or False. + """ + + tower_key_obj = self.env["cx.tower.key"] + is_server_code_version_process = self.env.context.get( + "is_server_code_version_process" + ) + for file in self: + if not is_server_code_version_process and ( + (action == "download" and file.source != "server") + or (action == "upload" and file.source != "tower") + or (action == "delete" and file.source != "tower") + ): + if raise_error: + raise UserError( + _( + "File %(f)s shouldn't have the '%(src)s' source " + " for the '%(act)s' action", + f=file.name, + src=file.source, + act=action, + ) + ) + return False + + if action == "delete": + try: + file.check_access("unlink") + except AccessError as e: + if raise_error: + raise AccessError( + _( + "Due to security restrictions you are " + "not allowed to delete %(fp)s", + fp=file.full_server_path, + ) + ) from e + return False + + try: + if action == "download": + res = file._process_download( + tower_key_obj, is_server_code_version_process + ) + if res: + return res + elif action == "upload": + if file.file_type == "binary": + file_content = b64decode(file.file) + else: + file_content = tower_key_obj._parse_code(file.rendered_code) + file.server_id.upload_file( + file_content, + tower_key_obj._parse_code(file.full_server_path), + ) + elif action == "delete": + file.server_id.delete_file( + tower_key_obj._parse_code(file.full_server_path) + ) + else: + return False + file.sudo().server_response = "ok" + except Exception as error: + if raise_error: + raise ValidationError( + _( + "Cannot %(action)s %(f)s to/from server: %(err)s", + action=action, + f=file.rendered_name, + err=exception_to_unicode(error), + ) + ) from error + file.server_response = repr(error) + + if not is_server_code_version_process: + self._update_file_sync_date(fields.Datetime.now()) + + @api.model + def _get_tower_sync_field_names(self): + """ + Return the list of field names to start synchronization + after changing these fields + """ + return ["name", "server_dir", "code"] + + @api.model + def _run_auto_pull_files(self): + """ + Run auto sync files + """ + now = fields.Datetime.now() + files = self.search( + [ + ("source", "=", "server"), + ("auto_sync", "=", True), + ("sync_date_next", "<=", now), + ] + ) + files.download(raise_error=False) + + def _update_file_sync_date(self, last_sync_date): + """ + Compute and update next date of sync + """ + for file in self: + vals = {} + if file.source == "server" and file.auto_sync and file.auto_sync_interval: + interval, interval_type = file.auto_sync_interval.split("-") + vals.update( + { + "sync_date_next": last_sync_date + + INTERVAL_TYPES[interval_type](int(interval)) + } + ) + if file.server_response == "ok": + vals.update({"sync_date_last": last_sync_date}) + file.sudo().write(vals) + + # Check cx.tower.reference.mixin for the function documentation + def _get_pre_populated_model_data(self): + res = super()._get_pre_populated_model_data() + res.update({"cx.tower.file": ["cx.tower.server", "server_id"]}) + return res diff --git a/addons/cetmix_tower_server/models/cx_tower_file_template.py b/addons/cetmix_tower_server/models/cx_tower_file_template.py new file mode 100644 index 0000000..bcd52d1 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_file_template.py @@ -0,0 +1,247 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from .cx_tower_file import TEMPLATE_FILE_FIELD_MAPPING + + +class CxTowerFileTemplate(models.Model): + """File template to manage multiple files at once""" + + _name = "cx.tower.file.template" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.key.mixin", + "cx.tower.template.mixin", + "cx.tower.access.role.mixin", + "cx.tower.tag.mixin", + ] + _description = "Cetmix Tower File Template" + _order = "name" + + active = fields.Boolean(default=True) + file_name = fields.Char( + help="Default full file name with file type for example: test.txt", + ) + code = fields.Text(string="File content") + server_dir = fields.Char(string="Directory on server") + file_ids = fields.One2many("cx.tower.file", "template_id") + file_count = fields.Integer( + "File(s)", + compute="_compute_file_count", + ) + tag_ids = fields.Many2many( + relation="cx_tower_file_template_tag_rel", + column1="file_template_id", + column2="tag_id", + ) + note = fields.Text(help="This field is used to put some notes regarding template.") + keep_when_deleted = fields.Boolean( + help="File will be kept on server when deleted in Tower", + ) + auto_sync = fields.Boolean( + help="If enabled, files created from this template will have " + "Auto Sync enabled by default. Used only with 'Tower' source.", + ) + file_type = fields.Selection( + selection=lambda self: self.env["cx.tower.file"]._selection_file_type(), + default=lambda self: self.env["cx.tower.file"]._default_file_type(), + required=True, + ) + source = fields.Selection( + [ + ("tower", "Tower"), + ("server", "Server"), + ], + required=True, + default="tower", + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_file_template_variable_rel", + column1="file_template_id", + column2="variable_id", + ) + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_file_template_user_rel", + domain=lambda self: [ + ("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id]) + ], + ) + manager_ids = fields.Many2many( + relation="cx_tower_file_template_manager_rel", + ) + + @classmethod + def _get_depends_fields(cls): + """ + Define dependent fields for computing + `variable_ids` in file template-related models. + + This implementation specifies that the fields `code`, `server_dir`, + and `file_name` are used to compute the + variables associated with a file template. + + Returns: + list: A list of field names (str) representing the dependencies. + + Example: + The following fields trigger recomputation + of `variable_ids`: + - `code`: The template content for the file. + - `server_dir`: The target directory on the + server where the template is applied. + - `file_name`: The name of the generated file. + """ + return ["code", "server_dir", "file_name"] + + # -- Computes + @api.depends("file_ids") + def _compute_file_count(self): + """ + Compute total template files + """ + for template in self: + template.file_count = len(template.file_ids) + + # -- Create/Write/Unlink + def write(self, vals): + """ + Override to update files related with the templates + """ + result = super().write(vals) + if any(field_ in vals for field_ in TEMPLATE_FILE_FIELD_MAPPING): + for file in self.mapped("file_ids"): + file.write(file._get_file_values_from_related_template()) + return result + + # -- Actions + def action_open_files(self): + """ + Open current template files + """ + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_action" + ) + action["domain"] = [("id", "in", self.file_ids.ids)] + return action + + # -- Business logic + def create_file( + self, server, server_dir="", if_file_exists="raise", jet_template=None, jet=None + ): + """ + Create a new file using the current template for the selected server. + If the same file already exists, just ignore it or raise an error based on the + parameter. + + :param server: recordset + The server (cx.tower.server) on which the file should be created. This is a + required parameter. + :param if_file_exists: str, optional + Defines the behavior if the file already exists on the server. + :param server_dir: str, optional + The directory on the server where the file should be created. If not set, + the server_dir field of the template will be used. + :param jet_template: cx.tower.jet.template, optional + The jet template to use for creating the new file. + :param jet: cx.tower.jet, optional + The jet to use for creating the new file. + + :return: cx.tower.file + Returns the newly created file record (cx.tower.file) if the file was + created successfully or if_file_exists is set to "overwrite". + Returns the existing file record if the file already exists + and if_file_exists is set to "skip". + + :raises ValidationError: + If the file already exists on the server if_file_exists is set to "raise". + """ + self.ensure_one() + # Explicit guard against invalid behavior values + valid_behaviors = {"skip", "raise", "overwrite"} + if if_file_exists not in valid_behaviors: + raise ValidationError( + _( + "Invalid if_file_exists value: %(if_file_exists)s. " + "Expected one of %(valid_behaviors)s.", + if_file_exists=if_file_exists, + valid_behaviors=valid_behaviors, + ) + ) + file_model = self.env["cx.tower.file"] + existing_files = file_model.search( + [ + ("server_id", "=", server.id), + ("source", "=", self.source), + ], + order="id DESC", + ) + existing_dir = server_dir or self.server_dir + + # Render the server directory and file name from the template + variables = list( + set( + self.get_variables_from_code(self.file_name) + + self.get_variables_from_code(existing_dir) + ) + ) + var_vals = self.env["cx.tower.variable"]._get_variable_values_by_references( + variables, + server=server, + jet_template=jet_template, + jet=jet, + ) + + unrendered_path = ( + f"{existing_dir}/{self.file_name}" if existing_dir else self.file_name + ) + rendered_path = self.render_code_custom(unrendered_path, **var_vals) + + # Filter existing files by rendered path + existing_files = existing_files.filtered( + lambda f: f.full_server_path == rendered_path + ) + + # Filter existing files by template if it exists, otherwise take the first one + existing_file = ( + existing_files.filtered(lambda f: f.template_id == self)[:1] + or existing_files[:1] + ) + + if existing_file and if_file_exists == "skip": + return existing_file.with_context(file_creation_skipped=True) + + if existing_file and if_file_exists == "raise": + raise ValidationError(_("File already exists on server.")) + + if existing_file and if_file_exists == "overwrite": + existing_file.with_context(is_custom_server_dir=True).write( + { + "template_id": self.id, # pylint: disable=no-member + "jet_template_id": jet_template.id if jet_template else None, + "jet_id": jet.id if jet else None, + } + ) + return existing_file + + vals = { + "name": self.file_name, + "server_id": server.id, + "server_dir": existing_dir, + "template_id": self.id, # pylint: disable=no-member + "code": self.code, + "file_type": self.file_type, + "source": self.source, + "auto_sync": self.auto_sync, + "jet_template_id": jet_template.id if jet_template else None, + "jet_id": jet.id if jet else None, + } + + new_file = file_model.with_context(is_custom_server_dir=True).create(vals) + # Return new_file if no file exists + return new_file diff --git a/addons/cetmix_tower_server/models/cx_tower_jet.py b/addons/cetmix_tower_server/models/cx_tower_jet.py new file mode 100644 index 0000000..0be05bb --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet.py @@ -0,0 +1,1703 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import ast +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, ValidationError + +from .constants import ( + JET_ACTION_NOT_AVAILABLE, + JET_DEPENDENCIES_NOT_SATISFIED, + JET_STATE_ERROR, +) +from .tools import generate_random_id + +_logger = logging.getLogger(__name__) + + +class CxTowerJet(models.Model): + """Jets represent application instances that can be managed independently""" + + _name = "cx.tower.jet" + _description = "Cetmix Tower Jet" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.variable.mixin", + "cx.tower.metadata.mixin", + "mail.thread", + "mail.activity.mixin", + "cx.tower.tag.mixin", + "cx.tower.access.role.mixin", + ] + _order = "sequence, name" + _mail_post_access = "read" + + active = fields.Boolean(default=True) + deletable = fields.Boolean( + readonly=True, + default=True, + help="This field is set by the jet actions. " + "If enabled, the jet can be deleted", + ) + url = fields.Char(string="URL", help="Jet URL, eg 'https://meme.example.com'") + color = fields.Integer(related="state_id.color", readonly=True) + icon = fields.Image( + string="Icon image", + related="jet_template_id.icon", + readonly=True, + store=False, + help="Jet icon, computed from the template by default", + ) + sequence = fields.Integer(default=10, help="Used to sort jets in views") + partner_id = fields.Many2one( + comodel_name="res.partner", + help="Partner associated with this jet", + ) + note = fields.Text() + + jet_cloned_from_id = fields.Many2one( + comodel_name="cx.tower.jet", + string="Cloned from", + readonly=True, + copy=False, + help="Jet this jet was cloned from. " + "This field is set when the jet is cloned from another jet.", + ) + + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + required=True, + ondelete="restrict", + help="Template that this jet is based on", + ) + jet_template_domain = fields.Binary( + compute="_compute_jet_template_domain", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + required=True, + ondelete="restrict", + help="Server where this jet is running", + ) + server_allowed_domain = fields.Binary( + compute="_compute_server_allowed_domain", + ) + file_ids = fields.One2many( + comodel_name="cx.tower.file", + inverse_name="jet_id", + string="Files", + help="Files of this jet", + ) + server_log_ids = fields.One2many( + comodel_name="cx.tower.server.log", + inverse_name="jet_id", + copy=False, + ) + scheduled_task_ids = fields.Many2many( + comodel_name="cx.tower.scheduled.task", + relation="cx_tower_scheduled_task_jet_rel", + column1="jet_id", + column2="scheduled_task_id", + string="Scheduled Tasks", + ) + + # -- Jet Requests + served_jet_request_id = fields.Many2one( + comodel_name="cx.tower.jet.request", + help="Request this jet is currently serving", + readonly=True, + copy=False, + ) + + # -- Dependencies + jet_requires_ids = fields.One2many( + comodel_name="cx.tower.jet.dependency", + inverse_name="jet_id", + string="Requires", + help="Other jets this jet depends on", + compute="_compute_jet_requires_ids", + store=True, + groups="cetmix_tower_server.group_manager", + copy=False, + ) + jet_required_by_ids = fields.One2many( + comodel_name="cx.tower.jet.dependency", + inverse_name="jet_depends_on_id", + string="Required By", + help="Jets that depend on this jet", + groups="cetmix_tower_server.group_manager", + copy=False, + readonly=True, + ) + + # -- States and actions + state_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="Current State", + tracking=True, + domain="[('id', 'in', jet_template_state_ids)]", + copy=False, + ) + state = fields.Char( + related="state_id.reference", + readonly=True, + store=True, + index=True, + string="State Reference", + help="Current state of the jet. " + "NB: this is " + "the reference of the state, not the name.", + ) + jet_template_state_ids = fields.One2many( + comodel_name="cx.tower.jet.state", + compute="_compute_state_available_ids", + ) + state_available_ids = fields.One2many( + comodel_name="cx.tower.jet.state", + compute="_compute_state_available_ids", + help="Available states for the jet. " + "Click on the button to transition to the state.", + copy=False, + ) + + target_state_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="Target State", + readonly=True, + copy=False, + help="Destination state to which the jet is currently transitioning", + ) + show_available_states = fields.Boolean( + help="Show available states in the jet view", + compute="_compute_show_available_states", + inverse="_inverse_show_available_states", + groups="cetmix_tower_server.group_manager", + ) + action_available_ids = fields.Many2many( + comodel_name="cx.tower.jet.action", + compute="_compute_available_actions", + string="Available Actions", + help="Available actions for the jet. " + "Click on the button to trigger the action.", + ) + current_action_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + string="Executing Action", + readonly=True, + copy=False, + ) + current_command_log_id = fields.Many2one( + comodel_name="cx.tower.command.log", + string="Executing Command Log", + groups="cetmix_tower_server.group_manager", + readonly=True, + copy=False, + ) + + # -- Waypoints + is_waypoints_available = fields.Boolean( + compute="_compute_is_waypoints_available", + readonly=True, + ) + waypoint_ids = fields.One2many( + comodel_name="cx.tower.jet.waypoint", + inverse_name="jet_id", + string="Waypoints", + help="Waypoints of the jet", + copy=False, + ) + waypoint_id = fields.Many2one( + comodel_name="cx.tower.jet.waypoint", + help="Current waypoint of the jet", + readonly=True, + copy=False, + tracking=True, + ) + + # -- Variables used for configuration + variable_value_ids = fields.One2many( + inverse_name="jet_id", + ) + + # -- Logs + command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", + inverse_name="jet_id", + copy=False, + ) + plan_log_ids = fields.One2many( + comodel_name="cx.tower.plan.log", + inverse_name="jet_id", + copy=False, + ) + + # -- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_jet_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_jet_manager_rel", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Compute methods + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @api.depends("name", "state_id") + def _compute_display_name(self): + """Compute the display name of the jet""" + for jet in self: + jet.display_name = f"{jet.name} ({jet.state})" if jet.state else jet.name + + @api.depends("server_id") + def _compute_jet_template_domain(self): + """Compute the domain of the jet template""" + for jet in self: + jet.jet_template_domain = ( + [("server_ids", "in", [jet.server_id.id])] if jet.server_id else [] + ) + + @api.depends("jet_template_id") + def _compute_server_allowed_domain(self): + """Compute the domain of the server allowed""" + for jet in self: + jet.server_allowed_domain = ( + [("id", "in", jet.jet_template_id.server_ids.ids)] + if jet.jet_template_id and jet.jet_template_id.server_ids + else [] + ) + + @api.depends("jet_template_id", "jet_template_id.action_ids") + def _compute_state_available_ids(self): + """Compute the available states for the jet""" + for jet in self: + if not jet.jet_template_id: + jet.update( + { + "jet_template_state_ids": False, + "state_available_ids": False, + } + ) + continue + actions = jet.jet_template_id.action_ids + if not actions: + jet.update( + { + "jet_template_state_ids": False, + "state_available_ids": False, + } + ) + continue + # Compute effective access level for the user + effective_user_access_level = jet._get_user_effective_access_level() + jet.update( + { + "jet_template_state_ids": actions.state_from_id + | actions.state_transit_id + | actions.state_to_id, + "state_available_ids": ( + actions.state_to_id - jet.state_id + ).filtered( + lambda s, + access_level=effective_user_access_level: s.access_level + <= access_level + ), + } + ) + + @api.depends( + "state_id", + "jet_template_id", + "jet_template_id.action_ids", + "jet_template_id.action_ids.state_from_id", + "jet_template_id.action_ids.state_to_id", + "jet_template_id.action_ids.priority", + ) + def _compute_available_actions(self): + """Compute available actions based on current state and template""" + for jet in self: + if not jet.jet_template_id: + jet.action_available_ids = False + continue + + # Find actions in the template that start from the current state + actions = jet.jet_template_id.action_ids.filtered( + lambda a, state=jet.state_id: a.state_from_id == state + ) + jet.update({"action_available_ids": actions}) + + @api.depends("jet_template_id", "jet_template_id.template_requires_ids") + def _compute_jet_requires_ids(self): + """Compute the dependencies of the jets""" + for jet in self: + jet_template_dependencies = jet.jet_template_id.template_requires_ids + + final_vals = [] + + # 1. Check removed dependencies + if jet_template_dependencies: + jet_dependencies_to_remove = jet.jet_requires_ids.filtered( + lambda d, + jtd=jet_template_dependencies: d.jet_template_dependency_id + not in jtd + ) + else: + jet_dependencies_to_remove = jet.jet_requires_ids + + if jet_dependencies_to_remove: + final_vals = [(3, dep.id) for dep in jet_dependencies_to_remove] + + # Check new template dependencies + if jet_template_dependencies: + if jet.jet_requires_ids: + new_jet_template_dependencies = jet_template_dependencies.filtered( + lambda d, j=jet: d.id + not in j.jet_requires_ids.jet_template_dependency_id.ids + ) + else: + new_jet_template_dependencies = jet_template_dependencies + for dep in new_jet_template_dependencies: + final_vals.append( + ( + 0, + 0, + { + "jet_id": jet.id, + "jet_template_dependency_id": dep.id, + }, + ) + ) + if final_vals: + jet.jet_requires_ids = final_vals + + @api.depends_context("uid") + def _compute_show_available_states(self): + """Compute if available states should be shown for the jet""" + # Set all records at once to avoid multiple writes + self.show_available_states = ( + self.env.user.cetmix_tower_show_jet_available_states + ) + + def _inverse_show_available_states(self): + """Inverse the show available states for the jet""" + for jet in self: + if jet.show_available_states is not None: + jet.env.user.cetmix_tower_show_jet_available_states = ( + jet.show_available_states + ) + + @api.depends("jet_template_id", "jet_template_id.waypoint_template_ids") + def _compute_is_waypoints_available(self): + """Compute if waypoints are available for the jet""" + for jet in self: + jet.is_waypoints_available = bool(jet.jet_template_id.waypoint_template_ids) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Constraints + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @api.constrains("server_id", "jet_template_id") + def _check_jet_limit_per_server(self): + """Check if the jet limit per server is reached""" + for jet in self: + if ( + jet.jet_template_id.limit_per_server + and jet.jet_template_id.limit_per_server > 0 + ): + if jet.jet_template_id.limit_per_server < len( + jet.jet_template_id.jet_ids.filtered( + lambda j, s=jet.server_id: j.server_id == s + ) + ): + raise ValidationError( + _( + "Jet limit per server reached for" + " '%(jet)s' on server '%(server)s'!", + jet=jet.display_name, + server=jet.server_id.display_name, + ) + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # ORM methods + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @api.model_create_multi + def create(self, vals_list): + """ + Create jets + - Generate jet reference if not provided + """ + + for vals in vals_list: + if not vals.get("reference"): + vals["reference"] = generate_random_id( + sections=3, population=4, separator="_" + ) + jets = super().create(vals_list) + + # Create server logs and scheduled tasks + for jet in jets: + # Server logs + for server_log in jet.jet_template_id.server_log_ids: + jet_log = server_log.copy( + { + "jet_id": jet.id, + "server_id": jet.server_id.id, + "jet_template_id": False, + } + ) + if server_log.log_type == "file": + jet_log.file_id = server_log.file_template_id.create_file( + server=jet.server_id, jet=jet, if_file_exists="skip" + ).id + + # Scheduled tasks + jet.scheduled_task_ids = jet.jet_template_id.scheduled_task_ids + + return jets + + def write(self, vals): + """Handle the entry into the new state""" + # Allow modifications in install mode only to load demo data + if ("jet_template_id" in vals or "server_id" in vals) and not ( + self._context.get("install_mode") and self._context.get("install_xmlid") + ): + raise ValidationError( + _( + "Jet template and server cannot be changed" + " once the jet is created!" + ) + ) + if "state_id" in vals: + for jet in self: + jet._on_state_exit(state=jet.state_id) + res = super().write(vals) + for jet in self: + jet._on_state_enter(state=jet.state_id) + else: + res = super().write(vals) + return res + + def unlink(self): + """ + Unlink all related files + """ + + # Check if the jet is deletable + not_deletable_jets = self.filtered(lambda j: not j.deletable) + if not_deletable_jets: + raise ValidationError( + _( + "Following jets cannot be deleted as they are not deletable: %s", + not_deletable_jets.mapped("display_name"), + ) + ) + files = self.file_ids + res = super().unlink() + + # Unlink files only after the records are deleted + # This is done to avoid deleting the files while + # the 'unlink' method fails due to some reason. + if files: + files.unlink() + return res + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Odoo Actions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def action_run_command(self): + """ + Returns wizard action to select command and run it + """ + context = self.env.context.copy() + context["default_jet_ids"] = self.ids + return { + "type": "ir.actions.act_window", + "name": _("Run Command"), + "res_model": "cx.tower.command.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_run_flight_plan(self): + """ + Returns wizard action to select flightplan and run it + """ + context = self.env.context.copy() + context["default_jet_ids"] = self.ids + return { + "type": "ir.actions.act_window", + "name": _("Run Flight Plan"), + "res_model": "cx.tower.plan.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_open_command_logs(self): + """ + Open current server command log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + action["domain"] = [("jet_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_plan_logs(self): + """ + Open current server flightplan log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + action["domain"] = [("jet_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_state_wizard(self): + """Open the jet state wizard""" + context = self.env.context.copy() + context["default_jet_ids"] = [(6, 0, self.ids)] + action = { + "type": "ir.actions.act_window", + "res_model": "cx.tower.jet.state.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + return action + + def action_open_action_wizard(self): + """Open the jet action wizard""" + context = self.env.context.copy() + context["default_jet_ids"] = [(6, 0, self.ids)] + action = { + "type": "ir.actions.act_window", + "res_model": "cx.tower.jet.action.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + return action + + def action_open_files(self): + """ + Open files of the current server + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_action" + ) + action["domain"] = [("jet_id", "=", self.id)] # pylint: disable=no-member + + context = self._context.copy() + if "context" in action and isinstance((action["context"]), str): + context.update(ast.literal_eval(action["context"])) + else: + context.update(action.get("context", {})) + + # Remove group_by from context + context.pop("group_by", None) + context.update( + { + "default_jet_id": self.id, + "default_server_id": self.server_id.id, + } + ) + action["context"] = context + return action + + def action_open_requires_jets(self): + """ + Open required jets of the current jet + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + action["domain"] = [("jet_required_by_ids.jet_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_required_by_jets(self): + """ + Open dependant jets of the current jet + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + action["domain"] = [("jet_requires_ids.jet_depends_on_id", "=", self.id)] # pylint: disable=no-member + return action + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # General functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _get_user_effective_access_level(self): + """ + Get the effective access level for the current user. + If user is manager but is not added as a manager to the jet, + his access level is considered as user. + Returns: + str: The effective access level for the current user. + see _selection_access_level() in cx.tower.access.mixin + """ + self.ensure_one() + user_access_level = self.env.user._cetmix_tower_access_level() + if user_access_level == "2" and self.env.user not in self.manager_ids: + return "1" + return user_access_level + + def get_variable_value(self, variable_reference, no_fallback=False): + """ + Return the value of a variable for the current jet. + NB: this function follows the value application order. + Jet->Jet Template->Server->Global + + Args: + variable_reference (Char): The reference of the variable + to get the value for + no_fallback (bool): If True, will return current record value + without checking fallback values. + + Returns: + str: The value of the variable for the current record or None + """ + self.ensure_one() + if no_fallback: + return super().get_variable_value(variable_reference, no_fallback) + variable = self.env["cx.tower.variable"].get_by_reference(variable_reference) + if not variable: + return None + values = variable._get_variable_values_by_references( + variable_references=[variable_reference], jet=self + ) + return values[variable_reference] + + def run_command( + self, + command, + path=None, + sudo=None, + ssh_connection=None, + **kwargs, + ): + """Run command on selected Jet. + A helper function that calls the corresponding server function. + + Important: this method raises an exception if the jet + is currently executing an action. + You should handle this exception in your code. + + Args: + command (cx.tower.command()): Command record + path (Char): directory where command is run. + Provide in case you need to override default command value + sudo (Boolean): use sudo + Defaults to None + ssh_connection (SSH client instance, optional): SSH connection. + Pass to reuse existing connection. + This is useful in case you would like to speed up + the ssh command running. Returns: + + Returns: + dict(): command running result if `no_command_log` + context value == True else None + """ + self.ensure_one() + + # Raise an exception if jets is currently executing an action + if self.current_action_id: + raise ValidationError( + _( + "Jet '%(jet)s' is currently executing an action", + jet=self.display_name, + ) + ) + + return self.server_id.run_command( + command=command, + path=path, + sudo=sudo, + ssh_connection=ssh_connection, + jet=self, + **kwargs, + ) + + def run_flight_plan(self, flight_plan, jet_template=None, **kwargs): + """ + Runs flight plan on the current jet. + + Important: this method raises an exception if the jet + is currently executing an action. + You should handle this exception in your code. + + Args: + flight_plan (cx.tower.plan()): flight plan to run + jet_template (cx.tower.jet.template()): jet template + to run the flight plan on + kwargs (dict): Optional arguments + Following are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "variable_values", dict(): custom variable values + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Will be applied only if user has write access to the server. + Raises: + ValidationError: If the jet is currently executing an action. + Returns: + log_record (cx.tower.plan.log()): plan log record + """ + + self.ensure_one() + + # Raise an exception if jets is currently executing an action + # TODO: keep an eye on this method in case we use it + # directly in actions. + if self.current_action_id: + raise ValidationError( + _( + "Jet '%(jet)s' is currently executing an action", + jet=self.display_name, + ) + ) + + return self.server_id.run_flight_plan( + flight_plan=flight_plan, + jet_template=jet_template, + jet=self, + **kwargs, + ) + + def bring_to_state(self, state_reference): + """ + Bring the jet to a specific state. + This is a wrapper around the _bring_to_state method meant to be used + in various automatic actions. + + IMPORTANT: alway prefer using this method over the _bring_to_state method + in automation (eg Python commands) because it will check the access level + of the user to the state and raise an exception if the user is not allowed + to set the state. + + Use `_bring_to_state` method directly if you want to provide a state + object instead of a reference. + + Args: + state_reference (Char): The reference of the state to bring the jet to. + Returns: + The jet is brought into the target state. + In case of an error, the jet is brought into the error state + if the latter is defined. + + Raises: + ValidationError: If the state is not found. + AccessError: If the user is not allowed to set the state. + """ + self.ensure_one() + state = self.env["cx.tower.jet.state"].get_by_reference(state_reference) + if not state: + raise ValidationError( + _( + "State '%(state)s' not found for jet '%(jet)s'", + state=state_reference, + jet=self.display_name, + ) + ) + + if state.access_level > self._get_user_effective_access_level(): + raise AccessError( + _("You are not allowed to set the '%(state)s' state!", state=state.name) + ) + + self._bring_to_state(state) + + def clone(self, server=None, name=None, state=None, **kwargs): + """ + Create a new jet from this template on the given server. + + Following configuration variables will be available in the flight plan: + `__original_jet__`: The reference of the original jet + `__requested_state__`: The reference of the requested state + the new jet was requested to be in. + + Use these variables in the flight plan to identify the original jet + and the requested state. + + Args: + server (cx.tower.server()): The server to clone the jet on. + If not provided, the jet will be cloned on the same server. + name (str): The name of the new jet. + If not provided, a random name will be generated. + state (cx.tower.jet.state()): The state to bring the new jet to. + + Kwargs: + field values to populate in the new jet record. + NB: configuration variables are provided as follows: + (dict): Custom configuration variables + Following format is used: + `variable_reference`: `variable_value_char` + eg: + {'branch': 'prod', 'odoo_version': '16.0'} + Returns: + cx.tower.jet(): The new jet or False if the cloning has failed + """ + self.ensure_one() + + jet_template = self.jet_template_id + if not server: + server = self.server_id + same_server = True + else: + same_server = server.id == self.server_id.id + + # Check if template allows cloning on the same server + if same_server and not jet_template.plan_clone_same_server_id: + raise ValidationError( + _( + "Cloning on the same server is not allowed" + " for template '%(template)s'", + template=jet_template.name, + ) + ) + # Check if template allows cloning to a different server + if not same_server and not jet_template.plan_clone_different_server_id: + raise ValidationError( + _( + "Cloning to a different server is not allowed" + " for template '%(template)s'", + template=jet_template.name, + ) + ) + # Check if the jet creation is allowed on the given server + if not jet_template._allow_jet_creation(server): + return False + + # Prepare the jet custom values + kwargs.update( + { + "jet_cloned_from_id": self.id, + } + ) + + # Create a new jet + jet = jet_template.create_jet( + server, name=name or self._default_cloned_jet_name(), **kwargs + ) + + # Set scheduled tasks of the original jet to the new jet + jet.scheduled_task_ids = self.scheduled_task_ids + + # Set server logs of the original jet to the new jet + # Delete the server logs of the new jet if the original jet + # has no server logs + if self.server_log_ids: + jet.server_log_ids = [ + log.copy({"jet_id": False, "server_id": False}).id + for log in self.server_log_ids + ] + # Create files for file-type server logs + for jet_log in jet.server_log_ids: + if jet_log.log_type == "command": + continue + if jet_log.log_type == "file": + jet_log.file_id = jet_log.file_template_id.create_file( + server=jet.server_id, jet=jet, if_file_exists="skip" + ).id + else: + jet.server_log_ids.unlink() + + # NB: we are not passing the state as we need to run + # the clone flight plan first. + # The plan should take care of the state transition + # using the configuration variables. + # Update the custom values in the kwargs + + variable_values = { + "__original_jet__": self.reference, + "__original_server__": self.server_id.reference, + "__requested_jet_state__": state.reference if state else None, + } + + if same_server and jet_template.plan_clone_same_server_id: + jet.run_flight_plan( + jet_template.plan_clone_same_server_id, variable_values=variable_values + ) + elif not same_server and jet_template.plan_clone_different_server_id: + jet.run_flight_plan( + jet_template.plan_clone_different_server_id, + variable_values=variable_values, + ) + + return jet + + def _default_cloned_jet_name(self): + """Return default cloned jet name""" + self.ensure_one() + return f"{self.name} (clone)" + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Jet actions, state transitions, jet requests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _trigger_action( + self, action, from_transition=False, raise_if_not_available=True, **kwargs + ): + """Trigger an action on the jet. + + The function flow is: + + 1. Bring the jet into the transit state. + 2. Execute the flight plan if defined. + 3. Bring the jet into the target state. + + Success: + The jet is brought into the target state. + + Error: + The jet is brought into the error state if it is defined. + Otherwise, the jet is brought into the initial state. + + + Args: + action (cx.tower.jet.action()): The action to trigger + from_transition (bool): True if the action is triggered + from a transition. + This is used to distinguish between a user directly + triggering the action and a transition from one state + to another. + raise_if_not_available (bool): + True if the function should raise an exception + if the action is not available for this jet. + **kwargs: Additional arguments: + - current_command_log: Optional command log record to track execution + + Returns: + dict: A dictionary with the following keys: + - status: The status of the action + - error: The error message if the action is not available + + Raises: + ValidationError: If the action is not available for this jet. + """ + self.ensure_one() + + # TODO: put action in the queue if jet is busy + + # Action properties must be accessible despite of the user group + action = action.sudo() + + # Get current command log + current_command_log = kwargs.get("current_command_log") + + # Ensure the action is available for this jet + if action.id not in self.action_available_ids.ids: + error = _( + "Action '%(action)s' is not available for jet" + " '%(jet)s' in state '%(state)s'", + action=action.name, + jet=self.name, # pylint: disable=no-member + state=self.state_id.name if self.state_id else _("Undefined"), + ) + if raise_if_not_available: + raise ValidationError(error) + if current_command_log: + current_command_log.finish( + status=JET_ACTION_NOT_AVAILABLE, + error=error, + ) + return {"status": JET_ACTION_NOT_AVAILABLE, "error": error} + + # Update the jet state + transit_state = action.state_transit_id + target_state = action.state_to_id + + # Check if the jet is already in the target state + # TODO: handle the case when destination state + # is the same as the current state. + # Eg when a jet is restarted. + if self.state_id == target_state and from_transition: + self.sudo().write({"target_state_id": None}) + self._finalize_transition(failed=False) + return {"status": 0, "error": None} + + # Set target state if not already set + if not self.target_state_id: + self.sudo().write({"target_state_id": target_state}) + + # Check if all dependencies are satisfied + # if starting from an undefined state. + # This is typical for a newly created jet. + if not self.state_id and not self._control_dependencies(): + # The process will be resumed + # when the dependencies are satisfied + error = _("Jet dependencies are not satisfied") + if current_command_log: + current_command_log.finish( + status=JET_DEPENDENCIES_NOT_SATISFIED, + error=error, + ) + return {"status": JET_DEPENDENCIES_NOT_SATISFIED, "error": error} + + self.sudo().write( + { + "state_id": transit_state, + "current_action_id": action.id, + "current_command_log_id": current_command_log.id + if current_command_log + else False, + } + ) + if action.plan_id: + # Run the flight plan + plan_kwargs = { + "plan_log": { + "jet_action_id": action.id, + }, + } + # Populate custom variable values from current command log + current_command_log = self.current_command_log_id + if current_command_log and current_command_log.variable_values: + plan_kwargs["variable_values"] = current_command_log.variable_values + + # Run the flight plan + with self.env.cr.savepoint(): + self.server_id.sudo().run_flight_plan( + flight_plan=action.plan_id, + jet=self, + **plan_kwargs, + ) + # Flight plan will trigger the `_flight_plan_finished` function again + # if the flight plan is finished successfully. + # So we don't need continue the loop in this case. + return {"status": 0, "error": None} + + # Set the state to the destination state if no plan is defined + final_vals = { + "state_id": target_state, + "current_action_id": False, + } + + # Reset the target state if the jet has reached the target state + if target_state == self.target_state_id: + final_vals["target_state_id"] = None + + self.sudo().write(final_vals) + + # Continue the chain of actions if the final state is not reached yet + if self.target_state_id: + self._bring_to_state(self.target_state_id) + + # Trigger the transition finished event + self._finalize_transition(failed=False) + return {"status": 0, "error": None} + + def _bring_to_state(self, state=None): + """ + Bring the jet to a specific state. + + The function flow is: + + 1. Compute the path of actions to bring the jet + to the target state. + 2. Set the target state. + 3. Trigger the first action in the path. + This will trigger a chain of actions until the jet is brought + into the target state. + + IMPORTANT: this method uses sudo() to bypass access rules. + This means that this method must be used with caution and only in cases + where the access level is not important. + For external automation including Python commands always prefer using + the bring_to_state() method instead. + For example: + ```python + jet = self.env["cx.tower.jet"].browse(jet_id) + jet.bring_to_state(state_reference) + ``` + + Args: + state (cx.tower.jet.state()): The state to bring the jet to + + Returns: + The jet is brought into the first state of the path. + In case of an error, the jet is brought into the error state + if the latter is defined. + + Raises: + ValidationError: If the path is not found. + """ + self.ensure_one() + + # Use sudo to bypass access rules + self = self.sudo() + + # Exit if jet is already in the target state + if self.state_id == state: + return + + # Compute the path of actions to bring the jet to the target state + path = self.jet_template_id._get_action_path( + state_from=self.state_id, state_to=state + ) + if not path: + raise ValidationError( + _( + "No path found to bring the jet %(jet)s to the state '%(state)s'", + jet=self.name, # pylint: disable=no-member + state=state.name if state else _("Undefined"), + ) + ) + + # Set the target state if not already set + if not self.target_state_id: + self.write( + { + "target_state_id": state, + } + ) + + # Trigger the first action in the path + self._trigger_action(path[0], from_transition=True) + + def _flight_plan_finished(self, plan_status): + """ + Handle the completion of a flight plan. + + Args: + plan_status (int): The status of the flight plan + (0: success, other: failure) + """ + self.ensure_one() + + # Used in case this is the last action in the chain + transition_failed = False + + # Reset the current action + vals = {"current_action_id": False} + + # If the flight plan is finished successfully, + # we bring the jet to the destination state + # of the current action + if plan_status == 0: + # Set the state to the destination state + vals["state_id"] = ( + self.current_action_id.state_to_id + and self.current_action_id.state_to_id.id + ) + + # Reset the target state if the jet has reached the target state + # This will stop the chain of actions + if self.target_state_id == self.current_action_id.state_to_id: + vals["target_state_id"] = None + + # If the flight plan is finished with an error, + # we bring the jet to the error state if it is defined + # or back to the initial state if not + # Reset the target state because we cannot continue the chain of actions + else: + vals.update( + { + "state_id": ( + self.current_action_id.state_error_id + and self.current_action_id.state_error_id.id + ) + or ( + self.current_action_id.state_from_id + and self.current_action_id.state_from_id.id + ), + "target_state_id": None, + } + ) + transition_failed = True + + self.sudo().write(vals) + + # Continue the chain of actions if the final state is not reached yet + if self.target_state_id: + self._bring_to_state(self.target_state_id) + else: + # Trigger the transition finished event + self._finalize_transition(failed=transition_failed) + + def _finalize_transition(self, failed=False): + """ + Handle the completion of a state transition. + + Args: + failed (bool): True if the transition failed, False otherwise + """ + self.ensure_one() + + # 1. Finalize the jet request if it exists + if self.served_jet_request_id: + self.served_jet_request_id._finalize(failed=failed) + + # 2. Finalize the command log if transition was + # triggered from a command + command_log = self.current_command_log_id + if command_log: + # Reset the current command log id + # Using sudo to bypass write access rules + self.sudo().write({"current_command_log_id": False}) + + # Prepare the command log finish values + if failed: + error = _( + "Action failed for jet %(jet)s.", + jet=self.name, # pylint: disable=no-member + ) + response = None + status = JET_STATE_ERROR + else: + response = _( + "Jet %(jet)s was moved to the '%(state)s' state.", + jet=self.name, # pylint: disable=no-member + state=self.state_id.name if self.state_id else _("Undefined"), + ) + status = 0 + error = None + + # Finish the command log + command_log.finish( + status=status, + response=response, + error=error, + ) + + # 3. Notify the jet that it is available + self._on_is_available() + + def _serve_jet_request(self, jet_request): + """ + Serve a jet request. + + Args: + jet_request (cx.tower.jet.request()): The jet request to serve + """ + self.ensure_one() + + # Save the request + # Using sudo to bypass write access rules + self.sudo().write({"served_jet_request_id": jet_request.id}) + + # State is reached, finalize the request + if self.state_id == jet_request.state_requested_id: + jet_request._finalize(failed=False) + else: + # Trigger the jet to bring itself to the required state + jet_request.state = "processing" + self._bring_to_state(jet_request.state_requested_id) + + def _finalize_jet_request(self, jet_request): + """ + This function is called when a jet request issued by this jet is finalized. + + Args: + jet_request (cx.tower.jet.request()): The jet request that was finalized + """ + self.ensure_one() + + # On success, update the dependency and + if jet_request.state == "success": + # Update the dependency if the request was for a dependency + dependency = jet_request.for_dependency_id + if dependency: + dependency.jet_depends_on_id = jet_request.jet_id + # Proceed with the state transition if all dependencies are satisfied + # and the transition is still in progress + if self._control_dependencies() and self.target_state_id: + self._bring_to_state(self.target_state_id) + else: + # Stop transition if the request failed + # Using sudo to bypass write access rules + self.sudo().write({"target_state_id": False}) + # Mark served jet request as failed + if self.served_jet_request_id: + self.served_jet_request_id._finalize(failed=True) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Waypoints + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def create_waypoint( + self, + waypoint_template, + name=None, + fly_here=False, + ignore_busy=False, + created_from_command_log=None, + **metadata, + ): + """Create a new waypoint for the jet. + + The jet must not be busy unless ignore_busy is True. + When created_from_command_log is provided, the waypoint stores it so that + the waypoint callback can finish the command log when the waypoint + reaches ready/current or error. + + Args: + waypoint_template (cx.tower.jet.waypoint.template or str): + The waypoint template or reference to create the waypoint from. + name (str, optional): The name of the waypoint. Defaults to None. + fly_here (bool, optional): Whether to fly to the waypoint after creation. + Defaults to False. + ignore_busy (bool, optional): Whether to ignore the busy state and create + the waypoint anyway. + Useful when creating waypoints from jet actions. + Defaults to False. + created_from_command_log (cx.tower.command.log, optional): Command log + that created this waypoint; the waypoint callback will finish it. + Defaults to None. + **metadata: Additional metadata to pass to the waypoint. + + Returns: + cx.tower.jet.waypoint + + Raises: + ValidationError: If the waypoint template is not found + or does not belong to the jet template, or if the jet is busy. + """ + self.ensure_one() + + # Check if the jet is busy + if self._is_busy() and not ignore_busy: + _logger.error( + "Cannot create waypoint for jet %s because it is busy", self.name + ) + raise ValidationError( + _("Cannot create waypoint for jet %s because it is busy", self.name) + ) + + # Resolve the waypoint template + if isinstance(waypoint_template, str): + waypoint_reference = waypoint_template + waypoint_template = self.env[ + "cx.tower.jet.waypoint.template" + ].get_by_reference(waypoint_reference) + if not waypoint_template: + _logger.error("Waypoint template %s not found", waypoint_reference) + raise ValidationError( + _("Waypoint template %s not found", waypoint_reference) + ) + + # Check if the waypoint template belongs to the jet template + if waypoint_template.jet_template_id != self.jet_template_id: + _logger.error( + "Waypoint template %s does not belong to the jet template %s", + waypoint_template.name, + self.jet_template_id.name, + ) + raise ValidationError( + _( + "Waypoint template %(waypoint_template)s does not belong " + "to the jet template %(jet_template)s", + waypoint_template=waypoint_template.name, + jet_template=self.jet_template_id.name, + ) + ) + + # Prepare the waypoint values + waypoint_values = self._prepare_waypoint_values( + waypoint_template=waypoint_template, + name=name, + **metadata, + ) + if created_from_command_log: + waypoint_values["created_from_command_log_id"] = created_from_command_log.id + + # Create the waypoint + waypoint = self.env["cx.tower.jet.waypoint"].create(waypoint_values) + waypoint.prepare(is_destination=fly_here) + return waypoint + + def _prepare_waypoint_values(self, waypoint_template, name=None, **metadata): + """Prepare the waypoint values + + Args: + waypoint_template (cx.tower.jet.waypoint.template): The waypoint template + name (Char, optional): The name of the waypoint. + """ + self.ensure_one() + + # Prepare the waypoint values + vals = { + "waypoint_template_id": waypoint_template.id, + "name": name if name else _("Auto-generated waypoint"), + "jet_id": self.id, + } + if metadata: + vals["metadata"] = metadata + + return vals + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Event handling + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _on_state_exit(self, state=None): + """ + Handle the exit of the jet from a state. + + Args: + state (cx.tower.jet.state()): The state jet is exiting + """ + self.ensure_one() + # TODO: Implement the logic to handle the exit of the jet from a state + pass + + def _on_state_enter(self, state=None): + """ + Handle the entry of the jet into a state. + + Args: + state (cx.tower.jet.state()): The state jet is entering + """ + self.ensure_one() + + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet", rec_ids=[self.id]) + + def _on_jet_request_completed(self, jet_request): + """ + Handle the completion of a jet request. + """ + self.ensure_one() + # TODO: Implement the logic to handle the completion of a jet request + pass + + def _on_is_available(self): + """ + Handle the event when the jet is not busy anymore. + """ + + # Process pending requests + jet_request_obj = self.env["cx.tower.jet.request"].sudo() + + # 1. Requests where the jet is requested explicitly + explicit_requests = jet_request_obj.search( + [ + ("jet_id", "=", self.id), # pylint: disable=no-member + ("state", "=", "new"), + ] + ) + if explicit_requests: + # Check which state is required by the request + # TODO: IMPORTANT: we must find a workaround to avoid infinite loops + # when different jets keep requesting the same target jet in different + # states and the target jet keeps jumping from one state to another. + + # Finalize all requests that request the same state as the jet + same_state_requests = explicit_requests.filtered( + lambda r: r.state_requested_id == self.state_id + ) + for request in same_state_requests: + request._finalize(failed=False) + + # Pick the first request that requests a different state + remaining_requests = explicit_requests - same_state_requests + if remaining_requests: + self._serve_jet_request(remaining_requests[0]) + return + + # 2. Requests where the jet is requested implicitly via template + if self._accepts_new_links(): + implicit_requests = jet_request_obj.search( + [ + ("server_id", "=", self.server_id.id), # pylint: disable=no-member + ("jet_template_id", "=", self.jet_template_id.id), # pylint: disable=no-member + ("jet_id", "=", False), + ("state", "=", "new"), + ] + ) + same_state_requests = implicit_requests.filtered( + lambda r: r.state_requested_id == self.state_id + ) + if same_state_requests: + # Set current jet as the target jet for the requests + same_state_requests.write({"jet_id": self.id}) # pylint: disable=no-member + for request in same_state_requests: + request._finalize(failed=False) + + # Pick the first request that requests a different state + remaining_requests = implicit_requests - same_state_requests + if remaining_requests: + remaining_request = remaining_requests[0] + # Set current jet as the target jet for the request + remaining_request.write({"jet_id": self.id}) # pylint: disable=no-member + self._serve_jet_request(remaining_request) + return + + # Send success notification when everything is done + # Use context timestamp to avoid timezone issues + context_timestamp = fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ) + + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_success = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_success" + ) + if notification_type_success: + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Jet") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + # Send success notification + self.env.user.notify_success( + message=_( + "%(timestamp)s
    " "Available in the '%(name)s' state", + name=self.state_id.name if self.state_id else _("Undefined"), + timestamp=context_timestamp, + ), + title=self.name, + sticky=notification_type_success == "sticky", + action=action, + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Status and busyness + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _accepts_new_links(self): + """ + Check if the jet is available to accept new links from other jets. + + Returns: + bool: True if the jet is available to accept new links from other jets, + False otherwise + """ + self.ensure_one() + # TODO: Implement the logic to check if the jet is available + # to accept new links from other jets + return True + + def _is_busy(self): + """ + Check if the jet is busy with some other action. + Overwrite this function to implement custom logic. + + Returns: + bool: True if the jet is busy with some other action, + False otherwise + """ + self.ensure_one() + + # Jet is considered busy if it is currently transitioning to another state + busy = bool(self.target_state_id) + return busy + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Manage dependencies + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def _control_dependencies(self): + """ + Check if dependencies are satisfied. + If some dependencies are missing, it creates a new jet request to ensure + that a jet that is required by that dependency is available. + + Returns: + bool: True if all dependencies are satisfied, False otherwise + """ + self.ensure_one() + + all_dependencies_satisfied = True + + jet_request_obj = self.env["cx.tower.jet.request"] + + # Check if jets are present in the required state + for jet_dependency in self.jet_requires_ids: + jet_template_dependency = jet_dependency.jet_template_dependency_id + if ( + jet_dependency.jet_depends_on_id + and jet_dependency.jet_depends_on_id.state_id + == jet_template_dependency.state_required_id + ): + # The dependency is satisfied, continue to the next dependency + continue + + # Create a new jet request to ensure we have the required jet + # in the required state + jet_request_obj._create_request( + server=self.server_id, + jet_template=jet_template_dependency.template_required_id, + state=jet_template_dependency.state_required_id, + requested_by_jet=self, + for_dependency=jet_dependency, + ) + # Stop here as it will be resumed when the jet request is finalized + all_dependencies_satisfied = False + break + + return all_dependencies_satisfied + + def _get_dependent_jets_by_template(self, jet_template): + """ + Check all dependencies of the jet and returns all jets + of the given template. + Both dependent and this jet depends on jets are returned. + + Args: + jet_template (cx.tower.jet.template()): The jet template + + Returns: + cx.tower.jet(): Recordset of jets + """ + self.ensure_one() + + # Check L1 jets this jet depends on + l1_jets = self.jet_requires_ids.filtered( + lambda r: r.jet_depends_on_id.jet_template_id == jet_template + ).jet_depends_on_id + # Check L1 jets that depend on this jet + l2_jets = self.jet_required_by_ids.filtered( + lambda r: r.jet_id.jet_template_id == jet_template + ).jet_id + + # TODO: check the entire dependency tree + return l1_jets | l2_jets + + def get_dependent_jets_by_template_reference(self, jet_template_reference): + """ + A wrapper for _get_dependent_jets_by_template that allows + to use the reference of the jet template instead of the record. + Designed to be used in the Python commands. + + Args: + jet_template_reference (str): The reference of the jet template + + Returns: + cx.tower.jet(): Recordset of jets with the given template + that depend on the current jet. + """ + self.ensure_one() + + jet_template = self.jet_template_id.get_by_reference(jet_template_reference) + if jet_template: + return self._get_dependent_jets_by_template(jet_template) + return False + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Access role mixin functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _get_post_create_fields(self): + """ + Add fields that should be populated after jet template creation + """ + res = super()._get_post_create_fields() + return res + ["variable_value_ids", "server_log_ids"] diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_action.py b/addons/cetmix_tower_server/models/cx_tower_jet_action.py new file mode 100644 index 0000000..6f4d991 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_action.py @@ -0,0 +1,100 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerJetAction(models.Model): + """Jet Actions represent transitions between states in a jet's lifecycle""" + + _name = "cx.tower.jet.action" + _description = "Cetmix Tower Jet Action" + _inherit = ["cx.tower.reference.mixin", "cx.tower.access.mixin"] + _order = "priority, id" + + active = fields.Boolean(related="jet_template_id.active", readonly=True) + priority = fields.Integer(default=10, required=True) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + string="Jet Template", + help="Jet template that this action belongs to", + ondelete="cascade", + ) + color = fields.Integer(related="state_to_id.color", readonly=True) + note = fields.Text() + + # -- State Transitions + state_from_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="From State", + help="Source state for this transition. Leave blank for an initial state", + ondelete="restrict", + ) + + state_transit_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="Transit State", + required=True, + help="Intermediate state during the transition", + ondelete="restrict", + ) + + state_to_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="To State", + help="Destination state for this transition. Leave blank for a final state", + ondelete="restrict", + ) + + state_error_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="Error State", + help="State to transition to if an error occurs", + ondelete="restrict", + ) + + plan_id = fields.Many2one( + string="Flight Plan", + comodel_name="cx.tower.plan", + help="Flight plan to execute when this action is triggered", + ) + + # TODO: ensure that all actions belong to the same jet template + + def trigger(self, jet=None): + """Trigger jet action on a given jet. + If jet is not provided, the action will be triggered on the jet + in the context key "jet_id". + + Args: + jet (cx.tower.jet): Jet to trigger the action. + """ + self.ensure_one() + + # Try to obtain jet from context if not provided as an argument + if jet is None: + jet_id = self.env.context.get("jet_id") + + # Just return, no exceptions for now + if not jet_id: + return + + jet = self.env["cx.tower.jet"].browse(jet_id) + + # Ensure that the action is for a single jet + if not jet or len(jet) > 1: + raise ValidationError(_("Action can be triggered only for a single jet")) + + # Trigger the action + jet._trigger_action(self) + + # ------------------------------ + # Reference mixin methods + # ------------------------------ + def _get_pre_populated_model_data(self): + res = super()._get_pre_populated_model_data() + res.update( + {"cx.tower.jet.action": ["cx.tower.jet.template", "jet_template_id"]} + ) + return res diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_dependency.py b/addons/cetmix_tower_server/models/cx_tower_jet_dependency.py new file mode 100644 index 0000000..4775135 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_dependency.py @@ -0,0 +1,63 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerJetDependency(models.Model): + """Model to manage dependent Jets""" + + _name = "cx.tower.jet.dependency" + _description = "Cetmix Tower Jet Dependency" + _log_access = False + + jet_template_dependency_id = fields.Many2one( + comodel_name="cx.tower.jet.template.dependency", + string="Jet Template Dependency", + index=True, + help="Related jet template dependency. " + "Used to track dependency changes at the template level.", + ondelete="cascade", + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + string="Jet", + required=True, + index=True, + help="Jet this dependency belongs to", + ondelete="cascade", + ) + jet_depends_on_id = fields.Many2one( + comodel_name="cx.tower.jet", + string="Depends On", + index=True, + help="Jet this Jet depends on.", + ondelete="cascade", + ) + + _sql_constraints = [ + ( + "unique_jet_dependency", + "UNIQUE(jet_id, jet_depends_on_id)", + "This dependency already exists!", + ) + ] + + @api.constrains("jet_id", "jet_depends_on_id", "jet_template_dependency_id") + def _check_self_dependency(self): + for record in self: + # Ensure jet dependency is not a self-dependency + if record.jet_id == record.jet_depends_on_id: + raise ValidationError(_("A jet cannot depend on itself!")) + # Ensure jet that we depend on has the template + # from the template dependency + if ( + record.jet_depends_on_id + and record.jet_template_dependency_id + and record.jet_depends_on_id.jet_template_id + != record.jet_template_dependency_id.template_required_id + ): + raise ValidationError( + _("A jet cannot depend on a jet with a different template!") + ) diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_request.py b/addons/cetmix_tower_server/models/cx_tower_jet_request.py new file mode 100644 index 0000000..6228097 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_request.py @@ -0,0 +1,260 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class CxTowerJetRequest(models.Model): + """ + Requests for jets. Issued when there is a jet needed in a specific + state on a server. + + Eg. jet "Application" needs a jet "Database" to be in state "Running" + to be able to start. + It looks for an existing jet in the required state and if not found, + creates a jet request. + + During the request processing, Tower will try to find and existing jet and + bring it to the required state. Or create a new one if not found. + + When a request is finalized, it will report the result to the request issuer + using the callback function. + + """ + + _name = "cx.tower.jet.request" + _description = "Cetmix Tower Jet Request" + + server_id = fields.Many2one( + comodel_name="cx.tower.server", + required=True, + ondelete="cascade", + copy=False, + help="Server where the jet is requested", + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + ondelete="cascade", + string="Serviced by Jet", + copy=False, + help="Jet that is requested", + ) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + required=True, + string="Requested Template", + ondelete="cascade", + copy=False, + help="Template of the jet that is requested. " + "Used to create a new jet if not found.", + ) + state_requested_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + ondelete="cascade", + copy=False, + help="State of the jet that is requested", + ) + requested_by_jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + ondelete="cascade", + string="Requested by Jet", + copy=False, + help="Jet that is requesting the jet", + ) + for_dependency_id = fields.Many2one( + comodel_name="cx.tower.jet.dependency", + ondelete="cascade", + copy=False, + help="Dependency for which request is created", + ) + state = fields.Selection( + selection=[ + ("new", "New"), + ("processing", "Processing"), + ("success", "Success"), + ("failed", "Failed"), + ], + default="new", + required=True, + copy=False, + ) + + @api.model + def _create_request( + self, + server, + jet=None, + jet_template=None, + state=None, + requested_by_jet=None, + for_dependency=None, + ): + """ + Create a new jet request. + + Args: + server (cx.tower.server()): Server to create the request on + jet (cx.tower.jet()): Jet to create the request for + jet_template (cx.tower.jet.template()): Template to create the request for + state (cx.tower.jet.state()): State to create the request for + requested_by_jet (cx.tower.jet()): Jet that is requesting the jet + for_dependency (cx.tower.jet.dependency()): Dependency for which request + is created + + Returns: + cx.tower.jet.request(): A jet request for the jet + """ + + # Must have either jet or jet template + if not jet and not jet_template: + raise ValidationError( + _("Either a jet or a jet template must be provided to create a request") + ) + + # Set jet template from the jet if not provided + if not jet_template and jet: + jet.ensure_one() + jet_template = jet.jet_template_id + + request = self.env["cx.tower.jet.request"].create( + { + "server_id": server.id, + "jet_id": jet.id if jet else None, + "jet_template_id": jet_template.id if jet_template else None, + "state_requested_id": state.id if state else None, + "requested_by_jet_id": requested_by_jet.id + if requested_by_jet + else None, + "for_dependency_id": for_dependency.id if for_dependency else None, + } + ) + + # Step 1. Use the existing jet if provided explicitly + if jet: + if jet.server_id != server: + raise ValidationError( + _( + "Jet %(jet)s is not on server %(server)s", + jet=jet.name, + server=server.name, + ) + ) + if jet.state_id == state and not jet._is_busy(): + _logger.info( + "Jet %s is available and not busy, finalizing request", jet.name + ) + request._finalize(failed=False) + elif jet.target_state_id == state: + _logger.info( + "Jet %s is transitioning to the target state, " + "waiting for it to finish", + jet.name, + ) + jet._serve_jet_request(jet_request=request) + else: + _logger.info( + "Jet %s is not available or busy, triggering jet to " + "bring itself to the required state", + jet.name, + ) + jet._serve_jet_request(jet_request=request) + return request + + # Step 2. Try to pick any of the existing jets from the template + available_jets = jet_template.jet_ids.filtered( + lambda j: j.server_id == server and j._accepts_new_links() + ) + for available_jet in available_jets: + # Finalize the request instantly if the jet state + # matches and jet is not busy + if available_jet.state_id == state and not available_jet._is_busy(): + _logger.info( + "Jet %s is available and not busy, finalizing request", + available_jet.name, + ) + request.jet_id = available_jet + request._finalize(failed=False) + return request + + # Step 3. Jet is available, and is not busy, but not in the required state + transitioning_jets = available_jets.filtered( + lambda j: j.target_state_id == state + ) + if transitioning_jets: + _logger.info( + "Jet %s is transitioning to the target state, " + "waiting for it to finish", + transitioning_jets[0].name, + ) + # Trigger the jet to bring itself to the required state + request.jet_id = transitioning_jets[0] + return request + + # Step 4. Jet is available, and is not busy, but not in the required state + not_busy_jets = available_jets.filtered(lambda j: not j._is_busy()) + if not_busy_jets: + # Pick the first available jet + not_busy_jet = not_busy_jets[0] + _logger.info( + "Jet %s is available and not busy, but not in the required state," + " triggering jet to bring itself to the required state", + not_busy_jet.name, + ) + # Trigger the jet to bring itself to the required state + request.jet_id = not_busy_jet + not_busy_jet._serve_jet_request(jet_request=request) + return request + + # Step 5. Jet is not available, or is busy and not transitioning + # to the required state - create a new jet + # TODO: Add an option to wait for the jet to become available + if jet_template: + jet_template.ensure_one() + _logger.info("Creating new jet using template %s", jet_template.name) + jet = jet_template.create_jet(server) + if jet: + _logger.info("Created new jet %s", jet.name) + request.jet_id = jet + if jet.state_id == state: + request._finalize(failed=False) + else: + # Trigger the jet to bring itself to the required state + jet._serve_jet_request(jet_request=request) + else: + _logger.error( + "Failed to create new jet using template %s", jet_template.name + ) + request._finalize(failed=True) + + _logger.info("Jet request creation finished") + return request + + def _finalize(self, failed=False): + """ + Finalize a jet request. + + Args: + failed (bool): Whether the request failed + """ + self.ensure_one() + + # 1. Update the state of the request + self.write( + { + "state": "success" if not failed else "failed", + } + ) + + # 2. Notify the jet that issued the request + if self.requested_by_jet_id: + self.requested_by_jet_id._finalize_jet_request(self) + + # 3. Remove the link to the jet that was handling the request + if self.jet_id and self.jet_id.served_jet_request_id == self: + # Unlink the jet from the request + self.jet_id.sudo().write({"served_jet_request_id": False}) diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_state.py b/addons/cetmix_tower_server/models/cx_tower_jet_state.py new file mode 100644 index 0000000..5eff637 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_state.py @@ -0,0 +1,90 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import AccessError, ValidationError + + +class CxTowerJetState(models.Model): + """Jet States represent the different states a jet can be in during its lifecycle""" + + _name = "cx.tower.jet.state" + _description = "Cetmix Tower Jet State" + _inherit = ["cx.tower.reference.mixin", "cx.tower.access.mixin"] + _order = "sequence, id" + + sequence = fields.Integer(default=10, required=True) + active = fields.Boolean(default=True) + color = fields.Integer() + note = fields.Text() + + # Set default access level to User + access_level = fields.Selection(default="1") + + def unlink(self): + """ + Do not allow to unlink a state + if it is used in any action + """ + actions = self.env["cx.tower.jet.action"].search( + [ + "|", + "|", + ("state_from_id", "in", self.ids), + ("state_to_id", "in", self.ids), + ("state_transit_id", "in", self.ids), + ] + ) + if actions: + raise ValidationError( + _( + "Some states are still used in the following actions: %(actions)s" + "\nJet templates: %(templates)s", + actions=", ".join(set(actions.mapped("name"))), + templates=", ".join(set(actions.mapped("jet_template_id.name"))), + ) + ) + return super().unlink() + + def set_state(self, jet=None): + """Sets the state of the jet + + Args: + jet (cx.tower.jet): Jet to set the state. + """ + self.ensure_one() + + # Try to obtain jet from context if not provided as an argument + if jet is None: + jet_id = self.env.context.get("jet_id") + + # Just return, no exceptions for now + if not jet_id: + return + + jet = self.env["cx.tower.jet"].browse(jet_id) + + # Ensure that the state is set for a single jet + if not jet or len(jet) > 1: + raise ValidationError(_("State can be set only for a single jet")) + + # Check access to the jet + jet.check_access("read") + + # Get user access level + user_access_level = self.env.user._cetmix_tower_access_level() + + # If user is manager but is not added as a manager to the jet, + # his access level is considered as user. + # NB: record access is already checked above. + if user_access_level == "2" and self.env.user not in jet.manager_ids: + user_access_level = "1" + + # Check if user access level is equal or greater + if self.access_level > user_access_level: + raise AccessError( + _("You are not allowed to set the '%(state)s' state!", state=self.name) + ) + + # Bring the jet to the state + jet._bring_to_state(self) diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_template.py b/addons/cetmix_tower_server/models/cx_tower_jet_template.py new file mode 100644 index 0000000..3f740c3 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_template.py @@ -0,0 +1,1446 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import ast +import base64 +import heapq +import logging +import xml.etree.ElementTree as ET +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from .tools import generate_random_id, is_valid_url + +_logger = logging.getLogger(__name__) + + +# Maximum number of retries to generate a unique jet name +# Used to prevent infinite loop +MAX_JET_NAME_RETRIES = 50 + + +class CxTowerJetTemplate(models.Model): + """Jet Templates are templates to create and manage jets""" + + _name = "cx.tower.jet.template" + _description = "Cetmix Tower Jet Template" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.access.mixin", + "cx.tower.access.role.mixin", + "cx.tower.variable.mixin", + "mail.thread", + "cx.tower.tag.mixin", + ] + _order = "name asc" + _mail_post_access = "read" + + active = fields.Boolean(default=True) + icon = fields.Image( + string="Icon image", + max_width=128, + max_height=128, + help="Icon of the related product to make navigation easier. " + "E.g. Docker logo for the Docker jet template.", + ) + note = fields.Text() + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_jet_template_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_jet_template_manager_rel", + ) + + # Jets + jet_ids = fields.One2many( + comodel_name="cx.tower.jet", + inverse_name="jet_template_id", + string="Jets", + copy=False, + ) + jet_count = fields.Integer(compute="_compute_jet_count", store=False) + + # Servers + server_ids = fields.Many2many( + comodel_name="cx.tower.server", + relation="cx_tower_jet_template_server_rel", + column1="jet_template_id", + column2="server_id", + string="Installed on Servers", + readonly=True, + help="These servers have this jet template installed", + copy=False, + ) + limit_per_server = fields.Integer( + string="Limit per Server", + help="Maximum number of Jets that can be launched on a server. " + "Set to 0 for no limit.", + ) + file_ids = fields.One2many( + comodel_name="cx.tower.file", + inverse_name="jet_template_id", + string="Files", + help="Files of this jet template", + copy=False, + ) + + # Wizards + show_in_create_wizard = fields.Boolean( + string="Show in Wizard", + help="If enabled, the template will be shown " + "in the wizard to create a new jet", + ) + + # Flight Plans + plan_install_id = fields.Many2one( + comodel_name="cx.tower.plan", + string="Installation Flight Plan", + help="Flight plan used to install the template from a server", + ) + plan_uninstall_id = fields.Many2one( + comodel_name="cx.tower.plan", + string="Uninstallation Flight Plan", + help="Flight plan used to uninstall the template from a server", + ) + plan_clone_same_server_id = fields.Many2one( + comodel_name="cx.tower.plan", + help="Flight plan used to clone the jet on the same server", + ) + plan_clone_different_server_id = fields.Many2one( + comodel_name="cx.tower.plan", + help="Flight plan used to clone the jet to a different server", + ) + + # Logs + command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", + inverse_name="jet_template_id", + copy=False, + ) + plan_log_ids = fields.One2many( + comodel_name="cx.tower.plan.log", + inverse_name="jet_template_id", + copy=False, + ) + + # Server logs + server_log_ids = fields.One2many( + comodel_name="cx.tower.server.log", + inverse_name="jet_template_id", + copy=True, + ) + # Scheduled Tasks + scheduled_task_ids = fields.Many2many( + comodel_name="cx.tower.scheduled.task", + relation="cx_tower_jet_template_scheduled_task_rel", + column1="jet_template_id", + column2="scheduled_task_id", + string="Scheduled Tasks", + copy=True, + ) + + # Configuration variables + variable_value_ids = fields.One2many( + inverse_name="jet_template_id", + copy=True, + ) + + # Actions + action_ids = fields.One2many( + comodel_name="cx.tower.jet.action", + inverse_name="jet_template_id", + string="Lifecycle Actions", + copy=True, + ) + action_create_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + string="Create Jet", + help="The action is used to create a new Jet", + compute="_compute_border_actions", + readonly=False, + store=True, + domain="[('state_from_id', '=', False), " + "('state_to_id', '!=', False)," + " ('jet_template_id', '=', id)]", + copy=False, + ) + action_destroy_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + string="Destroy Jet", + compute="_compute_border_actions", + readonly=False, + store=True, + help="The action is used to destroy a Jet", + domain="[('state_to_id', '=', False), ('jet_template_id', '=', id)]", + copy=False, + ) + + # Dependencies + template_requires_ids = fields.One2many( + comodel_name="cx.tower.jet.template.dependency", + inverse_name="template_id", + string="Requires", + help="Define other templates that must be in specific" + " states for this template to function", + copy=True, + groups="cetmix_tower_server.group_manager", + ) + template_required_by_ids = fields.One2many( + comodel_name="cx.tower.jet.template.dependency", + inverse_name="template_required_id", + string="Required by", + help="Define other templates that require this template" + " to be in a specific" + " state to function", + groups="cetmix_tower_server.group_manager", + ) + + # Installation + install_ids = fields.One2many( + comodel_name="cx.tower.jet.template.install.line", + inverse_name="jet_template_id", + string="Installations", + help="Installations of the template", + auto_join=True, + copy=False, + groups="cetmix_tower_server.group_manager", + readonly=True, + ) + + # Waypoints + waypoint_template_ids = fields.One2many( + comodel_name="cx.tower.jet.waypoint.template", + inverse_name="jet_template_id", + string="Waypoints", + help="Waypoints of the template", + copy=True, + ) + + # Dependency Graph + # Odoo blocks SVG images in fields.Binary, + # so we use fields.Char to store the SVG content + # https://github.com/odoo/odoo/blob/c27d978ade9bcbea056933d8fb8b5a924e983bde/odoo/fields.py#L2321 + dependency_graph_svg = fields.Char( + compute="_compute_dependency_graph_svg", + store=True, + recursive=True, + copy=False, + help="SVG image content of the dependency graph of the template", + ) + dependency_graph_image = fields.Binary( + string="Dependency Graph", + compute="_compute_dependency_graph_image", + compute_sudo=True, + help="SVG image of the dependency graph of the template", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Compute functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @api.depends("jet_ids") + def _compute_jet_count(self): + """Compute the number of jets for each template.""" + for template in self: + template.jet_count = len(template.jet_ids) + + @api.depends( + "action_ids", + "action_ids.state_from_id", + "action_ids.state_to_id", + "action_ids.priority", + ) + def _compute_border_actions(self): + """Compute the 'Create Jet' and 'Destroy Jet' actions""" + for template in self: + # If no initial state, add the one automatically + if not template.action_create_id: + # Has no initial state and has a final state + suitable_actions = template.action_ids.filtered( + lambda a: not a.state_from_id and a.state_to_id + ).sorted("priority") + # Take the first one (lowest priority = highest priority) + if suitable_actions: + template.action_create_id = suitable_actions[0] + + # If "Create" action has an initial state + # or does not have a final state + # it cannot be used to create a new Jet + elif ( + template.action_create_id.state_from_id + or not template.action_create_id.state_to_id + ): + template.action_create_id = False + + if not template.action_destroy_id: + # Has no final state + suitable_actions = template.action_ids.filtered( + lambda a: not a.state_to_id + ).sorted("priority") + # Take the first one (lowest priority = highest priority) + if suitable_actions: + template.action_destroy_id = suitable_actions[0] + + # If "Destroy" action has a final state + # it cannot be used to destroy a Jet + elif template.action_destroy_id.state_to_id: + template.action_destroy_id = False + + @api.depends( + "template_requires_ids", + "template_requires_ids.state_required_id", + "template_requires_ids.template_required_id.dependency_graph_image", + ) + def _compute_dependency_graph_svg(self): + """Compute dependency graph image using SVG generation""" + for template in self: + try: + graph_data = template._build_dependency_graph() + svg_content = template._generate_svg_graph(graph_data) + template.dependency_graph_svg = svg_content + except Exception as e: + _logger.error( + f"Error generating dependency graph " + f"for template {template.name}: {e}" + ) + template.dependency_graph_svg = False + + @api.depends("dependency_graph_svg") + def _compute_dependency_graph_image(self): + for template in self: + template.dependency_graph_image = template.dependency_graph_svg + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # ORM methods + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def unlink(self): + """ + Unlink all related files + """ + + # Don't allow to unlink a template if it has any jets + # or is installed on any server + templates_with_jets = self.filtered(lambda t: t.jet_ids) + if templates_with_jets: + raise ValidationError( + _( + "Following templates cannot be deleted " + "as they still have jets: %s", + templates_with_jets.mapped("display_name"), + ) + ) + templates_with_installed_servers = self.filtered(lambda t: t.server_ids) + if templates_with_installed_servers: + raise ValidationError( + _( + "Following templates cannot be deleted " + "as they are installed on servers: %s", + ",".join(templates_with_installed_servers.mapped("display_name")), + ) + ) + + files = self.file_ids + res = super().unlink() + + # Unlink files only after the records are deleted + # This is done to avoid deleting the files while + # the 'unlink' method fails due to some reason. + if files: + files.unlink() + return res + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Odoo Actions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def action_install_on_servers(self): + """Action to install the Jet Template on the selected servers.""" + self.ensure_one() + # Open the wizard to install the template on the selected servers + return { + "type": "ir.actions.act_window", + "name": _("Install on Servers"), + "res_model": "cx.tower.jet.template.install.wiz", + "view_mode": "form", + "target": "new", + "context": { + "default_jet_template_id": self.id, + }, + } + + def action_uninstall_from_server(self, server=None): + """Action to uninstall the Jet Template from the selected servers.""" + self.ensure_one() + # Open the wizard to uninstall the template from the selected servers + if not server: + server_id = self.env.context.get("server_id") + server = self.env["cx.tower.server"].browse(server_id) + if not server: + raise ValidationError(_("No server selected")) + return self.uninstall_from_servers(servers=server) + + def action_open_command_logs(self): + """ + Open current server command log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + action["domain"] = [("jet_template_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_plan_logs(self): + """ + Open current server flightplan log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + action["domain"] = [("jet_template_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_files(self): + """ + Open files of the current server + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_action" + ) + action["domain"] = [("jet_template_id", "=", self.id)] # pylint: disable=no-member + + context = self._context.copy() + if "context" in action and isinstance((action["context"]), str): + context.update(ast.literal_eval(action["context"])) + else: + context.update(action.get("context", {})) + + context.update( + { + "default_jet_template_id": self.id, # pylint: disable=no-member + "search_default_group_by_jet_id": 1, + } + ) + action["context"] = context + return action + + def action_open_jets(self): + """ + Open jets of the current jet template + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + context = self._context.copy() + if "context" in action and isinstance((action["context"]), str): + context.update(ast.literal_eval(action["context"])) + else: + context.update(action.get("context", {})) + + context.update( + { + "default_jet_template_id": self.id, # pylint: disable=no-member + "group_by": "server_id", + } + ) + action["domain"] = [("jet_template_id", "=", self.id)] # pylint: disable=no-member + action["context"] = context + return action + + def action_new_jet(self): + """ + Returns wizard action to launch a jet + """ + context = self.env.context.copy() + context.update( + { + "default_jet_template_id": self.id + if self.show_in_create_wizard + else False, + } + ) + return { + "type": "ir.actions.act_window", + "name": _("Launch New Jet"), + "res_model": "cx.tower.jet.create.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # General functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def get_variable_value(self, variable_reference, no_fallback=False): + """ + Return the value of a variable for the current jet. + NB: this function follows the value application order. + Jet Template->Server->Global + Args: + variable_reference (Char): The reference of the variable + to get the value for + no_fallback (bool): If True, will return current record value + without checking fallback values. + + + Returns: + str: The value of the variable for the current record or None + """ + self.ensure_one() + if no_fallback: + return super().get_variable_value(variable_reference, no_fallback) + variable = self.env["cx.tower.variable"].get_by_reference(variable_reference) + if not variable: + return None + values = variable._get_variable_values_by_references( + variable_references=[variable_reference], jet_template=self + ) + return values[variable_reference] + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Template Actions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def _get_action_path(self, state_from=None, state_to=None): + """Return the order of actions that lead from one state to another. + If the initial state is not provided, must start with "Create Action". + If the final state is not provided, must end with "Destroy Action". + + Args: + state_from (cx.tower.jet.state()): State to start from + state_to (cx.tower.jet.state()): State to end at + + Returns: + list: List of actions that lead from one state to another + """ + self.ensure_one() + + original_state_to = state_to + path = [] + + create_action = self.action_create_id if self.action_create_id else False + destroy_action = self.action_destroy_id if self.action_destroy_id else False + + if not state_from: + if not create_action: + return [] + path.append(create_action) + state_from = create_action.state_to_id + + if not state_to: + if not destroy_action: + return [] + state_to = destroy_action.state_from_id + + if state_from == state_to: + if not original_state_to and destroy_action: + return path + [destroy_action] + return path + + adjacency = self._get_action_adjacency() + state_path = self._find_action_path_bfs(state_from, state_to, adjacency) + if state_path is not None: + result_path = path + state_path + if not original_state_to and destroy_action: + result_path.append(destroy_action) + return result_path + + if ( + not original_state_to + and destroy_action + and state_from == destroy_action.state_from_id + ): + return path + [destroy_action] + + return [] + + def _get_action_adjacency(self): + """Build adjacency list for state transitions.""" + adjacency = {} + for action in self.action_ids: + if action.state_from_id and action.state_to_id: + if action.state_from_id not in adjacency: + adjacency[action.state_from_id] = [] + adjacency[action.state_from_id].append((action.state_to_id, action)) + return adjacency + + def _find_action_path_bfs(self, state_from, state_to, adjacency): + """Find the shortest path of actions from state_from to state_to + using BFS. + + Args: + state_from (cx.tower.jet.state()): State to start from + state_to (cx.tower.jet.state()): State to end at + adjacency (dict): Adjacency list for state transitions + """ + queue = [(state_from, [])] + visited = {state_from} + while queue: + current_state, state_path = queue.pop(0) + if current_state not in adjacency: + continue + for next_state, action in adjacency[current_state]: + if next_state == state_to: + return state_path + [action] + if next_state not in visited: + visited.add(next_state) + queue.append((next_state, state_path + [action])) + return None + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Install/Uninstall + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def _is_installation_needed(self, server): + """Check if installation is needed for the given server. + + Args: + server: Server to check + + Returns: + bool: False if server is already installed or being installed, + True otherwise + """ + # Check if template is already installed on the server + if server.id in self.server_ids.ids: + return False + + # Check if template is already being installed on the server + if ( + server.id + in self.install_ids.filtered( + lambda install: install.jet_template_install_id.state == "processing" + ).server_id.ids + ): + return False + + return True + + def install_on_servers(self, servers): + """Install the Jet Template on the selected servers. + + Args: + servers (cx.tower.server()): Servers to install the Jet Template on + """ + self.ensure_one() + + template_install_obj = self.env["cx.tower.jet.template.install"] + now = fields.Datetime.now() + context_timestamp = fields.Datetime.to_string(now) + + for server in servers: + # Check if installation is needed for this server + if not self._is_installation_needed(server): + _logger.info( + "Template '%s' is already installed or being installed" + " on the server '%s'", + self.name, # pylint: disable=no-member + server.name, + ) + # Notify the user + self.env.user.notify_info( + title=self.name, # pylint: disable=no-member + message=_( + "%(timestamp)s
    Template is already installed " + "or being installed" + " on the server '%(server_name)s'", + timestamp=context_timestamp, + server_name=server.name, + ), + ) + continue + + template_install_obj.install( + template=self, + server=server, + ) + + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet.template", rec_ids=[self.id]) + + def uninstall_from_servers(self, servers, raise_if_not_possible=True): + """Uninstall the Jet Template from the selected servers. + + Args: + servers (cx.tower.server()): Servers to uninstall the Jet Template from + raise_if_not_possible (bool): + If True, will raise an error if the uninstallation is not possible. + """ + self.ensure_one() + template_install_obj = self.env["cx.tower.jet.template.install"] + + for server in servers: + # Check if installation is possible for this server + warning_message = None + # Template is not installed on the server + if server.id not in self.server_ids.ids: + warning_message = _( + "Template '%(template_name)s' is not installed " + "on the server '%(server_name)s'", + template_name=self.name, # pylint: disable=no-member + server_name=server.name, + ) + # There are still jets on the server + elif server.jet_ids.filtered(lambda jet: jet.jet_template_id == self): + warning_message = _( + "There are still jets of template '%(template_name)s' " + "on the server '%(server_name)s'", + template_name=self.name, # pylint: disable=no-member + server_name=server.name, + ) + # There are other templates that depend on this template + # installed on the server + elif server.jet_template_ids.filtered( + lambda template: template.template_requires_ids.filtered( + lambda dependency: dependency.template_required_id == self + ) + ): + warning_message = _( + "There are other templates that depend " + "on template '%(template_name)s' " + "that are installed on the server '%(server_name)s'", + template_name=self.name, # pylint: disable=no-member + server_name=server.name, + ) + + if warning_message: + if raise_if_not_possible: + raise ValidationError(warning_message) + self.env.user.notify_warning( + message=warning_message, + title=self.name, # pylint: disable=no-member + ) + continue + + template_install_obj.uninstall( + template=self, + server=server, + ) + + def _get_system_variable_value(self, variable_reference): + """Return the jet template variable values + + Args: + variable_reference (Char): variable value + + Returns: + dict(): populates `tower` variable with with values. + { + 'jet_template': {..jet template vals..}, + }. + """ + + # This works for a single record only! + self.ensure_one() + + variable_value = {} + if variable_reference == "tower": + variable_value.update( + { + "jet_template": { + "name": self.name, # pylint: disable=no-member + "reference": self.reference, # pylint: disable=no-member + }, + } + ) + return variable_value + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Jet creation + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def create_jet(self, server, name=None, state=None, **kwargs): + """ + Create a new jet from this template on the given server. + + Args: + server (cx.tower.server()): The server to use + name (str): The name of the jet. + If not provided, a random name will be generated. + Defaults to None. + state (cx.tower.jet.state()): The state to set the jet to. + If not provided, the jet will be created in the initial state. + Defaults to None. + Kwargs: + field values to populate in the new jet record. + NB: configuration variables are provided as follows: + variable_values (dict): Custom configuration variables + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Returns: + cx.tower.jet(): The new jet or False if the creation has failed + """ + self.ensure_one() + + # Check if the jet creation is allowed on the given server + if not self._allow_jet_creation(server): + return False + + # Prepare the jet values + vals = self._prepare_jet_values(server, name, **kwargs) + + # Create a new jet + jet = self.env["cx.tower.jet"].create(vals) + + # Set the state of the jet + if state: + jet._bring_to_state(state) + + return jet + + def _prepare_jet_values(self, server, name=None, **kwargs): + """ + Prepare the jet values to create a new jet based + on the given server and template. + + Args: + server (cx.tower.server()): The server to create the jet on + **kwargs: Additional values to update in the final jet record. + """ + self.ensure_one() + + # Check if the URL is valid + url = kwargs.pop("url", None) + if url and not is_valid_url(url, no_scheme_check=True): + raise ValidationError( + _( + "Invalid URL: '%(url)s'. URL must contain a protocol and " + "a proper domain or IP, eg 'https://my_tower_jet.example.com'", + url=url, + ) + ) + + # If no name is provided, generate a random one + if not name: + name = self._generate_jet_name() + + # Check if the same name already exists on the server + # Keep generating a new name until a unique one is found + jet_obj = self.env["cx.tower.jet"] + # Pre-fetch existing names for this server + existing_names = set( + jet_obj.search([("server_id", "=", server.id)]).mapped("name") + ) + + for _attempt in range(MAX_JET_NAME_RETRIES): + if name not in existing_names: + break + name = self._generate_jet_name() + else: + # Loop exhausted without finding unique name + raise ValidationError( + _( + "Failed to generate unique jet name after %(attempts)d attempts", + attempts=MAX_JET_NAME_RETRIES, + ) + ) + + # Prepare the Jet values + vals = { + "name": name, + "jet_template_id": self.id, # pylint: disable=no-member + "server_id": server.id, + "url": url, + } + + # Parse specific fields from kwargs + if kwargs: + # Parse configuration variables + configuration_variables = kwargs.pop("variable_values", {}) + if configuration_variables: + variable_obj = self.env["cx.tower.variable"] + variable_values = [] + for ( + variable_reference, + variable_value, + ) in configuration_variables.items(): + variable = variable_obj.get_by_reference(variable_reference) + if variable: + variable_values.append( + ( + 0, + 0, + { + "variable_id": variable.id, + "value_char": variable_value, + }, + ) + ) + continue + _logger.warning( + "Variable reference '%s' not found while creating jet '%s'", + variable_reference, + self.name, # pylint: disable=no-member + ) + + if variable_values: + vals.update( + { + "variable_value_ids": variable_values, + } + ) + + # Populate the allowed fields + for field in self._allowed_jet_fields(): + if field in kwargs: + vals[field] = kwargs.pop(field) + + return vals + + def _allowed_jet_fields(self): + """Return the allowed fields for the jet creation""" + self.ensure_one() + return [ + "name", + "reference", + "sequence", + "tag_ids", + "partner_id", + "jet_cloned_from_id", + "scheduled_task_ids", + "server_log_ids", + ] + + def _allow_jet_creation(self, server): + """ + Check if the jet creation is allowed on the given server. + This function can be extended to check for other conditions. + Eg if jet capacity is reached for the server. + Or server template has a certain limit on the number of jets per server. + + Args: + server (cx.tower.server()): The server to check + + Returns: + bool: True if the jet creation is allowed, False otherwise + """ + self.ensure_one() + return True + + def _generate_jet_name(self): + """Generate a unique name for a jet""" + self.ensure_one() + return ( + f"{self.name} " + f"[{generate_random_id(sections=2, population=4, separator='-')}]" + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Dependency Graph + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def _build_dependency_graph(self): + """Build a dependency graph of all templates this template depends on + + Returns: + dict: A dictionary representing the dependency graph where: + - Keys are template IDs + - Values are dictionaries containing template info + and dependencies + """ + self.ensure_one() + + graph = {} + visited = set() + + # Use a stack to process templates iteratively instead of recursion + stack = [self] + + while stack: + template = stack.pop() + + # Skip if already visited + if template.id in visited: + continue + + # Mark as visited + visited.add(template.id) + + # Add current template to graph + graph[template.id] = { + "template": template, + "name": template.name, + "reference": template.reference, + "dependencies": [], + "level": 0, # Will be calculated later + } + + # Add dependencies + for dependency in template.template_requires_ids: + required_template = dependency.template_required_id + + # Add dependency info + dep_info = { + "template_id": required_template.id, + "template_name": required_template.name, + "template_reference": required_template.reference, + "required_state_id": dependency.state_required_id.id + if dependency.state_required_id + else None, + "required_state_name": dependency.state_required_id.name + if dependency.state_required_id + else None, + } + + graph[template.id]["dependencies"].append(dep_info) + + # Add required template to stack if not yet visited + if required_template.id not in visited: + stack.append(required_template) + + # Calculate dependency levels (distance from root template) + self._calculate_dependency_levels(graph) + + return graph + + def _calculate_dependency_levels(self, graph): + """Calculate the dependency level for each template in the graph + + Args: + graph (dict): The dependency graph to update with levels + """ + # Start with the root template (current template) at level 0 + queue = [(self.id, 0)] + levels = {self.id: 0} + + while queue: + template_id, level = queue.pop(0) + + if template_id not in graph: + continue + + # Update the level in the graph + graph[template_id]["level"] = level + + # Process dependencies + for dep in graph[template_id]["dependencies"]: + dep_template_id = dep["template_id"] + new_level = level + 1 + + # Only update if we haven't seen this template + # or found a shorter path + if dep_template_id not in levels or levels[dep_template_id] > new_level: + levels[dep_template_id] = new_level + queue.append((dep_template_id, new_level)) + + def _topological_sort_dependency_graph(self, graph): + """Topological order: prerequisite templates before dependents. + + For each edge ``required -> dependent`` (``dependent`` lists ``required`` + in ``template_requires_ids``), ``required`` appears earlier in the result. + + Tie-break: smallest template id first (deterministic). + + Args: + graph (dict): Output of :meth:`_build_dependency_graph`. + + Returns: + list: Template ids in topological order, or empty list if the graph + has a cycle. + """ + adj = defaultdict(list) + indegree = {tid: 0 for tid in graph} + + for tid in graph: + for dep in graph[tid]["dependencies"]: + dep_id = dep["template_id"] + if dep_id not in graph: + continue + adj[dep_id].append(tid) + indegree[tid] += 1 + + heap = [tid for tid in graph if indegree[tid] == 0] + heapq.heapify(heap) + + topo = [] + while heap: + node = heapq.heappop(heap) + topo.append(node) + for succ in sorted(adj[node]): + indegree[succ] -= 1 + if indegree[succ] == 0: + heapq.heappush(heap, succ) + + if len(topo) != len(graph): + return [] + + return topo + + def _get_all_dependencies_level_fallback(self, graph): + """Fallback order when the dependency graph has a cycle: sort by level.""" + dependencies_with_levels = [] + for template_id, info in graph.items(): + if template_id != self.id: + dependencies_with_levels.append((info["template"], info["level"])) + + dependencies_with_levels.sort(key=lambda x: x[1]) + return [t for t, _level in dependencies_with_levels] + + def _get_all_dependencies(self): + """Get all templates that this template depends on (directly or indirectly). + + Order is **reverse topological** + (see :meth:`_topological_sort_dependency_graph`): + ``cx.tower.jet.template.install`` assigns increasing ``order`` and runs + tasks with highest ``order`` first, so prerequisites must appear **later** + in this list than templates that depend on them. + + Returns: + list: ``cx.tower.jet.template`` records excluding ``self``. + """ + self.ensure_one() + graph = self._build_dependency_graph() + + topo_order = self._topological_sort_dependency_graph(graph) + if not topo_order: + _logger.warning( + "Dependency cycle or invalid graph for template %s; " + "using level-based dependency order", + self.name, + ) + return self._get_all_dependencies_level_fallback(graph) + + dependencies = [] + for tid in reversed(topo_order): + if tid == self.id: + continue + dependencies.append(graph[tid]["template"]) + + return dependencies + + def _check_dependency_satisfaction(self, server): + """Check if all dependant templates are installed on the server. + + Args: + server (cx.tower.server()): Server to check dependencies for + + Returns: + list: Templates that are not installed on the server + """ + dependencies = self._get_all_dependencies() + + missing_templates = [] + + for dependency in dependencies: + if server and server.id not in dependency.server_ids.ids: + missing_templates.append(dependency) + + return missing_templates + + def _get_all_depend_on_this(self): + """Get all templates that depend on this template (directly or indirectly) + + Returns: + recordset: All templates that depend on this template + """ + self.ensure_one() + + # Find all templates that have this template as a dependency + dependent_templates = set() + + # Start with direct dependents + direct_dependents = self.env["cx.tower.jet.template"].search( + [("template_requires_ids.template_required_id", "=", self.id)] + ) + + # Use a queue to find indirect dependents + queue = list(direct_dependents) + processed = set() + + while queue: + current_template = queue.pop(0) + + if current_template.id in processed: + continue + + processed.add(current_template.id) + dependent_templates.add(current_template.id) + + # Find templates that depend on the current template + next_level_dependents = self.env["cx.tower.jet.template"].search( + [ + ( + "template_requires_ids.template_required_id", + "=", + current_template.id, + ) + ] + ) + + for template in next_level_dependents: + if template.id not in processed: + queue.append(template) + + return self.env["cx.tower.jet.template"].browse(list(dependent_templates)) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # SVG Graph Generation + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def _generate_svg_graph(self, graph_data): + """Generate SVG dependency graph + + Args: + graph_data (dict): Dictionary containing template dependency information + + Returns: + bytes: Base64 encoded SVG content + """ + width, height = 800, 600 + + # Create SVG root + svg = ET.Element( + "svg", + { + "width": str(width), + "height": str(height), + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": f"0 0 {width} {height}", + }, + ) + + # Add styles + style = ET.SubElement(svg, "style") + style.text = """ + .node { stroke: #333; stroke-width: 2; } + .edge { stroke: #666; stroke-width: 2; marker-end: url(#arrowhead); } + .text { font-family: Arial; font-size: 14px; text-anchor: middle; font-weight: bold; } + .edge-label { font-family: Arial; font-size: 12px; text-anchor: middle; fill: #444; } + .root-node { fill: lightblue; } + .direct-dep { fill: lightgreen; } + .indirect-dep { fill: lightyellow; } + """ # noqa: E501 + + # Add arrow marker + defs = ET.SubElement(svg, "defs") + marker = ET.SubElement( + defs, + "marker", + { + "id": "arrowhead", + "markerWidth": "10", + "markerHeight": "7", + "refX": "9", + "refY": "3.5", + "orient": "auto", + }, + ) + ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7", "fill": "#666"}) + + if not graph_data or len(graph_data) <= 1: + # Single node + self._add_single_node_svg(svg, width, height) + else: + # Multiple nodes - arrange in levels + self._add_multi_node_svg(svg, graph_data, width, height) + + # Convert to string and then to base64 + svg_string = ET.tostring(svg, encoding="unicode") + return base64.b64encode(svg_string.encode("utf-8")) + + def _add_single_node_svg(self, svg, width, height): + """Add a single node to the SVG for templates with no dependencies + + Args: + svg (xml.etree.ElementTree.Element): SVG root element + width (int): SVG width + height (int): SVG height + """ + node_width, node_height = 200, 60 + x = width // 2 - node_width // 2 + y = height // 2 - node_height // 2 + + # Draw node rectangle + ET.SubElement( + svg, + "rect", + { + "x": str(x), + "y": str(y), + "width": str(node_width), + "height": str(node_height), + "class": "node root-node", + "rx": "10", # Rounded corners + }, + ) + + # Add text + ET.SubElement( + svg, + "text", + {"x": str(width // 2), "y": str(height // 2 + 5), "class": "text"}, + ).text = self.name + + def _add_multi_node_svg(self, svg, graph_data, width, height): + """Add multiple nodes and edges to the SVG for complex dependency graphs + + Args: + svg (xml.etree.ElementTree.Element): SVG root element + graph_data (dict): Dictionary containing template dependency information + width (int): SVG width + height (int): SVG height + """ + # Group templates by level + levels = {} + for template_id, info in graph_data.items(): + level = info["level"] + if level not in levels: + levels[level] = [] + levels[level].append((template_id, info)) + + positions = {} + node_width = 180 + node_height = 60 + level_height = 120 + margin = 50 + + # Calculate positions for each node + for level, nodes in levels.items(): + y = margin + level * level_height + available_width = width - 2 * margin + + if len(nodes) == 1: + # Center single node + x = width // 2 + positions[nodes[0][0]] = (x, y) + else: + # Distribute multiple nodes + spacing = available_width / len(nodes) + for i, node_tuple in enumerate(nodes): + template_id = node_tuple[0] # Extract template_id from tuple + x = margin + spacing * (i + 0.5) + positions[template_id] = (x, y) + + # Draw edges first (so they appear behind nodes) + self._draw_svg_edges(svg, graph_data, positions, node_height) + + # Draw nodes + self._draw_svg_nodes(svg, graph_data, positions, node_width, node_height) + + def _draw_svg_edges(self, svg, graph_data, positions, node_height): + """Draw edges between nodes in the SVG + + Args: + svg (xml.etree.ElementTree.Element): SVG root element + graph_data (dict): Dictionary containing template dependency information + positions (dict): Dictionary mapping template IDs to (x, y) positions + node_height (int): Height of nodes for edge positioning + """ + for template_id, info in graph_data.items(): + if template_id in positions: + x1, y1 = positions[template_id] + + for dep in info["dependencies"]: + dep_id = dep["template_id"] + if dep_id in positions: + x2, y2 = positions[dep_id] + + # Draw edge line + ET.SubElement( + svg, + "line", + { + "x1": str(x1), + "y1": str(y1 + node_height // 2), + "x2": str(x2), + "y2": str(y2 - node_height // 2), + "class": "edge", + }, + ) + + # Add edge label if there's a required state + if dep["required_state_name"]: + mid_x = (x1 + x2) / 2 + mid_y = (y1 + y2) / 2 + + # Background rectangle for label + label_text = dep["required_state_name"] + label_width = len(label_text) * 8 + 10 + label_height = 20 + + ET.SubElement( + svg, + "rect", + { + "x": str(mid_x - label_width // 2), + "y": str(mid_y - label_height // 2), + "width": str(label_width), + "height": str(label_height), + "fill": "white", + "stroke": "#ccc", + "rx": "3", + }, + ) + + ET.SubElement( + svg, + "text", + { + "x": str(mid_x), + "y": str(mid_y + 4), + "class": "edge-label", + }, + ).text = label_text + + def _draw_svg_nodes(self, svg, graph_data, positions, node_width, node_height): + """Draw nodes in the SVG + + Args: + svg (xml.etree.ElementTree.Element): SVG root element + graph_data (dict): Dictionary containing template dependency information + positions (dict): Dictionary mapping template IDs to (x, y) positions + node_width (int): Width of nodes + node_height (int): Height of nodes + """ + for template_id, info in graph_data.items(): + if template_id in positions: + x, y = positions[template_id] + template_obj = info["template"] + + # Determine node class based on level + if info["level"] == 0: + node_class = "node root-node" + elif info["level"] == 1: + node_class = "node direct-dep" + else: + node_class = "node indirect-dep" + + # Draw node rectangle + ET.SubElement( + svg, + "rect", + { + "x": str(x - node_width // 2), + "y": str(y - node_height // 2), + "width": str(node_width), + "height": str(node_height), + "class": node_class, + "rx": "10", # Rounded corners + }, + ) + + # Add text (truncate if too long) + display_name = template_obj.name + if len(display_name) > 20: + display_name = display_name[:17] + "..." + + ET.SubElement( + svg, "text", {"x": str(x), "y": str(y + 5), "class": "text"} + ).text = display_name + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Access role mixin functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _get_post_create_fields(self): + """ + Add fields that should be populated after jet template creation + """ + res = super()._get_post_create_fields() + return res + ["variable_value_ids", "server_log_ids"] diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py b/addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py new file mode 100644 index 0000000..6f37dc2 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py @@ -0,0 +1,168 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerJetTemplateDependency(models.Model): + """Define dependencies between Jet templates""" + + _name = "cx.tower.jet.template.dependency" + _inherit = "cx.tower.reference.mixin" + _description = "Cetmix Tower Jet Template Dependency" + _log_access = False + + name = fields.Char(related="template_id.name", readonly=True) + template_id = fields.Many2one( + string="Jet", + comodel_name="cx.tower.jet.template", + ondelete="cascade", + required=True, + help="The Jet template that requires another template", + ) + + template_required_id = fields.Many2one( + string="Required Jet", + comodel_name="cx.tower.jet.template", + ondelete="restrict", + required=True, + help="The Jet template that is required to be in a specific state", + domain="[('id', '!=', template_id)]", + ) + + state_required_id = fields.Many2one( + string="Required State", + comodel_name="cx.tower.jet.state", + required=True, + ondelete="restrict", + help="The state of the required Jet", + ) + + _sql_constraints = [ + ( + "unique_template_dependency", + "UNIQUE(template_id, template_required_id)", + "A template can only depend on another template once!", + ), + ] + + @api.constrains( + "template_id", + "template_required_id", + ) + def _check_circular_dependency(self): + """Check if this dependency would create a circular dependency chain""" + for dependency in self: + # Skip if the dependency isn't properly set yet + if not dependency.template_id or not dependency.template_required_id: + continue + + # Self-dependency is not allowed and already prevented by domain constraints + if dependency.template_id == dependency.template_required_id: + raise ValidationError(_("A template cannot depend on itself!")) + + # Build dependency graph + graph = self._build_dependency_graph() + + # Add the new dependency edge being created + if dependency.template_id.id not in graph: + graph[dependency.template_id.id] = set() + graph[dependency.template_id.id].add(dependency.template_required_id.id) + + # Check for circular dependencies + if self._has_cycle(graph, dependency.template_id.id): + raise ValidationError( + _( + "This dependency would create a circular reference chain! " + "Template '%(template)s' would indirectly depend on itself.", + template=dependency.template_id.name, + ) + ) + + @api.depends("template_id", "template_required_id") + def _compute_display_name(self): + for dependency in self: + dependency.display_name = ( + ( + f"{dependency.template_id.name} ->" + f" {dependency.template_required_id.name}" + ) + if dependency.template_id and dependency.template_required_id + else "..." + ) + + def write(self, vals): + """Do not allow modifications after creation""" + # Allow modifications in install mode only to load demo data + if ("template_id" in vals or "template_required_id" in vals) and not ( + self._context.get("install_mode") and self._context.get("install_xmlid") + ): + raise ValidationError( + _( + "You cannot modify an existing template dependency! " + "Please remove it and create a new one." + ) + ) + return super().write(vals) + + def _build_dependency_graph(self): + """Build a directed graph of template dependencies + + Returns: + dict: A dictionary where keys are template IDs and values are + sets of template IDs that are required by the key template + """ + graph = {} + # Get all dependencies in the system + # TODO: This is not efficient, we should find a better way later. + # Eg cache the graph in the template model. + all_deps = self.search([]) + + for dep in all_deps: + from_id = dep.template_id.id + to_id = dep.template_required_id.id + + if from_id not in graph: + graph[from_id] = set() + + graph[from_id].add(to_id) + + # Ensure the to_id is in the graph even if it doesn't require anything + if to_id not in graph: + graph[to_id] = set() + + return graph + + def _has_cycle(self, graph, start_node, visited=None, path=None): + """Check if the graph has a cycle starting from start_node + + Args: + graph (dict): Dependency graph where keys are template IDs and values are + sets of template IDs that the key depends on + start_node (int): Template ID to start the traversal from + visited (set, optional): Set of already visited nodes + path (set, optional): Set of nodes in the current DFS path + + Returns: + bool: True if a cycle is detected, False otherwise + """ + if visited is None: + visited = set() + if path is None: + path = set() + + visited.add(start_node) + path.add(start_node) + + for neighbor in graph.get(start_node, set()): + if neighbor not in visited: + if self._has_cycle(graph, neighbor, visited, path): + return True + elif neighbor in path: + # We found a cycle + return True + + # Remove the current node from the path as we backtrack + path.remove(start_node) + return False diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_template_install.py b/addons/cetmix_tower_server/models/cx_tower_jet_template_install.py new file mode 100644 index 0000000..a4f36aa --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_template_install.py @@ -0,0 +1,474 @@ +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class CxTowerJetTemplateInstall(models.Model): + """ + Used to track installation of Jet Templates on servers. + """ + + _name = "cx.tower.jet.template.install" + _description = "Jet Template Install/Uninstall" + _order = "create_date desc" + _rec_name = "jet_template_id" + + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + required=True, + help="Template to install/uninstall", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + index=True, + ondelete="cascade", + required=True, + help="Server to install/uninstall the template on", + ) + action = fields.Selection( + selection=[("install", "Install"), ("uninstall", "Uninstall")], + default="install", + ) + date_done = fields.Datetime(string="Completed on", readonly=True) + line_ids = fields.One2many( + comodel_name="cx.tower.jet.template.install.line", + inverse_name="jet_template_install_id", + auto_join=True, + string="Templates to install", + help="Complete list of templates to install/uninstall including dependencies", + ) + current_line_id = fields.Many2one( + comodel_name="cx.tower.jet.template.install.line", + string="Currently Installing", + help="Line that is currently being installed", + ) + state = fields.Selection( + selection=[ + ("processing", "Processing"), + ("done", "Done"), + ("failed", "Failed"), + ], + default="processing", + index=True, + ) + + @api.model + def install(self, server, template): + """Install the template on the server. + + Args: + server (cx.tower.server()): The server to install the template on. + template (cx.tower.jet.template()): The template to install. + + Returns: + cx.tower.jet.template.install(): The installation record. + """ + server.ensure_one() + template.ensure_one() + + # Compose the list of templates to install + # NB: templates will be installed later in reverse order + # to ensure that dependencies are satisfied + template_to_process = [template] + template._check_dependency_satisfaction( + server + ) + + # Prepare the template install lines + template_to_process_lines = [] + order = 0 + for t in template_to_process: + template_to_process_lines.append( + (0, 0, {"jet_template_id": t.id, "order": order}) + ) + order += 1 + + # Create a new install record + install_record = self.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "line_ids": template_to_process_lines, + } + ) + + # Send notification + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": install_record.id, + "views": [(False, "form")], + } + ) + + self.env.user.notify_info( + message=_( + "%(timestamp)s
    " "Installing template on server '%(server_name)s'", + server_name=server.name, + timestamp=fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ), + ), + title=template.name, + sticky=False, # explicitly set to False to avoid blocking the user's screen + action=action, + ) + + # Launch the installation + install_record._process_install() + + # Return the installation record + return install_record + + @api.model + def uninstall(self, server, template): + """Uninstall the template from the server. + NB: only one template can be uninstalled at a time. + + Args: + server (cx.tower.server()): The server to uninstall the template from. + template (cx.tower.jet.template()): The template to uninstall. + """ + server.ensure_one() + template.ensure_one() + + # Create a new install record + install_record = self.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + "action": "uninstall", + } + ) + + # Send notification + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": install_record.id, + "views": [(False, "form")], + } + ) + + self.env.user.notify_info( + message=_( + "%(timestamp)s
    " + "Uninstalling template on server '%(server_name)s'", + server_name=server.name, + timestamp=fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ), + ), + title=template.name, + sticky=False, # explicitly set to False to avoid blocking the user's screen + action=action, + ) + + # Launch the installation + install_record._process_install() + + # Return the installation record + return install_record + + def _process_install(self): + """ + Process the installation or uninstallation of the template. + """ + self.ensure_one() + + # We are not using `while` because flight plans + # may run asynchronously and we don't want to + # block the execution of the function + + # Continue only if the job is still processing + if self.state != "processing": + return + + # Exit if there are some lines currently being installed + if self.current_line_id: + return + + # Get the template to install + installation_tasks = self.line_ids.sorted("order", reverse=True) + for installation_task in installation_tasks: + # Pick the templates only in the "To Process" state + if installation_task.state != "to_process": + continue + + # Get the flight plan to install the template + if self.action == "install": + flight_plan = installation_task.jet_template_id.plan_install_id # pylint: disable=no-member + else: + flight_plan = installation_task.jet_template_id.plan_uninstall_id # pylint: disable=no-member + + # Run the corresponding flight plan + if flight_plan: + # Update the current template install line + self.write( + { + "current_line_id": installation_task.id, + } + ) + + # Add the install record to the flight plan params + plan_params = { + "jet_template_install_id": self.id, # pylint: disable=no-member + } + with self.env.cr.savepoint(): + # Run the flight plan (exceptions handled inside the flight plan) + self.server_id.run_flight_plan( + flight_plan=flight_plan, + jet_template=installation_task.jet_template_id, + **{"plan_log": plan_params}, + ) + # Flight plan will trigger the `_process_install` function again + # if the flight plan is finished successfully. + # So we don't need continue the loop in this case. + return + + # Mark the installation task as "Done" + # because nothing else is to be done here. + installation_task.write( + { + "state": "done", + } + ) + # Add to the list of installed templates + if self.action == "install": + installation_task.jet_template_id.write( + {"server_ids": [(4, self.server_id.id)]} + ) + else: + installation_task.jet_template_id.write( + {"server_ids": [(3, self.server_id.id)]} + ) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", + rec_ids=[self.id], + ) + + # Mark the installation as done + now = fields.Datetime.now() + self.write( + { + "state": "done", + "date_done": now, + } + ) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", rec_ids=[self.id] + ) + self.env.user.reload_views( + model="cx.tower.server", view_types=["form"], rec_ids=[self.server_id.id] + ) + self.env.user.reload_views( + model="cx.tower.jet.template", + view_types=["form"], + rec_ids=[self.jet_template_id.id], + ) + + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_success = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_success" + ) + # Send notification to the user + if notification_type_success: + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + # Send success notification + self.env.user.notify_success( + message=_( + "%(timestamp)s
    " + "%(action)s completed on server '%(server_name)s'", + action=_("Installation") + if self.action == "install" + else _("Uninstallation"), + server_name=self.server_id.name, + timestamp=fields.Datetime.context_timestamp(self, now), + ), + title=self.jet_template_id.name, # pylint: disable=no-member + sticky=notification_type_success == "sticky", + action=action, + ) + + def _flight_plan_finished(self, plan_status): + """ + Triggered when a flight plan that is used for installing/uninstalling + a template is finished. + + Args: + plan_status (int): The exit code of the flight plan. + """ + self.ensure_one() + + # Validate callback state + if not self.current_line_id: + _logger.warning( + "Callback invoked with no current_line_id for install %s", self.id + ) + return + + if self.state != "processing": + _logger.warning( + "Callback invoked for install %s in state %s", self.id, self.state + ) + return + + # Flight plan finished successfully + if plan_status == 0: + # Mark current line as done + self.current_line_id.write( # pylint: disable=no-member + { + "state": "done", + } + ) + # Add template to the list of installed templates + # or remove it from the list if it is being uninstalled + if self.action == "install": + self.current_line_id.jet_template_id.write( # pylint: disable=no-member + {"server_ids": [(4, self.server_id.id)]} + ) + else: + self.current_line_id.jet_template_id.write( # pylint: disable=no-member + {"server_ids": [(3, self.server_id.id)]} + ) + + # Remove the link to the current line and continue + self.write({"current_line_id": False}) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", + rec_ids=[self.id], + ) + self._process_install() + else: + # Mark current line as failed + self.current_line_id.write( # pylint: disable=no-member + { + "state": "failed", + } + ) + # Clear the current line link + self.write( + { + "state": "failed", + "date_done": fields.Datetime.now(), + "current_line_id": False, + } + ) + + # Set all other 'to_process' lines as failed + self.line_ids.filtered(lambda line: line.state == "to_process").write( + { + "state": "failed", + } + ) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", + rec_ids=[self.id], + ) + # Send notification to the user + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_error = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_error" + ) + if notification_type_error: + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + # Send error notification + self.env.user.notify_danger( + message=_( + "%(timestamp)s
    " + "%(action)s failed on server '%(server_name)s'", + action=_("Installation") + if self.action == "install" + else _("Uninstallation"), + server_name=self.server_id.name, + timestamp=fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ), + ), + title=self.jet_template_id.name, + sticky=notification_type_error == "sticky", + action=action, + ) + + def action_view_flight_plan_logs(self): + """Open flight plan logs related to this installation""" + self.ensure_one() + + return { + "name": _( + "Flight Plan Logs - %(install_name)s", + install_name=self.jet_template_id.name, + ), + "type": "ir.actions.act_window", + "res_model": "cx.tower.plan.log", + "view_mode": "list,form", + "domain": [("jet_template_install_id", "=", self.id)], # pylint: disable=no-member + } diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_template_install_line.py b/addons/cetmix_tower_server/models/cx_tower_jet_template_install_line.py new file mode 100644 index 0000000..fc41d3d --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_template_install_line.py @@ -0,0 +1,41 @@ +from odoo import fields, models + + +class CxTowerJetTemplateInstallLine(models.Model): + """ + Used to track the order and status of templates to install/uninstall. + """ + + _name = "cx.tower.jet.template.install.line" + _description = "Jet Template Install/Uninstall Line" + _order = "order" + _rec_name = "jet_template_id" + + order = fields.Integer(required=True, default=10) + jet_template_install_id = fields.Many2one( + comodel_name="cx.tower.jet.template.install", + ondelete="cascade", + required=True, + index=True, + ) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + ondelete="cascade", + required=True, + index=True, + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + related="jet_template_install_id.server_id", + readonly=True, + store=True, + ) + state = fields.Selection( + selection=[ + ("to_process", "To Process"), + ("processing", "Processing"), + ("done", "Done"), + ("failed", "Failed"), + ], + default="to_process", + ) diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py b/addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py new file mode 100644 index 0000000..eeb17a5 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_waypoint.py @@ -0,0 +1,789 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from .constants import GENERAL_ERROR, WAYPOINT_CREATE_FAILED +from .tools import generate_random_id + +_logger = logging.getLogger(__name__) + + +class CxTowerJetWaypoint(models.Model): + """Jet Waypoints represent waypoints for jets""" + + _name = "cx.tower.jet.waypoint" + _description = "Cetmix Tower Jet Waypoint" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.access.mixin", + "cx.tower.metadata.mixin", + ] + _order = "create_date desc" + + name = fields.Char(required=True) + access_level = fields.Selection( + selection=lambda self: self.env[ + "cx.tower.jet.waypoint.template" + ]._selection_access_level(), + compute="_compute_access_level", + readonly=False, + store=True, + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("preparing", "Preparing"), + ("ready", "Ready"), + ("error", "Error"), + ("arriving", "Arriving"), + ("leaving", "Leaving"), + ("current", "Current"), + ("deleting", "Deleting"), + ("deleted", "Deleted"), + ], + default="draft", + required=True, + readonly=True, + ) + can_fly_to = fields.Boolean( + compute="_compute_can_fly_to", + readonly=True, + ) + is_destination = fields.Boolean( + help="Indicates if this waypoint is the current destination", + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + required=True, + ondelete="cascade", + help="Jet this waypoint belongs to", + ) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + related="jet_id.jet_template_id", + readonly=True, + ) + waypoint_template_id = fields.Many2one( + string="Type", + comodel_name="cx.tower.jet.waypoint.template", + help="Waypoint template this waypoint is based on", + domain="[('jet_template_id', '=', jet_template_id)]", + required=True, + ondelete="restrict", + ) + variable_values = fields.Json( + help="Custom variable values for this waypoint", + readonly=True, + ) + variable_values_text = fields.Text( + help="Custom variable values for this waypoint", + compute="_compute_variable_values_text", + ) + created_from_command_log_id = fields.Many2one( + comodel_name="cx.tower.command.log", + string="Created From", + help="Command log that created this waypoint; the waypoint callback " + "finishes it when the waypoint reaches ready/current or error. " + "Kept for debugging/audit.", + ondelete="set null", + copy=False, + ) + + # ------------------------------------ + # --------- Selection ------------ + # ------------------------------------ + def _selection_access_level(self): + """ + Available access levels + + Returns: + List of tuples: available options. + """ + return [ + ("2", "Manager"), + ("3", "Root"), + ] + + # ------------------------------------ + # --------- Computed Fields --------- + # ------------------------------------ + @api.depends("name", "create_date") + def _compute_display_name(self): + """ + Compute the display name of the waypoint + """ + for waypoint in self: + timestamp = fields.Datetime.context_timestamp( + waypoint, waypoint.create_date + ) + formatted_date = timestamp.strftime("%Y-%m-%d %H:%M:%S") + waypoint.display_name = f"{waypoint.name} ({formatted_date})" + + @api.depends("waypoint_template_id") + def _compute_access_level(self): + """ + Set default access level to the waypoint template access level + """ + for waypoint in self: + if waypoint.waypoint_template_id: + waypoint.access_level = waypoint.waypoint_template_id.access_level + + @api.depends("jet_id.waypoint_ids", "jet_id.waypoint_ids.state") + def _compute_can_fly_to(self): + """ + Can fly only if waypoint is in the ready state and + is not the current waypoint and all the jet waypoints + are in the "ready" state + """ + for waypoint in self: + all_waypoints = waypoint.jet_id.waypoint_ids + waypoint.can_fly_to = waypoint.state == "ready" and not bool( + all_waypoints.filtered( + lambda w: w.state not in ["ready", "error", "current"] + ) + ) + + @api.depends("variable_values") + def _compute_variable_values_text(self): + """ + Compute the variable values text for the waypoint + """ + for waypoint in self: + waypoint.variable_values_text = ( + str(waypoint.variable_values) if waypoint.variable_values else False + ) + + # ------------------------------------ + # --------- Constraints ------------- + # ------------------------------------ + @api.constrains("is_destination", "jet_id") + def _check_is_destination(self): + """ + Validate ``is_destination`` on each waypoint in the recordset. + + Raises a ValidationError when: + - The waypoint is being set as destination while in the ``draft``, + ``error``, ``leaving``, ``deleting``, or ``deleted`` state. + Use ``prepare(is_destination=True)`` to designate a destination + waypoint; it transitions the waypoint out of ``draft`` and sets + ``is_destination`` atomically. + - Another destination waypoint already exists for the same jet + (at most one destination per jet is allowed). + """ + destination_waypoints = self.filtered("is_destination") + if not destination_waypoints: + return + + existing_destinations = self.search( + [ + ("jet_id", "in", destination_waypoints.mapped("jet_id").ids), + ("is_destination", "=", True), + ("id", "not in", destination_waypoints.ids), + ] + ) + existing_by_jet = {wp.jet_id.id: wp for wp in existing_destinations} + + # Track jet IDs already claimed as destination within this batch so that + # two records in the same transaction are caught even though neither + # appears in the DB search above. + seen_in_batch = {} + + invalid_states = {"draft", "error", "leaving", "deleting", "deleted"} + + for waypoint in destination_waypoints: + if waypoint.state in invalid_states: + raise ValidationError( + _( + "Cannot set is_destination to True for waypoint %(waypoint)s " + "because it is in the %(state)s state", + waypoint=waypoint.name, + state=waypoint.state, + ) + ) + jet_id = waypoint.jet_id.id + duplicate = existing_by_jet.get(jet_id) or seen_in_batch.get(jet_id) + if duplicate: + raise ValidationError( + _( + "Waypoint %(existing)s is already set as the destination " + "for jet %(jet)s. Only one destination waypoint is allowed " + "per jet.", + existing=duplicate.name, + jet=waypoint.jet_id.name, + ) + ) + seen_in_batch[jet_id] = waypoint + + # ------------------------------------ + # --------- CRUD Methods ------------- + # ------------------------------------ + @api.model_create_multi + def create(self, vals_list): + """ + Create waypoints + - Generate waypoint reference if not provided + """ + + for vals in vals_list: + if not vals.get("reference"): + vals["reference"] = generate_random_id( + sections=4, population=4, separator="_" + ) + jets = super().create(vals_list) + return jets + + def write(self, vals): + """ + Write. Do not allow to modify the template + if the waypoint is not in the draft state + """ + if "waypoint_template_id" in vals and not vals.get("state") == "draft": + for waypoint in self: + if ( + waypoint.waypoint_template_id.id != vals.get("waypoint_template_id") + and waypoint.state != "draft" + ): + raise ValidationError( + _( + "Cannot change waypoint type for %(waypoint)s " + "because it is not in the draft state", + waypoint=waypoint.name, + ) + ) + # Invalidate the state field + fields_to_invalidate = [] + if "state" in vals: + fields_to_invalidate.append("state") + if "variable_values" in vals: + fields_to_invalidate.append("variable_values") + if "is_destination" in vals: + fields_to_invalidate.append("is_destination") + if fields_to_invalidate: + self.invalidate_recordset(fields_to_invalidate) + return super().write(vals) + + def unlink(self): + """ + Unlink. + + Raises: + ValidationError: If the waypoint cannot be deleted + set the context value 'waypoint_no_raise_on_delete' to True + for not to raise the exception. + """ + # Deletable waypoints: + # - are in the 'draft' or 'deleted' state + # - waypoint is in the 'ready' or 'error' state and template + # doesn't have on_delete flight plan + # Non-deletable waypoints: + # - are in the 'arriving', 'leaving' or 'preparing' state + # or is the current waypoint of the jet + # or is marked as the active destination (is_destination=True) + # Need to run the on_delete flight plan: + # - waypoint is in the 'ready' or 'error' state and template has + # on_delete flight plan + if self._context.get("waypoint_force_delete"): + return super().unlink() + + waypoints_to_delete = self.browse() + waypoints_to_run_delete_plan = self.browse() + for waypoint in self: + if waypoint.is_destination: + exception_message = _( + "Cannot delete waypoint %(waypoint)s because it is " + "currently designated as the destination for jet %(jet)s.", + waypoint=waypoint.name, + jet=waypoint.jet_id.name, + ) + if self._context.get("waypoint_no_raise_on_delete"): + _logger.error(exception_message) + continue + raise ValidationError(exception_message) + if waypoint.state not in ["draft", "deleted", "error", "ready"]: + if waypoint.state == "current": + exception_message = _( + "Cannot delete the waypoint %(waypoint)s because it is" + " the current waypoint of the jet %(jet)s", + waypoint=waypoint.name, + jet=waypoint.jet_id.name, + ) + else: + exception_message = _( + "Cannot delete the waypoint %(waypoint)s because it is" + " in the %(state)s state", + waypoint=waypoint.name, + state=waypoint.state, + ) + if self._context.get("waypoint_no_raise_on_delete"): + _logger.error(exception_message) + continue + raise ValidationError(exception_message) + if ( + waypoint.state in ["ready", "error"] + and waypoint.waypoint_template_id.plan_delete_id + ): + waypoints_to_run_delete_plan |= waypoint + continue + waypoints_to_delete |= waypoint + + if waypoints_to_delete: + result = super(CxTowerJetWaypoint, waypoints_to_delete).unlink() + else: + result = True + + for waypoint in waypoints_to_run_delete_plan: + waypoint.write({"state": "deleting"}) + waypoint.jet_id.server_id.sudo().run_flight_plan( + jet=waypoint.jet_id, + flight_plan=waypoint.waypoint_template_id.plan_delete_id, + plan_log={"waypoint_id": waypoint.id}, + variable_values=waypoint._get_custom_variable_values(), + ) + return result + + # ------------------------------------ + # --------- Waypoint Setters --------- + # ------------------------------------ + def prepare(self, is_destination=False): + """ + Prepare the newly created waypoint. + + Args: + is_destination (bool): True if the waypoint is the destination + Returns: + Boolean: True if the waypoint was prepared successfully + Raises: + ValidationError: If the waypoint cannot be prepared + """ + self.ensure_one() + _logger.info( + _( + "Preparing waypoint %(waypoint)s on jet %(jet)s", + waypoint=self.name, + jet=self.jet_id.name, + ) + ) + if not self.state == "draft": + error = _( + "Cannot prepare waypoint %(waypoint)s on jet %(jet)s because" + " it is not in the 'draft' state", + waypoint=self.name, + jet=self.jet_id.name, + ) + _logger.error(error) + raise ValidationError(error) + + if self.waypoint_template_id.plan_create_id: + self.write({"state": "preparing", "is_destination": is_destination}) + with self.env.cr.savepoint(): + self.jet_id.server_id.sudo().run_flight_plan( + flight_plan=self.waypoint_template_id.plan_create_id, + jet=self.jet_id, + plan_log={ + "waypoint_id": self.id, + }, + variable_values=self._get_custom_variable_values(), + ) + else: + self.write({"state": "ready", "is_destination": is_destination}) + # Save jet variable values when state changes to ready + self._save_variable_values() + + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet", rec_ids=[self.jet_id.id]) + + # Fly to this waypoint if set as destination + if is_destination: + self.fly_to() + else: + self._finalize_create_waypoint_command_log(success=True) + _logger.info( + _( + "Successfully prepared waypoint %(waypoint)s on jet %(jet)s", + waypoint=self.name, + jet=self.jet_id.name, + ) + ) + return True + + def fly_to(self): + """ + Fly to the waypoint + + Returns: + bool: True if event was handled else False + """ + self.ensure_one() + _logger.info( + _( + "Flying to waypoint %(waypoint)s on jet %(jet)s", + waypoint=self.name, + jet=self.jet_id.name, + ) + ) + if self.state != "ready": + error = _( + "Cannot fly to waypoint %(waypoint)s on jet %(jet)s because" + " it is not in the 'ready' state", + waypoint=self.name, + jet=self.jet_id.name, + ) + _logger.error(error) + raise ValidationError(error) + + # Cannot fly to waypoint if there is another waypoint + # in the "arriving" or state + other_waypoints = self.jet_id.waypoint_ids.filtered( + lambda w: w.state in ["arriving", "leaving"] + ) + if other_waypoints: + error = _( + "Cannot fly to waypoint %(waypoint)s on jet %(jet)s because" + " there is another waypoint %(other_waypoint)s " + "in the 'arriving' or 'leaving' state", + waypoint=self.name, + jet=self.jet_id.name, + other_waypoint=other_waypoints[0].name, + ) + _logger.error(error) + raise ValidationError(error) + + # Leave the previous waypoint + previous_waypoint = self.jet_id.waypoint_id + if not previous_waypoint: + # No previous waypoint, set state to arriving + # Variable values will be restored in _arrive() + self.write({"state": "arriving", "is_destination": True}) + self._arrive() + return True + + # Don't go to the waypoint if it is already the current waypoint + if previous_waypoint.id == self.id: + return True + + # Cannot leave the waypoint if it is not ready or current + if previous_waypoint.state not in ["ready", "current"]: + error = _( + "Cannot fly to waypoint %(waypoint)s on jet %(jet)s because" + " the previous waypoint %(previous_waypoint)s is not in the" + " 'ready' or 'current' state", + waypoint=self.name, + jet=self.jet_id.name, + previous_waypoint=previous_waypoint.name, + ) + _logger.error(error) + raise ValidationError(error) + + # Mark destination first; switch to arriving only after leave succeeds. + if not self.is_destination: + self.write({"is_destination": True}) + + # Leave the previous waypoint (this will save its variable values) + previous_waypoint._leave() + if previous_waypoint.state == "error": + # Roll back destination when source leave fails immediately. + self.write({"is_destination": False}) + self._finalize_create_waypoint_command_log( + success=False, + error=_("Failed to leave current waypoint."), + ) + return False + # If leaving completed immediately (no plan_leave_id), + # arrive at the new waypoint (which will restore variable values) + if self.state == "ready" and previous_waypoint.state in ["ready", "current"]: + self.write({"state": "arriving"}) + self._arrive() + _logger.info( + _( + "Successfully flew to waypoint %(waypoint)s on jet %(jet)s", + waypoint=self.name, + jet=self.jet_id.name, + ) + ) + return True + + def _leave(self): + """ + Leave the waypoint. + + Returns: + bool: True if event was handled else False + """ + self.ensure_one() + if self.state not in ["ready", "current"]: + return False + self.write({"state": "leaving"}) + plan_leave = self.waypoint_template_id.plan_leave_id + if plan_leave: + with self.env.cr.savepoint(): + self.jet_id.server_id.sudo().run_flight_plan( + jet=self.jet_id, + flight_plan=plan_leave, + plan_log={ + "waypoint_id": self.id, + }, + variable_values=self._get_custom_variable_values(), + ) + else: + self.write({"state": "ready"}) + # Save jet variable values + self._save_variable_values() + return True + + def _arrive(self): + """ + Arrive at the waypoint. + + Returns: + bool: True if event was handled else False + """ + self.ensure_one() + if not self.state == "arriving": + return False + # Restore variable values before running the arrive plan + self._restore_variable_values() + plan_arrive = self.waypoint_template_id.plan_arrive_id + if plan_arrive: + self.jet_id.server_id.sudo().run_flight_plan( + jet=self.jet_id, + flight_plan=plan_arrive, + plan_log={ + "waypoint_id": self.id, + }, + variable_values=self._get_custom_variable_values(), + ) + else: + # Clear destination flag when arriving without plan + self.write({"is_destination": False, "state": "current"}) + self.jet_id.write({"waypoint_id": self.id}) + self.jet_id.invalidate_recordset(["waypoint_id"]) + self._finalize_create_waypoint_command_log(success=True) + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet", rec_ids=[self.jet_id.id]) + return True + + # --------------------------- + # --------- Hooks --------- + # --------------------------- + def _finalize_create_waypoint_command_log(self, success=True, error=None): + """Finish the command log that created this waypoint, if any. + + Called when the waypoint reaches ready/current (success) or error. + Only calls finish() if the log is not already finished (guard against + double-finish). Does not clear created_from_command_log_id. + + Args: + success (bool): True if waypoint reached ready/current. + error (str, optional): Error message when success is False. + + Returns: + bool: True if command log was finished, False otherwise. + """ + self.ensure_one() + log_record = self.created_from_command_log_id + if not log_record: + return False + if log_record.finish_date: + return False + status = 0 if success else (WAYPOINT_CREATE_FAILED if error else GENERAL_ERROR) + response = _("Waypoint reached %s", self.state) if success else None + log_record.finish( + status=status, + response=response, + error=error, + ) + return True + + def _plan_finished(self, plan_log): + """ + Handle the plan finished event + + Args: + plan_log (cx.tower.plan.log): Plan log record + + Returns: + bool: True if event was handled + """ + self.ensure_one() + if plan_log.plan_status == 0: + # Successfully finished the plan + jet = self.jet_id # preserve in case of deleting + + if self.state == "arriving": + # Set the waypoint as the current waypoint + # when successfully arriving + self.jet_id.write({"waypoint_id": self.id}) + self.jet_id.invalidate_recordset(["waypoint_id"]) + # Clear destination flag when successfully arrived + self.write({"state": "current", "is_destination": False}) + self._finalize_create_waypoint_command_log(success=True) + _logger.info( + _( + "Successfully arrived at waypoint %(waypoint)s on jet %(jet)s", + waypoint=self.name, + jet=self.jet_id.name, + ) + ) + elif self.state == "deleting": + self.write({"state": "deleted"}) + waypoint_name = self.name + jet_name = self.jet_id.name + self.unlink() + _logger.info( + _( + "Successfully deleted waypoint %(waypoint)s on jet %(jet)s", + waypoint=waypoint_name, + jet=jet_name, + ) + ) + elif self.state in ["leaving", "preparing"]: + # Save jet variable values + self._save_variable_values() + + # Arrive at the destination waypoint + # if there is any in the arriving state (only for leaving) + if self.state == "leaving": + destination_waypoint = self.jet_id.waypoint_ids.filtered( + "is_destination" + ) + if destination_waypoint: + destination_waypoint.write({"state": "arriving"}) + destination_waypoint._arrive() + + # Set the waypoint state to ready after leaving or preparing + prepared = self.state == "preparing" + self.write({"state": "ready"}) + # Fly to this waypoint if set as destination + if self.is_destination and prepared: + self.fly_to() + else: + self._finalize_create_waypoint_command_log(success=True) + + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet", rec_ids=[jet.id]) + return True + + # Failed to finish the plan + # - restore variable values from current waypoint + # - set the waypoint state to error + if self.state == "arriving": + # Restore variable values from jet's current waypoint + current_waypoint = self.jet_id.waypoint_id + if current_waypoint: + current_waypoint._restore_variable_values() + # Set current waypoint state to "current" + current_waypoint.write({"state": "current"}) + # Clear destination flag when arriving fails + self.write({"is_destination": False, "state": "error"}) + self._finalize_create_waypoint_command_log( + success=False, error=_("Plan failed while arriving.") + ) + else: + if self.state == "leaving": + # Cancel pending destination when leave plan fails. + destination_waypoint = self.jet_id.waypoint_ids.filtered( + lambda w: w.is_destination and w.id != self.id + ) + if destination_waypoint: + destination_waypoint.write({"is_destination": False}) + destination_waypoint._finalize_create_waypoint_command_log( + success=False, + error=_("Failed to leave current waypoint."), + ) + self.write({"state": "error", "is_destination": False}) + self._finalize_create_waypoint_command_log( + success=False, error=_("Plan failed.") + ) + + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet", rec_ids=[self.jet_id.id]) + return True + + # ----------------------------------- + # --------- Helper Methods --------- + # ----------------------------------- + def _save_variable_values(self): + """ + Save current jet variable values to the waypoint. + Only jet-specific values are saved (not template/server/global values). + + Returns: + bool: True if values were saved + """ + self.ensure_one() + + # Get all variable values that belong to this jet specifically + # (not template/server/global values) + # Use variable_value_ids field from variable mixin + jet_variable_values = self.jet_id.variable_value_ids + + # Build dictionary mapping variable_reference to value_char + variable_values_dict = {} + for var_value in jet_variable_values: + variable_values_dict[var_value.variable_reference] = ( + var_value.value_char or "" + ) + + # Save to waypoint's variable_values field + self.write({"variable_values": variable_values_dict}) + self.invalidate_recordset(["variable_values"]) + return True + + def _restore_variable_values(self): + """ + Restore variable values from the waypoint to the jet. + - Removes all variable values that are not saved in the waypoint + + Returns: + bool: True if values were restored + """ + self.ensure_one() + if not self.variable_values: + # Remove all jet variable values if waypoint has no saved values + self.jet_id.variable_value_ids.unlink() + return True + + # Get all current jet variable values + current_jet_values = self.jet_id.variable_value_ids + saved_references = set(self.variable_values.keys()) + + # Remove variable values that are not in the saved waypoint values + values_to_remove = current_jet_values.filtered( + lambda v: v.variable_reference not in saved_references + ) + if values_to_remove: + values_to_remove.unlink() + + # Restore each variable value from the saved dictionary + # Variable mixin handles checking if value is the same + for variable_reference, saved_value in self.variable_values.items(): + self.jet_id.set_variable_value(variable_reference, saved_value) + + return True + + def _get_custom_variable_values(self): + """ + Prepare custom variable values to pass with flight plans. + Following custom values are available: + + __waypoint: waypoint reference + __waypoint_type: waypoint template reference + __waypoint_state: waypoint state + __waypoint_: waypoint metadata + + Returns: + dict: Custom variable values to pass with flight plans + """ + self.ensure_one() + custom_values = { + "__waypoint": self.reference, + "__waypoint_type": self.waypoint_template_id.reference, + "__waypoint_state": self.state, + } + if self.metadata: + for key, value in self.metadata.items(): + custom_values[f"__waypoint_{key}"] = value + return custom_values diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_waypoint_template.py b/addons/cetmix_tower_server/models/cx_tower_jet_waypoint_template.py new file mode 100644 index 0000000..ab2207d --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_waypoint_template.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class CxTowerJetWaypointTemplate(models.Model): + """Jet Waypoint Templates define waypoints for jet templates""" + + _name = "cx.tower.jet.waypoint.template" + _description = "Cetmix Tower Jet Waypoint Template" + _inherit = ["cx.tower.reference.mixin", "cx.tower.access.mixin"] + _order = "sequence, name asc" + + name = fields.Char(required=True) + sequence = fields.Integer(default=10, help="Used to sort waypoints in views") + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + ondelete="cascade", + help="Jet template this waypoint template belongs to", + ) + plan_create_id = fields.Many2one( + string="Create Flight Plan", + comodel_name="cx.tower.plan", + help="Flight plan to run after waypoint is created", + ) + plan_arrive_id = fields.Many2one( + string="Arrive Flight Plan", + comodel_name="cx.tower.plan", + help="Flight plan to run after waypoint is reached", + ) + plan_leave_id = fields.Many2one( + string="Leave Flight Plan", + comodel_name="cx.tower.plan", + help="Flight plan to run before leaving the waypoint", + ) + plan_delete_id = fields.Many2one( + string="Delete Flight Plan", + comodel_name="cx.tower.plan", + help="Flight plan to run before deleting the waypoint", + ) + note = fields.Text() + + def _selection_access_level(self): + """ + Available access levels + + Returns: + List of tuples: available options. + """ + return [ + ("2", "Manager"), + ("3", "Root"), + ] + + @api.depends("name", "jet_template_id", "jet_template_id.name") + def _compute_display_name(self): + """Compute record display name. + + The UI should show waypoint templates in the format: + `` ()``. + """ + for record in self: + jet_template_name = record.jet_template_id.name or "" # type: ignore[attr-defined] + if jet_template_name: + record.display_name = ( # type: ignore[attr-defined] + f"{record.name} ({jet_template_name})" + ) + else: + record.display_name = record.name # type: ignore[attr-defined] diff --git a/addons/cetmix_tower_server/models/cx_tower_key.py b/addons/cetmix_tower_server/models/cx_tower_key.py new file mode 100644 index 0000000..3e275b7 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_key.py @@ -0,0 +1,413 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import api, fields, models + + +class CxTowerKey(models.Model): + """SSH Private key and secret storage""" + + _name = "cx.tower.key" + _description = "Cetmix Tower Key/Secret Storage" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.access.role.mixin", + "cx.tower.vault.mixin", + ] + _order = "name" + + KEY_PREFIX = "#!cxtower" + KEY_TERMINATOR = "!#" + SECRET_FIELDS = ["secret_value"] + + key_type = fields.Selection( + selection=[ + ("k", "SSH Key"), + ("s", "Secret"), + ], + required=True, + ) + reference_code = fields.Char( + compute="_compute_reference_code", + help="Key reference for inline usage", + ) + secret_value = fields.Text( + string="SSH Private Key", + ) + value_ids = fields.One2many( + string="Values", + comodel_name="cx.tower.key.value", + inverse_name="key_id", + ) + server_ssh_ids = fields.One2many( + string="Used as SSH Key", + comodel_name="cx.tower.server", + inverse_name="ssh_key_id", + readonly=True, + help="Used as SSH key in the following servers", + ) + note = fields.Text() + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_key_user_rel", + domain=lambda self: [ + ("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id]) + ], + ) + manager_ids = fields.Many2many( + relation="cx_tower_key_manager_rel", + ) + + @api.depends("reference", "key_type") + def _compute_reference_code(self): + """Compute key reference + Eg '#!cxtower.secret.KEY!#' + """ + for rec in self: + if rec.reference: + key_prefix = self._compose_key_prefix(rec.key_type) + if key_prefix: + rec.reference_code = f"#!cxtower.{key_prefix}.{rec.reference}!#" + else: + rec.reference_code = None + else: + rec.reference_code = None + + @api.returns("self", lambda value: value.id) + def copy(self, default=None): + """Copy key. Ensure secret value is copied. + + Args: + default (dict, optional): Default values. Defaults to None. + + Returns: + self: Copied key + """ + default = default or {} + default["secret_value"] = self._get_secret_value("secret_value") + result = super().copy(default=default) + + # Copy key values + for value in self.value_ids: + value.copy( + { + "key_id": result.id, + } + ) + + return result + + def _get_reference_pattern(self): + """ + Override mixin method + """ + return "[a-zA-Z0-9_]" + + def _compose_key_prefix(self, key_type): + """Compose key prefix based on key type. + Override to implement own key prefixes. + + + Args: + key_type (Char): Key type selection value ('s' for secret, 'k' for SSH key) + + + Returns: + Char: key prefix + """ + if key_type == "s": + key_prefix = "secret" + else: + key_prefix = None + return key_prefix + + def _parse_code_and_return_key_values(self, code, pythonic_mode=False, **kwargs): + """Replaces key placeholders in code with the corresponding values, + returning key values. + + This function is meant to be used in the flow where key values + are needed for some follow up operations such as command log clean up. + + NB: + - key format must follow "#!cxtower.key.KEY_ID!#" pattern. + eg #!cxtower.secret.GITHUB_TOKEN!# for GITHUB_TOKEN key + Args: + code (Text): code to process + pythonic_mode (Bool): If True, all variables in kwargs are converted to + strings and wrapped in double quotes. + Default is False. + kwargs (dict): optional arguments + + Returns: + Dict(): 'code': Command text, 'key_values': List of key values + """ + + # No need to search if code is too short + if len(code) <= len(self.KEY_PREFIX) + 3 + len( + self.KEY_TERMINATOR + ): # at least one dot separator and two symbols + return {"code": code, "key_values": None} + + # Get key strings + key_strings = self._extract_key_strings(code) + + # Set key values + key_values = [] + # Replace keys with values + for key_string in key_strings: + # Replace key including key terminator + key_value = self._parse_key_string(key_string, **kwargs) + if pythonic_mode and key_value: + # save key value as string in pythonic mode + key_value = f'"{key_value}"' + # Escape newline characters to ensure the key value remains + # a valid single-line string. This prevents syntax errors + # when the string is used in contexts where unescaped + # newlines would break Python syntax or evaluation logic. + key_value = key_value.replace("\n", "\\n") + + # Save key value if not saved yet + if key_value and key_value not in key_values: + key_values.append(key_value) + + # Handle False and None values + if not key_value: + key_value = str(key_value) + + # Replace key with value + code = code.replace(key_string, key_value) + + return {"code": code, "key_values": key_values} + + def _parse_code(self, code, **kwargs): + """Replaces key placeholders in code with the corresponding values. + + Args: + code (Text): code to proceed + kwargs (dict): optional arguments + + Returns: + Text: code with key values in place and list of key values. + Use key values + """ + + return self._parse_code_and_return_key_values(code, **kwargs)["code"] + + def _extract_key_strings(self, code): + """Extract all keys from code + Args: + code (Text): description + **kwargs (dict): optional arguments + Returns: + [str]: list of key strings + """ + key_strings = [] + key_terminator_len = len(self.KEY_TERMINATOR) + index_from = 0 # initial position + + while index_from >= 0: + index_from = code.find(self.KEY_PREFIX, index_from) + if index_from >= 0: + # Key end + index_to = code.find(self.KEY_TERMINATOR, index_from) + # Extract key value only if key terminator is found + if index_to > 0: + # Extract key string including key terminator + extract_to = index_to + key_terminator_len + key_string = code[index_from:extract_to] + # Add only if not added before + if key_string not in key_strings: + key_strings.append(key_string) + # Update index from + index_from = extract_to + else: + # No terminator found, move past this occurrence of prefix + index_from += len(self.KEY_PREFIX) + else: + # No more prefixes found + break + + return key_strings + + def _parse_key_string(self, key_string, **kwargs): + """Parse key string and call resolver based on the key type. + Each key string consists of 3 parts: + - key marker: #!cxtower + - key type: e.g. "secret", "password", "login" etc + - key ID: e.g "qwerty123", "mystrongpassword" etc + + Inherit this function to implement your own parser or resolver + Args: + key_string (str): key string + **kwargs (dict) optional values + + Returns: + str: key value or None if not able to parse + """ + + key_parts = self._extract_key_parts(key_string) + if key_parts is None: + return None + + key_type, reference = key_parts + key_value = self._resolve_key(key_type, reference, **kwargs) + + return key_value + + def _extract_key_parts(self, key_string): + """Extract and validate key parts from the key string. + + Args: + key_string (str): key string + + Returns: + tuple: (key_type, reference) if valid, else None + """ + key_parts = ( + key_string.replace(" ", "").replace(self.KEY_TERMINATOR, "").split(".") + ) + + # Must be 3 parts including pre! + if len(key_parts) == 3 and key_parts[0] == self.KEY_PREFIX: + return key_parts[1], key_parts[2] + + return None + + def _resolve_key(self, key_type, reference, **kwargs): + """Resolve key + Inherit this function to implement your own resolvers + + Args: + reference (str): key reference + **kwargs (dict) optional values + + Returns: + str: value or None if not able to parse + """ + if key_type == "secret": + return self._resolve_key_type_secret(reference, **kwargs) + + def _resolve_key_type_secret(self, reference, **kwargs): + """Resolve key of type "secret". + Use this function as a custom parser example + + Args: + reference (str): key reference + **kwargs (dict) optional values + + Returns: + str: value or False if not able to parse + """ + if not reference: + return + + # Compose domain used to fetch keys + # + # Keys are checked in the following order: + # 1. Partner and Server specific + # 2. Server specific + # 3. Partner specific + # 4. General (no server or partner specified) + server_id = kwargs.get("server_id") + partner_id = kwargs.get("partner_id") + + # Fetch key + key = self.sudo().search([("reference", "=", reference)], limit=1) + if not key: + return + + # Check if key has custom values + key_values = key.value_ids + key_value = None + + # 1. Server and Partner specific key first + if key_values and server_id and partner_id: + filtered_key_values = key_values.filtered( + lambda k: k.server_id.id == server_id and k.partner_id.id == partner_id + ) + if filtered_key_values: + key_value = filtered_key_values[0] + + # 2. Server specific key first + if not key_value and key_values and server_id: + filtered_key_values = key_values.filtered( + lambda k: k.server_id.id == server_id and not k.partner_id + ) + if filtered_key_values: + key_value = filtered_key_values[0] + + # 3. Partner specific key next + if not key_value and key_values and partner_id: + filtered_key_values = key_values.filtered( + lambda k: k.partner_id.id == partner_id and not k.server_id + ) + if filtered_key_values: + key_value = filtered_key_values[0] + + # 4. General key next + if not key_value and key_values: + filtered_key_values = key_values.filtered( + lambda k: not k.partner_id and not k.server_id + ) + if filtered_key_values: + key_value = filtered_key_values[0] + + if key_value: + return key_value._get_secret_value("secret_value") + + def _replace_with_spoiler(self, code, key_values): + """Helper function that replaces clean text keys in code with spoiler. + Eg + 'Code with passwordX and passwordY` will look like: + 'Code with *** and ***' + + Important: this function doesn't parse keys by itself. + You need to get and provide key values yourself. + + Args: + code (Text): code to clean + key_values (List): secret values to be cleaned from code + + Returns: + Text: cleaned code + """ + + if not key_values: + return code + + # Replace keys with values + for key_value in key_values: + # If key_value includes quotes, remove them for the replacement + key_value = key_value.strip('"') + # If key_value contains an escaped line break replace then remove escaping + key_value = key_value.replace("\\n", "\n") + # Replace key including key terminator + code = code.replace(key_value, self.SECRET_VALUE_PLACEHOLDER) + + return code + + def _set_secret_values(self, vals): + """Set secret value. + Override this method in case you need + to implement custom key storages. + + Args: + vals (dict): Dictionary of field names to secret values + """ + self.ensure_one() + if self.key_type == "s": + # Set general value or create new one if not exists + general_value = self.value_ids.filtered( + lambda x: not x.server_id and not x.partner_id + ) + if general_value: + general_value._set_secret_values(vals) + else: + create_vals = {"key_id": self.id} + create_vals.update(vals) + self.value_ids.create(create_vals) + + elif self.key_type == "k": + return super()._set_secret_values(vals) diff --git a/addons/cetmix_tower_server/models/cx_tower_key_mixin.py b/addons/cetmix_tower_server/models/cx_tower_key_mixin.py new file mode 100644 index 0000000..a50ea88 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_key_mixin.py @@ -0,0 +1,70 @@ +from odoo import api, fields, models + + +class CxTowerKeyMixin(models.AbstractModel): + """Mixin for managing secrets and SSH keys""" + + _name = "cx.tower.key.mixin" + _description = "Cetmix Tower Key/Secret Mixin" + + secret_ids = fields.Many2many( + comodel_name="cx.tower.key", + compute="_compute_secret_ids", + compute_sudo=True, + readonly=True, + store=True, + string="Secrets", + ) + + @api.depends("code") + def _compute_secret_ids(self): + """ + Compute the secret IDs based on the references found in the code field. + + This method updates the secret_ids Many2many field by extracting secret + references from the code field. If no code is present, the field is cleared. + It ensures updates are only triggered when there are differences between + the current and new secret IDs. + """ + for record in self: + if record.code: + new_secrets = self._extract_secret_ids(record.code) + + # This will create a recordset that contains the difference + if record.secret_ids != new_secrets: + record.secret_ids = new_secrets + else: + record.secret_ids = False + + @api.model + def _extract_secret_ids(self, code): + """ + Extract secret IDs based on references found in the given `code`. + + Args: + code: Text containing potential secret references. + + Returns: + recordset: cx.tower.key recordset of secrets found in the code. + """ + key_model = self.env["cx.tower.key"] + key_strings = key_model._extract_key_strings(code) + + key_refs = [] + for key_string in key_strings: + key_parts = key_model._extract_key_parts(key_string) + if key_parts: + key_refs.append(key_parts[1]) + + return key_model.search(self._compose_secret_search_domain(key_refs)) + + def _compose_secret_search_domain(self, key_refs): + """Compose domain for searching secrets by references. + + Args: + key_refs (List[str]): List of secret references. + + Returns: + List: final domain for searching secrets. + """ + return [("reference", "in", key_refs)] diff --git a/addons/cetmix_tower_server/models/cx_tower_key_value.py b/addons/cetmix_tower_server/models/cx_tower_key_value.py new file mode 100644 index 0000000..faa4115 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_key_value.py @@ -0,0 +1,112 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerKeyValue(models.Model): + """Secret value storage""" + + _name = "cx.tower.key.value" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.vault.mixin", + ] + _description = "Cetmix Tower Secret Value Storage" + + SECRET_FIELDS = ["secret_value"] + + name = fields.Char(related="key_id.name", readonly=False) + key_id = fields.Many2one( + comodel_name="cx.tower.key", + string="Key", + required=True, + ondelete="cascade", + domain="[('key_type', '=', 's')]", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + ondelete="cascade", + help="Server to which the key belongs", + ) + partner_id = fields.Many2one( + comodel_name="res.partner", + ondelete="cascade", + help="Partner to which the key belongs", + ) + is_global = fields.Boolean( + string="Global", + compute="_compute_is_global", + help="This value is applicable to all servers and partners", + ) + secret_value = fields.Text() + + @api.depends("server_id", "partner_id") + def _compute_is_global(self): + for record in self: + record.is_global = not record.server_id and not record.partner_id + + @api.constrains("key_id", "server_id", "partner_id") + def _check_key_id(self): + for rec in self: + if not rec.key_id: + continue + # Only keys of type 'secret' can have custom secret values + if rec.key_id.key_type != "s": + raise ValidationError( + _( + "Custom secret values can be defined" + " only for key type 'secret'" + ) + ) + # Only one global secret value can be defined for a key + global_values = rec.key_id.value_ids.filtered( + lambda x, rec=rec: not x.server_id and not x.partner_id + ) + if len(global_values) > 1: + raise ValidationError( + _("Only one global secret value can be defined for a key") + ) + # Only one secret value can be defined for a server and partner + server_partner_values = rec.key_id.value_ids.filtered( + lambda x, rec=rec: x.server_id == rec.server_id + and x.partner_id == rec.partner_id + ) + if len(server_partner_values) > 1: + raise ValidationError( + _( + "Only one secret value can be defined" + " for a server and partner" + ) + ) + # Only one secret value can be defined for a server + server_values = rec.key_id.value_ids.filtered( + lambda x, rec=rec: x.server_id == rec.server_id and not x.partner_id + ) + if len(server_values) > 1: + raise ValidationError( + _("Only one secret value can be defined for a server") + ) + # Only one secret value can be defined for a partner + partner_values = rec.key_id.value_ids.filtered( + lambda x, rec=rec: x.partner_id == rec.partner_id and not x.server_id + ) + if len(partner_values) > 1: + raise ValidationError( + _("Only one secret value can be defined for a partner") + ) + + @api.returns("self", lambda value: value.id) + def copy(self, default=None): + """Copy key value. Ensure secret value is copied. + + Args: + default (dict, optional): Default values. Defaults to None. + + Returns: + self: Copied key value + """ + default = default or {} + default["secret_value"] = self._get_secret_value("secret_value") + return super().copy(default=default) diff --git a/addons/cetmix_tower_server/models/cx_tower_metadata_mixin.py b/addons/cetmix_tower_server/models/cx_tower_metadata_mixin.py new file mode 100644 index 0000000..0d0b2f7 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_metadata_mixin.py @@ -0,0 +1,45 @@ +# Copyright (C) 2026 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class CxTowerMetadataMixin(models.AbstractModel): + """Used to implement metadata in models.""" + + _name = "cx.tower.metadata.mixin" + _description = "Cetmix Tower metadata mixin" + + metadata = fields.Json( + help="Additional metadata for this record", + readonly=True, + groups="cetmix_tower_server.group_manager", + ) + metadata_text = fields.Text( + help="Additional metadata for this record", + compute="_compute_metadata_text", + groups="cetmix_tower_server.group_manager", + ) + + @api.depends("metadata") + def _compute_metadata_text(self): + """ + Compute the metadata text for the record + """ + for record in self: + record.metadata_text = str(record.metadata) if record.metadata else False + + def update_metadata(self, metadata): + """ + Updates the metadata for the record. + Preserves the existing metadata. + + Args: + metadata (dict): The metadata to update the record with + + Returns: + bool: True if the metadata was updated, False otherwise + """ + self.ensure_one() + # Preserve the existing data in self.metadata. + self.write({"metadata": {**(self.metadata or {}), **metadata}}) + return True diff --git a/addons/cetmix_tower_server/models/cx_tower_os.py b/addons/cetmix_tower_server/models/cx_tower_os.py new file mode 100644 index 0000000..f98dc4c --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_os.py @@ -0,0 +1,17 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class CxTowerOs(models.Model): + """Operating System""" + + _name = "cx.tower.os" + _inherit = [ + "cx.tower.reference.mixin", + ] + _description = "Cetmix Tower Operating System" + _order = "name" + + color = fields.Integer(help="For better visualization in views") + parent_id = fields.Many2one(string="Previous Version", comodel_name="cx.tower.os") diff --git a/addons/cetmix_tower_server/models/cx_tower_plan.py b/addons/cetmix_tower_server/models/cx_tower_plan.py new file mode 100644 index 0000000..9a93b5f --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_plan.py @@ -0,0 +1,423 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from operator import indexOf + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import expr_eval + +from .constants import ( + ANOTHER_PLAN_RUNNING, + PLAN_LINE_CONDITION_CHECK_FAILED, + PLAN_LINE_NOT_ASSIGNED, + PLAN_NOT_ASSIGNED, + PLAN_NOT_COMPATIBLE_WITH_SERVER, +) + + +class CxTowerPlan(models.Model): + """Cetmix Tower flight plan""" + + _name = "cx.tower.plan" + _description = "Cetmix Tower Flight Plan" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.access.mixin", + "cx.tower.access.role.mixin", + "cx.tower.tag.mixin", + ] + _order = "name asc" + + active = fields.Boolean(default=True) + allow_parallel_run = fields.Boolean( + help="If enabled, multiple instances of the same flight plan " + "can be run on the same server at the same time.\n" + "Otherwise, ANOTHER_PLAN_RUNNING status will be returned if another" + " instance of the same flight plan is already running" + ) + + color = fields.Integer(help="For better visualization in views") + server_ids = fields.Many2many(string="Servers", comodel_name="cx.tower.server") + tag_ids = fields.Many2many( + relation="cx_tower_plan_tag_rel", + column1="plan_id", + column2="tag_id", + ) + line_ids = fields.One2many( + string="Lines", + comodel_name="cx.tower.plan.line", + inverse_name="plan_id", + auto_join=True, + copy=True, + ) + command_ids = fields.Many2many( + string="Commands", + comodel_name="cx.tower.command", + relation="cx_tower_command_flight_plan_used_id_rel", + column1="plan_id", + column2="command_id", + help="Commands used in this flight plan", + compute="_compute_command_ids", + store=True, + ) + note = fields.Text() + on_error_action = fields.Selection( + string="On Error", + selection=[ + ("e", "Exit with command exit code"), + ("ec", "Exit with custom exit code"), + ("n", "Run next command"), + ], + required=True, + default="e", + help="This action will be triggered on error " + "if no command action can be applied", + ) + custom_exit_code = fields.Integer( + help="Will be used instead of the command exit code" + ) + + access_level_warn_msg = fields.Text( + compute="_compute_command_access_level", + compute_sudo=True, + ) + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_plan_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_plan_manager_rel", + ) + + @api.depends("line_ids.command_id.access_level", "access_level") + def _compute_command_access_level(self): + """Check if the access level of a command in the plan + is higher than the plan's access level""" + for record in self: + commands = record.mapped("line_ids").mapped("command_id") + # Retrieve all commands associated with the flight plan + commands_with_higher_access = commands.filtered( + lambda c, access_level=record.access_level: c.access_level + > access_level + ) + if commands_with_higher_access: + command_names = ", ".join(commands_with_higher_access.mapped("name")) + record.access_level_warn_msg = _( + "The access level of command(s) '%(command_names)s' included in the" + " current Flight plan is higher than the access level of the" + " Flight plan itself. Please ensure that you want to allow" + " those commands to be run anyway.", + command_names=command_names, + ) + else: + record.access_level_warn_msg = False + + @api.depends("line_ids", "line_ids.command_id") + def _compute_command_ids(self): + """Compute command ids""" + for plan in self: + plan.command_ids = plan.line_ids.command_id + + def action_open_plan_logs(self): + """ + Open current flight plan log records + """ + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + action["domain"] = [("plan_id", "=", self.id)] + return action + + def _get_dependent_model_relation_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_dependent_model_relation_fields() + return res + ["line_ids"] + + def _is_plan_incompatible_with_server(self, server): + """ + Check if the flight plan is compatible with the server. + Note: this function uses the inverse logic to simplify the checks. + + Args: + server (cx.tower.server()): Server object + + Returns: + Char or False: Incompatible reason or False if compatible + """ + + # Check if the flight plan is compatible with the server + if not self.server_ids: + return False + if server.id not in self.server_ids.ids: + return _("Flight plan is not compatible with the server") + + # Check if the flight plan commands are compatible with the server + for command in self.command_ids: + # Check the entire command first + if not command._check_server_compatibility(server): + return _( + "Command %(command_name)s is not compatible with the server", + command_name=command.name, + ) # pylint: disable=no-member + + # Check if the nested flight plan is compatible with the server + if command.action == "plan": + plan_check_result = ( + command.flight_plan_id._is_plan_incompatible_with_server(server) + ) + if plan_check_result: + return plan_check_result + + return False + + def _get_post_create_fields(self): + res = super()._get_post_create_fields() + return res + ["line_ids"] + + def _run_single(self, server, jet_template=None, jet=None, **kwargs): + """Run single Flight Plan on a single server + + Args: + server (cx.tower.server()): Server object + jet_template (cx.tower.jet.template()): jet template record + jet (cx.tower.jet()): jet record + kwargs (dict): Optional arguments + Following are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "variable_values", dict(): custom variable values + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Will be applied only if user has write access to the server. + + Returns: + log_record (cx.tower.plan.log()): plan log record + """ + + self.ensure_one() + # Ensure we have a single server record + server.ensure_one() + + # Check if Jet belongs to the server + if jet and jet.server_id != server: + raise ValidationError( + _( + "Jet %(jet)s does not belong to server %(server)s", + jet=jet.name, + server=server.name, + ) + ) + + # Check plan access before running + # This is needed to avoid possible access violations + self.check_access("read") + + # Save jet template and jet in kwargs + plan_log_vals = kwargs.get("plan_log", {}) + if jet_template: + plan_log_vals["jet_template_id"] = jet_template.id + if jet: + plan_log_vals["jet_id"] = jet.id + kwargs["plan_log"] = plan_log_vals + + # Access log as root to bypass access restrictions + plan_log_obj = self.env["cx.tower.plan.log"].sudo() + + # Check if flight plan and all its commands can be run on this server + # This check is skipped if 'from_command' context key is set to True + if not self.env.context.get("from_command"): + plan_is_incompatible = self._is_plan_incompatible_with_server(server) + if plan_is_incompatible: + # Create a log record with the custom message and exit + plan_log_kwargs = kwargs.get("plan_log", {}) + plan_log_kwargs["custom_message"] = plan_is_incompatible + kwargs["plan_log"] = plan_log_kwargs + plan_log = plan_log_obj.record( + server=server, + plan=self, + status=PLAN_NOT_COMPATIBLE_WITH_SERVER, + **kwargs, + ) + return plan_log + + # Check if the same plan is being run on this server right now + if not self.allow_parallel_run or self.env.context.get( + "prevent_plan_recursion" + ): + domain = [ + ("server_id", "=", server.id), + ("plan_id", "=", self.id), # type: ignore + ("is_running", "=", True), + ] + if jet_template: + domain.append(("jet_template_id", "=", jet_template.id)) + if jet: + domain.append(("jet_id", "=", jet.id)) + running_count = plan_log_obj.search_count(domain=domain) + if running_count > 0: + plan_log = plan_log_obj.record( + server=server, plan=self, status=ANOTHER_PLAN_RUNNING, **kwargs + ) + return plan_log + + # Start Flight Plan and return the log record + return plan_log_obj.start( + server=server, + plan=self, + **kwargs, + ) + + def _get_next_action_values(self, command_log): + """Get next action values based of previous command result: + + - Action to proceed + - Exit code + - Next line of the plan if next line should be run + + Args: + command_log (cx.tower.command.log()): Command log record + + Returns: + action, exit_code, next_line (Selection, Integer, cx.tower.plan.line()) + + """ + # Iterate all actions and return the first matching one. + # If no action is found return the default plan values + # If the line is the last one return last command exit code + + if not command_log.plan_log_id: # Exit with custom code "Plan not found" + return "ec", PLAN_NOT_ASSIGNED, None + + current_line = command_log.plan_log_id.plan_line_executed_id + if not current_line: + return "ec", PLAN_LINE_NOT_ASSIGNED, None + + # Default values + exit_code = command_log.command_status + server = command_log.server_id + jet_template = command_log.jet_template_id + jet = command_log.jet_id + + # Check line condition + variable_values = ( + command_log.variable_values or command_log.plan_log_id.variable_values or {} + ) + if not current_line._is_executable_line( + server=server, + jet_template=jet_template, + jet=jet, + variable_values=variable_values, + ): + # Immediately return to the next line if condition fails + return self._get_next_action_state( + "n", PLAN_LINE_CONDITION_CHECK_FAILED, current_line + ) + + # Check plan action lines + for action_line in current_line.action_ids: + conditional_expression = ( + f"{exit_code} {action_line.condition} {action_line.value_char}" + ) + # Evaluate expression using safe_eval + if expr_eval(conditional_expression): + action = action_line.action + # Use custom exit code if action requires it + if action == "ec" and action_line.custom_exit_code is not None: + exit_code = action_line.custom_exit_code + + # Apply action-defined values into the variable values context + for variable_value in action_line.variable_value_ids: + ref = variable_value.variable_id.reference + variable_values[ref] = variable_value.value_char + + # Persist the updated custom values only in logs + # so they remain available within the current flight plan context + updated_values = dict(variable_values) + command_log.variable_values = updated_values + if command_log.plan_log_id: + command_log.plan_log_id.variable_values = updated_values + + return self._get_next_action_state(action, exit_code, current_line) + + # If no action matched, fallback to default ones + return self._get_next_action_state(None, exit_code, current_line) + + def _get_next_action_state(self, action, exit_code, current_line): + """ + Determine the next action, exit code, and next line based on the current state. + + Args: + action (Selection): Action to proceed + exit_code (Integer): Exit code + current_line (cx.tower.plan.line()): Current line + + Returns: + action, exit_code, next_line (Selection, Integer, cx.tower.plan.line()) + """ + lines = current_line.plan_id.line_ids + is_last_line = current_line == lines[-1] + + # If no conditions were met fallback to default ones + if not action: + action = "n" if exit_code == 0 else current_line.plan_id.on_error_action + + # Exit with custom code + if action == "ec": + exit_code = current_line.plan_id.custom_exit_code + + # Determine the next line if current is not the last one + next_line = None + if action == "n" and not is_last_line: + next_line = lines[indexOf(lines, current_line) + 1] + + # Exit with command code if not exiting with custom code + if is_last_line and action != "ec": + action = "e" + + return action, exit_code, next_line + + def _run_next_action(self, command_log): + """Run next action based on the command result + + Args: + command_log (cx.tower.command.log()): Command log record + """ + self.ensure_one() + action, exit_code, plan_line = self._get_next_action_values(command_log) + plan_log = command_log.plan_log_id + + # Update log message + if exit_code == PLAN_LINE_CONDITION_CHECK_FAILED: + # save log exit code as success + exit_code = 0 + + # Run next line + if action == "n" and plan_line: + server = command_log.server_id + variable_values = command_log.variable_values or plan_log.variable_values + if plan_line._is_executable_line( + server=server, + jet_template=plan_log.jet_template_id, + jet=plan_log.jet_id, + variable_values=variable_values, + ): + plan_line._run( + server, + plan_log, + variable_values=variable_values, + ) + else: + plan_line._skip( + server, + plan_log, + log={"variable_values": dict(variable_values or {})}, + ) + + # Exit + if action in ["e", "ec"]: + plan_log.finish(exit_code) + + # NB: we are not putting any fallback here in case + # someone needs to inherit and extend this function diff --git a/addons/cetmix_tower_server/models/cx_tower_plan_line.py b/addons/cetmix_tower_server/models/cx_tower_plan_line.py new file mode 100644 index 0000000..29798dd --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_plan_line.py @@ -0,0 +1,315 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + +from .constants import PLAN_LINE_CONDITION_CHECK_FAILED + +_logger = logging.getLogger(__name__) + + +class CxTowerPlanLine(models.Model): + """Flight Plan Line""" + + _name = "cx.tower.plan.line" + _inherit = [ + "cx.tower.reference.mixin", + ] + _order = "sequence, plan_id" + _description = "Cetmix Tower Flight Plan Line" + + active = fields.Boolean(related="plan_id.active", readonly=True) + sequence = fields.Integer(default=10) + name = fields.Char(related="command_id.name", readonly=True) + plan_id = fields.Many2one( + string="Flight Plan", + comodel_name="cx.tower.plan", + auto_join=True, + ondelete="cascade", + ) + action = fields.Selection( + selection=lambda self: self.command_id._selection_action(), + compute="_compute_action", + required=True, + readonly=False, + ) + command_id = fields.Many2one( + comodel_name="cx.tower.command", + required=True, + ondelete="restrict", + domain="[('action', '=', action)]", + ) + note = fields.Text(related="command_id.note", readonly=True) + path = fields.Char( + help="Location where command will be executed. Overrides command default path. " + "You can use {{ variables }} in path", + ) + + use_sudo = fields.Boolean( + help="Will use sudo based on server settings." + "If no sudo is configured will run without sudo" + ) + action_ids = fields.One2many( + string="Actions", + comodel_name="cx.tower.plan.line.action", + inverse_name="line_id", + auto_join=True, + copy=True, + help="Actions trigger based on command result." + " If empty next command will be executed", + ) + command_code = fields.Text( + related="command_id.code", + readonly=True, + ) + tag_ids = fields.Many2many(related="command_id.tag_ids", readonly=True) + access_level = fields.Selection( + related="plan_id.access_level", + readonly=True, + store=True, + ) + condition = fields.Char( + help="Conditions under which this Flight Plan Line " + "will be launched. e.g.: {{ odoo_version}} == '14.0'", + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_plan_line_variable_rel", + column1="plan_line_id", + column2="variable_id", + string="Variables", + compute="_compute_variable_ids", + store=True, + ) + # -- Command related entities + plan_run_id = fields.Many2one( + comodel_name="cx.tower.plan", + related="command_id.flight_plan_id", + readonly=True, + string="Run Flight Plan", + ) + plan_run_line_ids = fields.One2many( + comodel_name="cx.tower.plan.line", + related="command_id.flight_plan_id.line_ids", + string="Flight Plan Lines", + readonly=True, + ) + file_template_id = fields.Many2one( + comodel_name="cx.tower.file.template", + related="command_id.file_template_id", + readonly=True, + ) + file_template_code = fields.Text( + string="Template Code", + related="file_template_id.code", + readonly=True, + ) + + @api.depends("condition") + def _compute_variable_ids(self): + """ + Compute variable_ids based on condition field. + """ + template_mixin_obj = self.env["cx.tower.template.mixin"] + for record in self: + record.variable_ids = template_mixin_obj._prepare_variable_commands( + ["condition"], force_record=record + ) + + def _compute_action(self): + """ + Compute action based on command. + """ + + # We set action only once, so there is no 'depends' in this function + for record in self: + if record.action: + continue + if record.command_id: + record.action = record.command_id.action + else: + record.action = False + + @api.constrains("command_id") + def _check_command_id(self): + """ + Check recursive plan line execution. + """ + for line in self: + # Check recursive plan line execution + visited_plans = set() + self._check_recursive_plan(line.command_id, visited_plans) + + @api.onchange("action") + def _inverse_action(self): + """ + Reset command when action changes. + """ + self.command_id = False + + def _check_recursive_plan(self, command, visited_plans): + """ + Recursively check if the command plan creates a cycle. + Raise a ValidationError if a cycle is detected. + """ + if command.flight_plan_id and command.action == "plan": + if command.flight_plan_id.id in visited_plans: + raise ValidationError( + _( + "Recursive plan call detected in plan %(name)s.", + name=command.flight_plan_id.name, + ) + ) + visited_plans.add(command.flight_plan_id.id) + # recursively check the lines in the plan + for line in command.flight_plan_id.line_ids: + self._check_recursive_plan(line.command_id, visited_plans) + + def _run(self, server, plan_log_record, **kwargs): + """Run command from the Flight Plan line + + Args: + server (cx.tower.server()): Server object + plan_log_record (cx.tower.plan.log()): Log record object + kwargs (dict): Optional arguments + Following are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to command logger} + - "key": {values passed to key parser} + + """ + self.ensure_one() + + # Set current line as currently executed in log + plan_log_record.plan_line_executed_id = self + + # It is necessary to save information about which plan log + # was created for a command log that has the command action “plan” + flight_plan_command_log = kwargs.get("flight_plan_command_log") + if flight_plan_command_log: + flight_plan_command_log.triggered_plan_log_id = plan_log_record.id + + # Pass plan_log to command so it will be saved in command log + log_vals = kwargs.get("log", {}) + log_vals.update({"plan_log_id": plan_log_record.id}) + kwargs.update({"log": log_vals}) + + # Set 'sudo' value + use_sudo = self.use_sudo and server.use_sudo + + # Use sudo to bypass access rules for execute command with higher access level + command_as_root = self.sudo().command_id + + # Set path + path = self.path or command_as_root.path + if plan_log_record.waypoint_id: + kwargs["waypoint"] = plan_log_record.waypoint_id + server.run_command( + command=command_as_root, + path=path, + sudo=use_sudo, + jet_template=plan_log_record.jet_template_id, + jet=plan_log_record.jet_id, + **kwargs, + ) + + def _is_executable_line( + self, server, jet_template=None, jet=None, variable_values=None + ): + """ + Check if this line can be executed based on its condition. + + Args: + server (cx.tower.server()): The server on which conditions are checked. + jet_template (cx.tower.jet.template()): The jet template being used. + jet (cx.tower.jet()): The jet being used. + variable_values (dict, optional): Custom values provided when running the + flight plan. These values are merged with server variables when + rendering the condition. + + Returns: + bool: True if the line can be executed, otherwise False. + """ + self.ensure_one() + condition = self.condition + if condition: + variables = self.command_id.get_variables_from_code(condition) # pylint: disable=no-member + if variables: + variable_obj = self.env["cx.tower.variable"] + server_values = variable_obj._get_variable_values_by_references( + variables, + server=server, + jet_template=jet_template, + jet=jet, + ) + # Merge with custom values passed to the flight plan (if any) + merged_values = {**server_values, **(variable_values or {})} + if merged_values: + condition = self.command_id.render_code_custom( + condition, pythonic_mode=True, **merged_values + ) + + # For evaluate a string that contains an expression that mostly uses + # Python constants, arithmetic expressions and the objects directly provided + # in context we need use `safe_eval` + # We catch all exceptions and return False to avoid raising an exception + try: + result = safe_eval(condition) + except Exception as e: + _logger.error( + "Error evaluating condition '%s' for plan line '%s' " + "in plan '%s' for server '%s'. Line is skipped. Error: %s", + condition, + self.name, + self.plan_id.name, + server.name, + str(e), + ) + result = False + return result + + return True # Assume the line can be executed if no condition is specified + + def _skip(self, server, plan_log_record, **kwargs): + """ + Triggered when plan line skipped by condition + """ + self.ensure_one() + + # Set current line as currently executed in log + plan_log_record.plan_line_executed_id = self + + # Log the unsuccessful execution attempt + now = fields.Datetime.now() + log_vals = kwargs.get("log", {}) + log_vals.update( + { + "plan_log_id": plan_log_record.id, + "condition": self.condition, + "is_skipped": True, + } + ) + + self.env["cx.tower.command.log"].record( + server_id=server.id, + command_id=self.command_id.id, # pylint: disable=no-member + start_date=now, + finish_date=now, + status=PLAN_LINE_CONDITION_CHECK_FAILED, + error=_("Plan line condition check failed."), + **log_vals, + ) + + def _get_dependent_model_relation_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_dependent_model_relation_fields() + return res + ["action_ids"] + + def _get_pre_populated_model_data(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_pre_populated_model_data() + res.update({"cx.tower.plan.line": ["cx.tower.plan", "plan_id"]}) + return res diff --git a/addons/cetmix_tower_server/models/cx_tower_plan_line_action.py b/addons/cetmix_tower_server/models/cx_tower_plan_line_action.py new file mode 100644 index 0000000..04cdcc2 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_plan_line_action.py @@ -0,0 +1,101 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class CxTowerPlanLineAction(models.Model): + """Flight Plan Line Action""" + + _inherit = ["cx.tower.variable.mixin", "cx.tower.reference.mixin"] + _name = "cx.tower.plan.line.action" + _description = "Cetmix Tower Flight Plan Line Action" + + active = fields.Boolean(default=True) + name = fields.Char(compute="_compute_name") + sequence = fields.Integer(default=10) + line_id = fields.Many2one( + comodel_name="cx.tower.plan.line", auto_join=True, ondelete="cascade" + ) + plan_id = fields.Many2one( + comodel_name="cx.tower.plan", + related="line_id.plan_id", + store=True, + readonly=True, + ) + condition = fields.Selection( + selection=[ + ("==", "=="), + ("!=", "!="), + (">", ">"), + (">=", ">="), + ("<", "<"), + ("<=", "<="), + ], + required=True, + ) + value_char = fields.Char(string="Result", required=True) + action = fields.Selection( + selection=[ + ("e", "Exit with command exit code"), + ("ec", "Exit with custom exit code"), + ("n", "Run next command"), + ], + required=True, + default="n", + ) + custom_exit_code = fields.Integer( + help="Will be used instead of the command exit code" + ) + access_level = fields.Selection( + related="line_id.access_level", + readonly=True, + store=True, + ) + variable_value_ids = fields.One2many( + # Other field properties are defined in mixin + inverse_name="plan_line_action_id", + copy=True, + ) + + @api.depends("condition", "action", "value_char") + def _compute_name(self): + action_selection_vals = dict(self._fields["action"].selection) # type: ignore + for rec in self: + # Some values are not updated until record is not saved. + # This is a disclaimer to avoid misunderstanding + if not isinstance(rec.id, int): + rec.name = _( + "...save record to see the final expression " + "or click the line to edit" + ) + + # Compose name based on values + elif rec.condition and rec.action and rec.value_char: + action_string = action_selection_vals.get(rec.action) + + # Add custom exit code if action presumes it + if rec.action == "ec": + action_string = f"{action_string} {rec.custom_exit_code}" + rec.name = " ".join( + ( + _("If exit code"), + rec.condition, + rec.value_char, + _("then"), + action_string, + ) + ) + else: + rec.name = _("Wrong action") + + def _get_dependent_model_relation_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_dependent_model_relation_fields() + return res + ["variable_value_ids"] + + def _get_pre_populated_model_data(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_pre_populated_model_data() + res.update({"cx.tower.plan.line.action": ["cx.tower.plan.line", "line_id"]}) + return res diff --git a/addons/cetmix_tower_server/models/cx_tower_plan_log.py b/addons/cetmix_tower_server/models/cx_tower_plan_log.py new file mode 100644 index 0000000..971c229 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_plan_log.py @@ -0,0 +1,531 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, fields, models + +from .constants import PLAN_IS_EMPTY, PLAN_STOPPED + +_logger = logging.getLogger(__name__) + + +class CxTowerPlanLog(models.Model): + """Flight Plan Log""" + + _name = "cx.tower.plan.log" + _description = "Cetmix Tower Flight Plan Log" + _order = "start_date desc, id desc" + + active = fields.Boolean(default=True) + name = fields.Char(compute="_compute_name", compute_sudo=True, store=True) + label = fields.Char( + help="Custom label. Can be used for search/tracking", + index="trigram", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", required=True, index=True, ondelete="cascade" + ) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + readonly=True, + index=True, + ondelete="cascade", + ) + jet_template_install_id = fields.Many2one( + string="Jet Template Install Job", + comodel_name="cx.tower.jet.template.install", + readonly=True, + ondelete="cascade", + index=True, + help="Jet Template Install/Uninstall record being run. ", + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + readonly=True, + index=True, + ondelete="cascade", + ) + jet_action_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + readonly=True, + help="Used to track flight plans executed by jet actions", + ) + waypoint_id = fields.Many2one( + comodel_name="cx.tower.jet.waypoint", + help="Waypoint this plan log belongs to", + ) + + plan_id = fields.Many2one( + string="Flight Plan", + comodel_name="cx.tower.plan", + required=True, + index=True, + ondelete="cascade", + ) + access_level = fields.Selection( + related="plan_id.access_level", + readonly=True, + store=True, + index=True, + ) + + # -- Time + start_date = fields.Datetime(string="Started") + finish_date = fields.Datetime(string="Finished") + duration = fields.Float( + help="Time consumed for execution, seconds", + compute="_compute_duration", + store=True, + ) + duration_current = fields.Float( + string="Duration, sec", + compute="_compute_duration_current", + help="For how long a flight plan is already running", + ) + + # -- Commands + is_running = fields.Boolean( + help="Plan is being executed right now", compute="_compute_duration", store=True + ) + is_stopped = fields.Boolean( + string="Stopped", default=False, help="Flight plan was stopped by user" + ) + plan_line_executed_id = fields.Many2one( + comodel_name="cx.tower.plan.line", + help="Flight Plan line that is being currently executed", + ) + command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", inverse_name="plan_log_id", auto_join=True + ) + plan_status = fields.Integer( + string="Status", + help="0 if plan is finished successfully. \n" + "-301 if another instance of this flight plan is running, \n" + "-302 if plan is empty, \n" + "-303 if plan reference is missing, \n" + "-304 if plan line reference is missing, \n" + "-306 if plan is not compatible with server,\n" + "-308 if plan is stopped by user", + ) + custom_message = fields.Text( + help="Custom message to be displayed in the plan log", + ) + parent_flight_plan_log_id = fields.Many2one( + "cx.tower.plan.log", string="Main Log", ondelete="cascade" + ) + scheduled_task_id = fields.Many2one( + "cx.tower.scheduled.task", + ondelete="set null", + help="Scheduled task that triggered this flight plan", + ) + variable_values = fields.Json( + default={}, + help="Custom variable values passed to the flight plan", + ) + + @api.depends("server_id.name", "plan_id.name") + def _compute_name(self): + for rec in self: + rec.name = ": ".join((rec.server_id.name, rec.plan_id.name)) # type: ignore + + @api.depends("start_date", "finish_date") + def _compute_duration(self): + for plan_log in self: + # Not started yet + if not plan_log.start_date: + continue + + # If plan is finished, compute duration + if plan_log.finish_date: + plan_log.update( + { + "duration": ( + plan_log.finish_date - plan_log.start_date + ).total_seconds(), + "is_running": False, + } + ) + continue + + # If plan is running, set is_running to True + plan_log.is_running = True + + @api.depends("is_running") + def _compute_duration_current(self): + """Shows relative time between now() and start time for running plans, + and computed duration for finished ones. + """ + now = fields.Datetime.now() + for plan_log in self: + if plan_log.is_running: + plan_log.duration_current = (now - plan_log.start_date).total_seconds() + else: + plan_log.duration_current = plan_log.duration + + def start(self, server, plan, start_date=None, **kwargs): + """ + Runs plan on server. + Creates initial log records for each command that cannot be executed until + it finds the first executable command. + + Args: + server (cx.tower.server()) server. + plan (cx.tower.plan()) Flight Plan. + start_date (datetime) flight plan start date time. + **kwargs (dict): optional values + Following keys are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "no_command_log" (bool): If True, no logs will be recorded for + non-executable lines. + - "variable_values", dict(): custom variable values + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Will be applied only if user has write access to the server. + Returns: + cx.tower.plan.log(): New flightplan log record. + """ + + def get_executable_line( + plan, server, jet_template=None, jet=None, variable_values=None + ): + """ + Generator to get each line and check if it's executable. + Args: + plan (cx.tower.plan()): Flight Plan. + server (cx.tower.server()): Server. + jet_template (cx.tower.jet.template()): Jet Template. + jet (cx.tower.jet()): Jet. + Returns: + tuple: (line, is_executable) + """ + for line in plan.line_ids: + yield ( + line, + line._is_executable_line( + server=server, + jet_template=jet_template, + jet=jet, + variable_values=variable_values, + ), + ) + + vals = { + "server_id": server.id, + "plan_id": plan.id, + "is_running": True, + "start_date": start_date or fields.Datetime.now(), + } + + # Extract and apply plan log kwargs + plan_log_kwargs = kwargs.get("plan_log") + if plan_log_kwargs: + vals.update(plan_log_kwargs) + + # Extract and apply variable values + variable_values = kwargs.get("variable_values") + if variable_values: + vals["variable_values"] = variable_values + + plan_log = self.sudo().create(vals) + + # Process each line until the first executable one is found + for line, is_executable in get_executable_line( + plan=plan, + server=server, + jet_template=plan_log.jet_template_id, + jet=plan_log.jet_id, + variable_values=variable_values, + ): + if is_executable: + line._run(server=server, plan_log_record=plan_log, **kwargs) + break + else: + if self._context.get("no_command_log"): + continue + line._skip( + server, + plan_log, + log={ + "variable_values": dict(variable_values or {}), + "jet_template_id": plan_log.jet_template_id.id + if plan_log.jet_template_id + else None, + "jet_id": plan_log.jet_id.id if plan_log.jet_id else None, + }, + ) + break + else: + plan_log.finish(plan_status=PLAN_IS_EMPTY) + + return plan_log + + def stop(self): + """ + Force stop this plan log (and currently running command if possible). + """ + user_name = self.env.user.name + for log in self: + if not log.is_running: + continue + + # Finish plan log + log.finish( + plan_status=PLAN_STOPPED, + custom_message=_("Stopped by user %(user)s", user=user_name), + is_stopped=True, + ) + + # Stop running command + running_cmd_logs = log.command_log_ids.filtered(lambda c: c.is_running) + running_cmd_logs.stop() + + def action_stop(self): + """ + Action to stop the running plans. + """ + self.stop() + + if len(self) > 1: # more than one plan is running + title = _("Flight Plans Stopped") + message = ", ".join([plan.name for plan in self]) + else: + title = _("Flight Plan Stopped") + message = self.name + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": title, + "message": message, + "sticky": False, + "next": { + "type": "ir.actions.act_window_close", + }, + }, + } + + def finish(self, plan_status, **kwargs): + """Finish plan execution + + Args: + plan_status (Integer) plan execution code + **kwargs (dict): optional values + """ + self.ensure_one() + + values = { + "is_running": False, + "plan_status": plan_status, + "finish_date": fields.Datetime.now(), + } + + # Apply kwargs + if kwargs: + values.update(kwargs) + self.sudo().write(values) + + # Call the plan finished hook + # Use try/except to ensure that the plan finished hook is called + try: + # Savepoint to ensure that values are stored + # if something goes wrong. + with self.env.cr.savepoint(): + self._plan_finished() + except Exception as e: + _logger.warning( + "Post-finish hook for plan '%s' failed: %s", self.plan_id.name, e + ) + # Continue with the rest of the logic + + # Jet Template action: only if it's not a sub-plan + # NB: Jet Template is always set automatically even if + # it's not provided explicitly when the plan is run. + if not self.jet_template_id or self.parent_flight_plan_log_id: + return + + # Waypoint action: only if it's not a sub-plan + if self.waypoint_id: + try: + with self.env.cr.savepoint(): + self.waypoint_id._plan_finished(self) + except Exception as e: + _logger.warning( + "Post-finish hook for waypoint '%s' failed: %s", + self.waypoint_id.name, + e, + ) + + # Finish template install/uninstall + if self.jet_template_install_id: + try: + with self.env.cr.savepoint(): + self.jet_template_install_id._flight_plan_finished( + plan_status=self.plan_status, + ) + except Exception as e: + _logger.warning( + "Post-finish hook for template install/uninstall " + "'%s'" + " failed: %s", + self.jet_template_install_id.name, + e, + ) + + # Jet + if self.jet_id and self.jet_action_id: + try: + with self.env.cr.savepoint(): + self.jet_id._flight_plan_finished( + plan_status=self.plan_status, + ) + except Exception as e: + _logger.warning( + "Post-finish hook for jet '%s' failed: %s", self.jet_id.name, e + ) + + def record(self, server, plan, status, **kwargs): + """ + Record plan log without running it. + + Args: + server (cx.tower.server()) server. + plan (cx.tower.plan()) Flight Plan. + status (int) plan execution code + start_date (datetime) flight plan start date time. + finish_date (datetime) flight plan finish date time. + **kwargs (dict): optional values + Following keys are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "no_command_log" (bool): If True, no logs will be recorded for + non-executable lines. + Returns: + cx.tower.plan.log(): New flightplan log record. + """ + + vals = { + "server_id": server.id, + "plan_id": plan.id, + "start_date": fields.Datetime.now(), + } + + # Extract and apply plan log kwargs + plan_log_kwargs = kwargs.get("plan_log") + if plan_log_kwargs: + vals.update(plan_log_kwargs) + + plan_log = self.sudo().create(vals) + plan_log.finish(plan_status=status) + return plan_log + + def _plan_finished(self): + """Triggered when flightplan in finished + Inherit to implement your own hooks + + Returns: + bool: True if event was handled + """ + self.ensure_one() + + # Do not notify if a plan that was run from another plan has been executed + if self.parent_flight_plan_log_id: + return True + + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_success = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_success" + ) + notification_type_error = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_error" + ) + + # Prepare notifications + if not notification_type_success and not notification_type_error: + return True + + # Use context timestamp to avoid timezone issues + context_timestamp = fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ) + + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Log") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + + # Send notification only if not a jet-related plan + if ( + self.plan_status == 0 + and notification_type_success + and not self.jet_template_id + ): + # Success notification + self.create_uid.notify_success( + message=_( + "%(timestamp)s
    " "Flight Plan '%(name)s' finished successfully", + name=self.plan_id.name, + timestamp=context_timestamp, + ), + title=self.server_id.name, + sticky=notification_type_success == "sticky", + action=action, + ) + + # Error notification + # They are shown for jet-related plans and template installation/uninstallation + # as well to simplify the debugging process. + if self.plan_status != 0 and notification_type_error: + self.create_uid.notify_danger( + message=_( + "%(timestamp)s
    " + "Flight Plan '%(name)s'" + " finished with error", + name=self.plan_id.name, + timestamp=context_timestamp, + ), + title=self.server_id.name, + sticky=notification_type_error == "sticky", + action=action, + ) + + return True + + def _plan_command_finished(self, command_log): + """This function is triggered when a command from this log is finished. + Next action is triggered based on command status (ak exit code) + + Args: + command_log (cx.tower.command.log()): Command log object + + """ + self.ensure_one() + + # Prevent scheduling further actions if this log was stopped + if self.is_stopped: + return + + # Update plan log variable values from command log + # Overwrite with command log values (last command's values take precedence) + self.variable_values = command_log.variable_values + + # Get next line to execute + self.plan_id._run_next_action(command_log) # type: ignore diff --git a/addons/cetmix_tower_server/models/cx_tower_reference_mixin.py b/addons/cetmix_tower_server/models/cx_tower_reference_mixin.py new file mode 100644 index 0000000..959477b --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_reference_mixin.py @@ -0,0 +1,480 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import re +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.osv import expression +from odoo.tools import ormcache + + +class CxTowerReferenceMixin(models.AbstractModel): + """ + Used to create and manage unique record references. + """ + + _name = "cx.tower.reference.mixin" + _description = "Cetmix Tower reference mixin" + _rec_names_search = ["name", "reference"] + + # Used to check the reference before it's being fixed. + # Ensures there's at least one valid symbol + # that can be used later as a new reference basis. + REFERENCE_PRELIMINARY_PATTERN = r"[\da-zA-Z]" + + name = fields.Char(required=True, index="trigram") + reference = fields.Char( + index=True, + help="Can contain English letters, digits and '_'. Leave blank to autogenerate", + ) + + _sql_constraints = [ + ("reference_unique", "UNIQUE(reference)", "Reference must be unique") + ] + + @api.model_create_multi + def create(self, vals_list): + """ + Overrides create to ensure 'reference' is auto-corrected + or validated for each record. + + Add `reference_mixin_override` context key to skip the reference check + + Args: + vals_list (list[dict]): List of dictionaries with record values. + + Returns: + Records: The created record(s). + """ + + if vals_list and not self._context.get("reference_mixin_override"): + # Check if we need to populate references based on parent record + auto_generate_settings = self._get_pre_populated_model_data().get( + self._name + ) + if auto_generate_settings: + parent_model, relation_field = auto_generate_settings + vals_list = self._pre_populate_references( + parent_model, relation_field, vals_list + ) + + # Fix or create references + for vals in vals_list: + if not vals: + continue + + # Remove leading and trailing whitespaces from name + vals_name = vals.get("name") + name = vals_name.strip() if vals_name else vals_name + + # Remove leading and trailing whitespaces from reference + vals_reference = vals.get("reference") + reference = vals_reference.strip() if vals_reference else vals_reference + + # Nothing can be done if no name or reference is provided + if not name and not reference: + continue + + # Save name back to vals if it was modified + if vals_name != name: + vals["name"] = name + + # Generate reference + vals.update( + {"reference": self._generate_or_fix_reference(reference or name)} + ) + + res = super().create(vals_list) + self.env.registry.clear_cache() + return res + + def write(self, vals): + """ + Updates record, auto-correcting or validating 'reference' + based on 'name' or existing value. + + Add `reference_mixin_override` context key to skip the reference check + + Args: + vals (dict): Values to update, may include 'reference'. + + Returns: + Result of the super `write` call. + """ + if not self._context.get("reference_mixin_override") and "reference" in vals: + reference = vals.get("reference", False) + if not reference: + # Get name from vals + updated_name = vals.get("name") + + # No name in vals. Update records one by one + if not updated_name: + for record in self: + record_vals = vals.copy() + record_vals.update( + {"reference": self._generate_or_fix_reference(record.name)} + ) + super(CxTowerReferenceMixin, record).write(record_vals) + return True + # Name is present in vals + reference = self._generate_or_fix_reference(updated_name) + else: + reference = self._generate_or_fix_reference(reference) + vals.update({"reference": reference}) + + res = super().write(vals) + + # Update references of dependent models + if "reference" in vals: + self._update_dependent_model_references() + # Clear caches + self.env.registry.clear_cache() + return res + + def unlink(self): + """ + Overrides unlink to clear cache for this method + """ + res = super().unlink() + self.env.registry.clear_cache() + return res + + def copy(self, default=None): + """ + Overrides the copy method to ensure unique reference values + for duplicated records. + + Args: + default (dict, optional): Default values for the new record. + + Returns: + Record: The newly copied record with adjusted defaults. + """ + self.ensure_one() + if default is None: + default = {} + + # skip copying 'name' because this function can be used in models + # where 'name' field is not stored + if not self.env.context.get("reference_mixin_skip_copy"): + default["name"] = self._get_copied_name(force_name=default.get("name")) + if "reference" not in default: + default["reference"] = self._generate_or_fix_reference(default["name"]) + return super().copy(default=default) + + def _get_reference_pattern(self): + """ + Returns the regex pattern used for validating and correcting references. + This allows for easy modification of the pattern in one place. + + Important: pattern must be enclosed in square brackets! + + Returns: + str: A regex pattern + """ + return "[a-z0-9_]" + + def _get_pre_populated_model_data(self): + """Returns List of models that should try to generate + references based on the related model reference. + + Eg flight plan lines references are generated based on the flight plan one. + + Returns: + dict: Model values dictionary: + {model_name: [parent_model, relation_field]} + """ + return {} + + def _get_extra_vals_fields(self): + """Returns list of extra fields that are needed for reference generation. + This method if used to make custom reference generation logic more flexible. + Eg for 'cx.tower.variable.value': + 'server_id', 'server_template_id', 'plan_line_action_id'. + So for common models like 'cx.tower.server' this method is not needed. + + Returns: + list: List of fields: + [field_name1, field_name2, ...] + """ + return [] + + def _get_dependent_model_relation_fields(self): + """Returns list of fields that reference dependent models. + + Eg flight plan lines references are generated based on the flight plan one. + + Returns: + list: List of fields: + [field_name1, field_name2, ...] + """ + return [] + + def _update_dependent_model_references(self): + """Update references of dependent models""" + dependent_model_relation_fields = self._get_dependent_model_relation_fields() + if dependent_model_relation_fields: + for field in dependent_model_relation_fields: + related_model_name = self[field]._name + + # Check if the related model has auto-generate settings + auto_generate_settings = ( + self[field]._get_pre_populated_model_data().get(related_model_name) + ) + if auto_generate_settings: + parent_model, relation_field = auto_generate_settings + else: + continue + + # Parse the field for all records + for record in self: + related_records = record[field] + # Get vals list + rec_vals_list = related_records.read( + [relation_field] + related_records._get_extra_vals_fields() + ) + # Transform Many2one tuples to IDs + for rv in rec_vals_list: + for k, v in rv.items(): + # Transform m2o fields from (id, name) to id + if isinstance(v, tuple): + rv[k] = v[0] + related_records._pre_populate_references( + parent_model, relation_field, rec_vals_list + ) + ref_by_id = {rv["id"]: rv["reference"] for rv in rec_vals_list} + for related_record in related_records: + related_record.reference = ref_by_id[related_record.id] + + def _generate_or_fix_reference(self, reference_source): + """ + Generate a new reference of fix an existing one. + + Args: + reference_source (str): Original string. + + Returns: + str: Generated or fixed reference. + """ + + # Check if reference matches the pattern + reference_pattern = self._get_reference_pattern() + + if re.fullmatch(rf"{reference_pattern}+", reference_source): + reference = reference_source + + # Fix reference if it doesn't match + else: + # Modify the pattern to be used in `sub` + inner_pattern = reference_pattern[1:-1] + reference = ( + re.sub( + rf"[^{inner_pattern}]", + "", + reference_source.strip().replace(" ", "_").lower(), + ) + or self._get_model_generic_reference() + ) + + # Check if the same reference already exists and add a suffix if yes + counter = 1 + final_reference = reference + + # If exclude same records from search results + if self and not self.env.context.get("reference_mixin_skip_self"): + domain = [("id", "not in", self.ids)] + else: + domain = [] + final_domain = expression.AND([domain, [("reference", "=", final_reference)]]) + + # Search all records without restrictions including archived + self_with_sudo_and_context = self.sudo().with_context(active_test=False) + while self_with_sudo_and_context.search_count(final_domain) > 0: + counter += 1 + final_reference = f"{reference}_{counter}" + final_domain = expression.AND( + [domain, [("reference", "=", final_reference)]] + ) + + return final_reference + + def _get_copied_name(self, force_name=None): + """ + Return a copied name of the record + by adding the suffix (copy) at the end + and counter until the name is unique. + + Args: + force_name (str): Used to use force name instead of record name. + + Returns: + An unique name for the copied record + """ + self.ensure_one() + original_name = force_name or self.name + copy_name = _("%(name)s (copy)", name=original_name) + + counter = 1 + # Ensures that the generated copy name is unique by + # appending a counter until a unique name is found. + while self.search_count([("name", "=", copy_name)]) > 0: + counter += 1 + copy_name = _( + "%(name)s (copy %(number)s)", + name=original_name, + number=str(counter), + ) + + return copy_name + + def _get_model_generic_reference(self): + """Get generic reference for current model. + Generic references are used as a fallback in the automatic + reference generation. + When a reference cannot be generated neither from the 'reference' + nor from the 'name' field values. + + Eg for the 'cx.tower.plan' model such reference will look like + 'tower_plan'. + + Returns: + Char: generated prefix + """ + model_prefix = self._name.replace("cx.tower.", "").replace(".", "_") + return model_prefix + + def get_by_reference(self, reference): + """Get record based on its reference. + + Important: references are case sensitive! + + Args: + reference (Char): record reference + + Returns: + Record: Record that matches provided reference + """ + return self.browse(self._get_id_by_reference(reference)) + + @ormcache("self.env.uid", "self.env.su", "reference") + def _get_id_by_reference(self, reference): + """Get record id based on its reference. + + Important: references are case sensitive! + + Args: + reference (Char): record reference + + Returns: + Record: Record id that matches provided reference + """ + records = self.search([("reference", "=", reference)]) + + # This is in case some models will remove reference uniqueness constraint + return records and records[0].id + + @api.model + def _prepare_references(self, model, key_name, vals_list): + """ + Prepare a dictionary of references for given model records. + + This function extracts unique IDs from a list of dictionaries (vals_list) + based on a specified key (key_name), fetches the corresponding records + from the specified model, and returns a dictionary mapping record IDs to + their references. + + Args: + model (str): The name of the model to fetch records from. + key_name (str): The key in the dictionaries of vals_list that contains + the record IDs. + vals_list (list of dict): A list of dictionaries containing the values + to be processed. + + Returns: + dict: A dictionary mapping record IDs to their references. + """ + if not vals_list: + # No entries to process, return an empty dictionary + return {} + + try: + CxModel = self.env[model] + except KeyError as err: + raise ValueError( + _( + ( + "Model '%(model)s' does not exist. " + "Please provide a valid model name." + ), + model=model, + ) + ) from err + + # Extract all unique ids from vals_list + line_ids = { + vals.get(key_name) + for vals in vals_list + if vals.get(key_name) and not vals.get("reference") + } + + # Fetch all line references in a single query + lines = CxModel.browse(line_ids) + return {line.id: line.reference for line in lines if line.reference} + + @api.model + def _pre_populate_references(self, model_name, field_name, vals_list): + """ + Populates reference fields in a list of dictionaries (vals_list) + intended for record creation. + + This method generates unique references for each dictionary entry in + `vals_list` based on a specified field that links to records in + another model (indicated by `model_name`). It uses existing references + from the related records as a basis and appends a suffix and an + incrementing index to ensure uniqueness. + If reference is present in values it will not be overwritten. + + Args: + model_name (str): The name of the related model to extract + reference data from. + field_name (str): The key in each dictionary in `vals_list` + containing the related record's ID. + vals_list (list of dict): A list of dictionaries where each dictionary + represents values for a new record. + + Returns: + list: The modified `vals_list`, with a unique 'reference' + entry in each dictionary. + """ + + # Extract parent record references from vals_list + parent_record_refs = self._prepare_references(model_name, field_name, vals_list) + line_index_dict = defaultdict(int) + + # Used to make reference more readable + model_reference = self._get_model_generic_reference() + + # Populate vals with references + for vals in vals_list: + # Skip if reference is provided explicitly and has symbols + existing_reference = vals.get("reference") + if existing_reference and bool( + re.search(self.REFERENCE_PRELIMINARY_PATTERN, existing_reference) + ): + continue + + # Compose based on related record reference if exists + record_id = vals.get(field_name) + if record_id and parent_record_refs.get(record_id): + line_index_dict[record_id] += 1 + line_index = line_index_dict[record_id] + vals["reference"] = ( + f"{parent_record_refs[record_id]}_{model_reference}_{line_index}" + ) + else: + # Handle cases where the field is not present + line_index_dict["no_record"] += 1 + line_index = line_index_dict["no_record"] + vals["reference"] = f"no_{model_reference}_{line_index}" + + return vals_list diff --git a/addons/cetmix_tower_server/models/cx_tower_scheduled_task.py b/addons/cetmix_tower_server/models/cx_tower_scheduled_task.py new file mode 100644 index 0000000..b6c6a4c --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_scheduled_task.py @@ -0,0 +1,442 @@ +import logging +from datetime import timedelta + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class CxTowerScheduledTask(models.Model): + """ + Scheduled Tasks. + Used to schedule commands and flight plans to run on servers and jets. + """ + + _name = "cx.tower.scheduled.task" + _description = "Scheduled Task" + _inherit = ["cx.tower.access.role.mixin", "cx.tower.reference.mixin"] + _order = "sequence, next_call" + + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + server_ids = fields.Many2many( + "cx.tower.server", + "cx_tower_scheduled_task_server_rel", + "scheduled_task_id", + "server_id", + string="Servers", + ) + server_template_ids = fields.Many2many( + string="Server Templates", + comodel_name="cx.tower.server.template", + relation="cx_tower_server_template_scheduled_task_rel", + column1="scheduled_task_id", + column2="server_template_id", + ) + jet_ids = fields.Many2many( + "cx.tower.jet", + "cx_tower_scheduled_task_jet_rel", + "scheduled_task_id", + "jet_id", + string="Jets", + ) + jet_template_ids = fields.Many2many( + string="Jet Templates", + comodel_name="cx.tower.jet.template", + relation="cx_tower_jet_template_scheduled_task_rel", + column1="scheduled_task_id", + column2="jet_template_id", + ) + action = fields.Selection( + [("command", "Command"), ("plan", "Flight Plan")], required=True + ) + command_id = fields.Many2one("cx.tower.command", string="Command") + plan_id = fields.Many2one(string="Flight Plan", comodel_name="cx.tower.plan") + is_running = fields.Boolean(default=False, readonly=True) + interval_number = fields.Integer(default=1, help="Repeat every x.") + interval_type = fields.Selection( + [ + ("minutes", "Minutes"), + ("hours", "Hours"), + ("days", "Days"), + ("dow", "Days of Week"), + ("weeks", "Weeks"), + ("months", "Months"), + ], + string="Interval Unit", + default="months", + ) + next_call = fields.Datetime( + string="Next Execution Date", + required=True, + default=fields.Datetime.now, + help="Next planned execution date for this task.", + ) + last_call = fields.Datetime( + string="Last Execution Date", help="Previous time the task ran successfully." + ) + # Days of week + monday = fields.Boolean() + tuesday = fields.Boolean() + wednesday = fields.Boolean() + thursday = fields.Boolean() + friday = fields.Boolean() + saturday = fields.Boolean() + sunday = fields.Boolean() + + custom_variable_value_ids = fields.One2many( + "cx.tower.scheduled.task.cv", + "scheduled_task_id", + string="Custom Variable Values", + ) + warning_message = fields.Text( + compute="_compute_warning_message", + ) + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_scheduled_task_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_scheduled_task_manager_rel", + ) + + _sql_constraints = [ + ( + "interval_positive", + "CHECK (interval_number > 0)", + "Interval number must be greater than zero.", + ), + ] + + @api.constrains( + "interval_type", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ) + def _check_days_of_week(self): + """ + Check if at least one day of week is selected + """ + for task in self: + if task.interval_type == "dow" and not any( + [ + task.monday, + task.tuesday, + task.wednesday, + task.thursday, + task.friday, + task.saturday, + task.sunday, + ] + ): + raise ValidationError( + _( + "At least one day of week must be selected for the task '%s'.", + task.display_name, + ) + ) + + @api.depends("interval_number", "interval_type") + def _compute_warning_message(self): + """ + Show warning on the task form if interval in the scheduled task + is less than interval in the underlaying cron job. + """ + cron = self.env.ref( + "cetmix_tower_server.ir_cron_run_scheduled_tasks", raise_if_not_found=False + ) + if not cron: + self.warning_message = False + return + + # Using now's date as the base point ensures a consistent and comparable + # reference when calculating the next scheduled execution for both the cron + # and the tasks. + now = fields.Datetime.now() + # _get_next_call is designed for tasks, but can also be used for the + # cron record, as both share the same interval fields. This keeps interval + # comparison logic consistent. + cron_next = self._get_next_call(cron, now) + + for task in self: + if task.interval_type == "dow": + task.warning_message = False + continue + task_next = self._get_next_call(task, now) + if task_next < cron_next: + task.warning_message = _( + "The selected task interval is too low in relation to the general " + "system settings. This may lead to task execution delays." + ) + else: + task.warning_message = False + + def action_run(self): + """ + Run scheduled action and reschedule next call. + """ + return self._run() + + def action_open_command_logs(self): + """ + Open current scheduled task command log records + """ + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + action["domain"] = [("scheduled_task_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_plan_logs(self): + """ + Open current scheduled task flightplan log records + """ + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + action["domain"] = [("scheduled_task_id", "=", self.id)] # pylint: disable=no-member + return action + + @api.model + def _run_scheduled_tasks(self): + """ + Cron: finds due tasks and runs their actions (command/plan). + Handles errors per-task and reserves tasks atomically to avoid double execution. + """ + now = fields.Datetime.now() + due_tasks = self.search( + [ + ("next_call", "<=", now), + ("active", "=", True), + ("is_running", "=", False), + ] + ) + if not due_tasks: + return + + due_tasks.with_context(from_cron=True)._run() + + def _run(self): + """ + Run scheduled action and reschedule next call. + """ + tasks = self._reserve_tasks() + if not tasks: + return + + if self.env.context.get("from_cron"): + # WARNING: Explicit commit! + # This commit is made **only** when called from cron (context["from_cron"]). + # Reason: To atomically reserve scheduled tasks by setting is_running=True, + # so that only one cron worker processes each task, even if multiple workers + # pick up the cron job at the same time. Without this commit, the change + # would not be visible to other transactions until the end of the cron + # transaction, leading to a race condition and possible double execution. + # Explicit commits are strongly discouraged in Odoo business logic and + # should be used only with clear justification and in strictly controlled + # contexts (like this cron scenario). Never add this commit for general + # business flows! + self.env.cr.commit() # pylint: disable=invalid-commit + + errors = [] + for task in tasks: + try: + with self.env.cr.savepoint(): + if task.action == "command" and task.command_id: + task._run_command() + elif task.action == "plan" and task.plan_id: + task._run_plan() + except Exception as e: + _logger.exception("Scheduled task %s failed: %s", task.id, e) + + task_error = _( + "Unable to run scheduled task '%(f)s'. Error: %(e)s", + f=task.display_name, + e=e, + ) + errors.append(task_error) + + finally: + finished_at = fields.Datetime.now() + # Always update the scheduling, even if the task failed + task.write( + { + "last_call": finished_at, + "next_call": self._get_next_call(task, task.next_call), + "is_running": False, + } + ) + + if errors: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Failure"), + "message": "\n".join(errors), + }, + } + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Scheduled tasks run successfully."), + }, + } + + def _get_next_call(self, task, from_date): + """ + Calculate next_call datetime + + task: cx.tower.scheduled.task + from_date: datetime + """ + if task.interval_type == "dow": + return self._get_next_call_dow(task, from_date) + + num = task.interval_number or 1 + intervals = { + "minutes": timedelta(minutes=num), + "hours": timedelta(hours=num), + "days": timedelta(days=num), + "weeks": timedelta(weeks=num), + "months": relativedelta(months=num), + } + return from_date + intervals.get(task.interval_type, timedelta()) + + def _get_task_selected_days(self, task): + """ + Get list of selected weekday numbers for a task + + task: cx.tower.scheduled.task + Returns: list of weekday numbers (0=Monday, 6=Sunday) + """ + selected_days = [] + if task.monday: + selected_days.append(0) + if task.tuesday: + selected_days.append(1) + if task.wednesday: + selected_days.append(2) + if task.thursday: + selected_days.append(3) + if task.friday: + selected_days.append(4) + if task.saturday: + selected_days.append(5) + if task.sunday: + selected_days.append(6) + return selected_days + + def _get_next_call_dow(self, task, from_date): + """ + Calculate next_call datetime for days of week interval type + + task: cx.tower.scheduled.task + from_date: datetime + """ + # Days of week: find next selected day at the same time + # weekday() returns 0=Monday, 6=Sunday + selected_days = self._get_task_selected_days(task) + if not selected_days: + raise ValidationError( + _( + "At least one day of week must be selected for the task '%s'.", + task.display_name, + ) + ) + current_weekday = from_date.weekday() + + # Find next selected day (starting from tomorrow to get next occurrence) + # Check days in current week first (after today) + next_day = None + for day in selected_days: + if day > current_weekday: + next_day = day + break + + # If no day found in current week, take first day of next week + if next_day is None: + next_day = min(selected_days) + days_ahead = (7 - current_weekday) + next_day + else: + days_ahead = next_day - current_weekday + + # Create new datetime with same time, on the next selected day + next_date = from_date + timedelta(days=days_ahead) + return next_date.replace( + hour=from_date.hour, + minute=from_date.minute, + second=from_date.second, + microsecond=from_date.microsecond, + ) + + def _run_command(self): + """Run command on selected servers.""" + variable_values = { + value.variable_id.reference: value.value_char + for value in self.custom_variable_value_ids + } + kwargs = { + "log": {"scheduled_task_id": self.id}, + "variable_values": variable_values, + } + # Run for servers + for server in self.server_ids: + server.run_command(self.command_id, **kwargs) + # Run for jets + for jet in self.jet_ids: + jet.run_command(self.command_id, **kwargs) + + def _run_plan(self): + """Run flight plan on selected servers.""" + variable_values = { + value.variable_id.reference: value.value_char + for value in self.custom_variable_value_ids + } + kwargs = { + "plan_log": {"scheduled_task_id": self.id}, + "variable_values": variable_values, + } + # Run for servers + for server in self.server_ids: + server.run_flight_plan(self.plan_id, **kwargs) + # Run for jets + for jet in self.jet_ids: + jet.run_flight_plan(self.plan_id, **kwargs) + + def _reserve_tasks(self, limit=None): + """ + Atomically select and lock free tasks for processing. + """ + sql = """ + SELECT id + FROM cx_tower_scheduled_task + WHERE is_running = FALSE AND id IN %s + ORDER BY id + """ + params = [tuple(self.ids)] + if limit: + sql += " LIMIT %s" + params.append(limit) + sql += " FOR UPDATE SKIP LOCKED" + self.env.cr.execute(sql, tuple(params)) + + task_ids = [row[0] for row in self.env.cr.fetchall()] + if not task_ids: + return self.browse() + + tasks = self.browse(task_ids) + tasks.write({"is_running": True}) + return tasks diff --git a/addons/cetmix_tower_server/models/cx_tower_scheduled_task_cv.py b/addons/cetmix_tower_server/models/cx_tower_scheduled_task_cv.py new file mode 100644 index 0000000..47a0925 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_scheduled_task_cv.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class CxTowerScheduledTaskCv(models.Model): + """ + Custom variable values for scheduled tasks. + """ + + _inherit = "cx.tower.custom.variable.value.mixin" + _name = "cx.tower.scheduled.task.cv" + _description = "Custom variable values for scheduled tasks" + + scheduled_task_id = fields.Many2one( + "cx.tower.scheduled.task", + string="Scheduled Task", + required=True, + ondelete="cascade", + ) diff --git a/addons/cetmix_tower_server/models/cx_tower_server.py b/addons/cetmix_tower_server/models/cx_tower_server.py new file mode 100644 index 0000000..41ac87e --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_server.py @@ -0,0 +1,2472 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import ast +import io +import logging +from datetime import timedelta +from functools import wraps + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.base.models.res_users import check_identity + +from ..ssh.ssh import SSHConnection, SSHManager +from .constants import ( + ANOTHER_COMMAND_RUNNING, + COMMAND_NOT_COMPATIBLE_WITH_SERVER, + COMMAND_TIMED_OUT, + COMMAND_TIMED_OUT_MESSAGE, + FILE_CREATION_FAILED, + GENERAL_ERROR, + JET_NOT_FOUND, + JET_TEMPLATE_NOT_FOUND, + NO_COMMAND_RUNNER_FOUND, + PYTHON_COMMAND_ERROR, + SSH_CONNECTION_ERROR, + WAYPOINT_CREATE_FAILED, + WAYPOINT_TEMPLATE_NOT_FOUND, +) +from .tools import generate_random_id + +_logger = logging.getLogger(__name__) + + +def ensure_ssh_disconnect(func): + """ + Decorator that ensures the SSH connection is disconnected after the transaction + completes, whether by commit or rollback. + + This decorator registers hooks (postcommit and postrollback) before calling the + decorated function. Thus, even if the function raises an exception (and it's caught + at a higher level), the hooks will still be executed, ensuring that the + SSH connection is closed. + """ + + @wraps(func) + def wrapped(self, *args, **kwargs): + # Try to obtain the SSH connection once + try: + connection = self._get_ssh_client(raise_on_error=True) + except Exception as e: + _logger.error("Error obtaining SSH connection: %s", e) + connection = None + + # Define a hook to disconnect the SSH connection using the obtained connection. + def disconnect_connection(): + if connection: + try: + connection.disconnect() + except Exception as e: + _logger.error("Error disconnecting SSH connection: %s", e) + + # Register the disconnect hook for both commit and rollback events. + self.env.cr.postcommit.add(disconnect_connection) + self.env.cr.postrollback.add(disconnect_connection) + + # Call the decorated function. + result = func(self, *args, **kwargs) + return result + + return wrapped + + +class CxTowerServer(models.Model): + """Represents a server entity. + + Keeps information required to connect and perform routine operations + such as configuration, file management etc. + """ + + _name = "cx.tower.server" + _inherit = [ + "cx.tower.access.role.mixin", + "cx.tower.variable.mixin", + "cx.tower.reference.mixin", + "cx.tower.metadata.mixin", + "mail.thread", + "mail.activity.mixin", + "cx.tower.vault.mixin", + "cx.tower.tag.mixin", + ] + _description = "Cetmix Tower Server" + _order = "name asc" + + SECRET_FIELDS = ["ssh_password", "host_key"] + + # ---- Main + active = fields.Boolean(default=True) + color = fields.Integer(help="For better visualization in views") + partner_id = fields.Many2one(comodel_name="res.partner") + status = fields.Selection( + selection=lambda self: self._selection_status(), + default=None, + required=False, + ) + + # ---- Connection + ip_v4_address = fields.Char( + string="IPv4 Address", groups="cetmix_tower_server.group_manager" + ) + ip_v6_address = fields.Char( + string="IPv6 Address", groups="cetmix_tower_server.group_manager" + ) + skip_host_key = fields.Boolean( + default=False, + help="Enable to skip host key verification", + ) + host_key = fields.Char( + groups="cetmix_tower_server.group_manager", + help="Host key to verify the server", + ) + ssh_port = fields.Integer( + string="SSH port", + required=True, + default=22, + groups="cetmix_tower_server.group_manager", + ) + ssh_username = fields.Char( + string="SSH Username", required=True, groups="cetmix_tower_server.group_manager" + ) + ssh_password = fields.Char( + string="SSH Password", + groups="cetmix_tower_server.group_manager", + ) + ssh_key_id = fields.Many2one( + comodel_name="cx.tower.key", + string="SSH Private Key", + domain=[("key_type", "=", "k")], + groups="cetmix_tower_server.group_manager", + ) + ssh_auth_mode = fields.Selection( + string="SSH Auth Mode", + selection=[ + ("p", "Password"), + ("k", "Key"), + ], + default="p", + required=True, + groups="cetmix_tower_server.group_manager", + ) + use_sudo = fields.Selection( + string="Use sudo", + selection=[("n", "Without password"), ("p", "With password")], + help="Run commands using 'sudo'. Leave empty if 'sudo' is not needed.", + groups="cetmix_tower_server.group_manager", + ) + url = fields.Char( + string="URL", help="Server web interface, eg 'https://doge.example.com'" + ) + # ---- Variables + variable_value_ids = fields.One2many( + inverse_name="server_id" # Other field properties are defined in mixin + ) + + # ---- Keys + secret_ids = fields.One2many( + string="Secrets", + comodel_name="cx.tower.key.value", + inverse_name="server_id", + groups="cetmix_tower_server.group_manager", + ) + + # ---- Attributes + os_id = fields.Many2one( + string="Operating System", + comodel_name="cx.tower.os", + groups="cetmix_tower_server.group_manager", + ) + tag_ids = fields.Many2many( + relation="cx_tower_server_tag_rel", + column1="server_id", + column2="tag_id", + ) + note = fields.Text() + command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", inverse_name="server_id" + ) + plan_log_ids = fields.One2many( + comodel_name="cx.tower.plan.log", inverse_name="server_id" + ) + file_ids = fields.One2many( + "cx.tower.file", + "server_id", + string="Files", + ) + file_count = fields.Integer( + "Total Files", + compute="_compute_counters", + ) + + # ---- Server logs + server_log_ids = fields.One2many( + string="Server Logs", + comodel_name="cx.tower.server.log", + inverse_name="server_id", + ) + + # ---- Related server template + server_template_id = fields.Many2one( + "cx.tower.server.template", + readonly=True, + index=True, + ) + + # ---- Delete plan + plan_delete_id = fields.Many2one( + "cx.tower.plan", + string="On Delete Plan", + groups="cetmix_tower_server.group_manager", + help="This Flightplan will be run when the server is deleted", + ) + + # ---- Jets + jet_template_ids = fields.Many2many( + comodel_name="cx.tower.jet.template", + relation="cx_tower_jet_template_server_rel", + column1="server_id", + column2="jet_template_id", + string="Installed Jet Templates", + readonly=True, + copy=False, + ) + jet_template_count = fields.Integer( + compute="_compute_counters", + ) + jet_ids = fields.One2many( + comodel_name="cx.tower.jet", + inverse_name="server_id", + string="Jets", + copy=False, + ) + jet_count = fields.Integer( + compute="_compute_counters", + ) + + # ---- Access. Add relation for mixin fields + + user_ids = fields.Many2many( + relation="cx_tower_server_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_server_manager_rel", + ) + + # ---- Shortcuts + shortcut_ids = fields.Many2many( + comodel_name="cx.tower.shortcut", + relation="cx_tower_server_shortcut_rel", + column1="server_id", + column2="shortcut_id", + string="Shortcuts", + ) + + # ---- Scheduled Tasks + scheduled_task_ids = fields.Many2many( + comodel_name="cx.tower.scheduled.task", + relation="cx_tower_scheduled_task_server_rel", + column1="server_id", + column2="scheduled_task_id", + string="Scheduled Tasks", + ) + + command_ids = fields.Many2many( + comodel_name="cx.tower.command", + relation="cx_tower_server_command_rel", + column1="server_id", + column2="command_id", + string="Commands", + ) + + plan_ids = fields.Many2many( + comodel_name="cx.tower.plan", + relation="cx_tower_plan_cx_tower_server_rel", + column1="cx_tower_server_id", + column2="cx_tower_plan_id", + string="Flight Plans", + ) + + def _selection_status(self): + """ + Status selection options + + Returns: + list: status selection options + """ + return [ + ("stopped", "Stopped"), + ("starting", "Starting"), + ("running", "Running"), + ("stopping", "Stopping"), + ("restarting", "Restarting"), + ("deleting", "Deleting"), + ("delete_error", "Deletion Error"), + ] + + # ---- Computed fields + def _compute_counters(self): + """ + Compute total jet template, jets and files installed on server + Note: as numbers depend on the records user has access to, + we don't store the values. + @depends is not needed because they are displayed in the views only + or computed when accessed explicitly. + """ + for server in self: + server.update( + { + "jet_template_count": len(server.jet_template_ids), + "jet_count": len(server.jet_ids), + "file_count": len(server.file_ids), + } + ) + + @api.constrains("ip_v4_address", "ip_v6_address", "ssh_auth_mode") + def _constraint_ssh_settings(self): + """Ensure SSH settings are valid. + Set 'skip_ssh_settings_check' context key to skip the checks + """ + # Skip the check if context key is set + if self._context.get("skip_ssh_settings_check"): + return + + for rec in self: + # Combine all errors together + validation_errors = [] + if not rec.ip_v4_address and not rec.ip_v6_address: + validation_errors.append( + _( + "Please provide IPv4 or IPv6 address for %(srv)s", + srv=rec.name, + ) + ) + if rec.ssh_auth_mode == "k" and not rec.ssh_key_id: + validation_errors.append( + _("Please provide SSH Key for %(srv)s", srv=rec.name) + ) + + # Raise errors if any + if validation_errors: + validation_error = "\n".join(validation_errors) + raise ValidationError(validation_error) + + @api.model_create_multi + def create(self, vals_list): + """Override create to validate SSH password before record creation.""" + # Validate SSH password before creating records + if not self._context.get("skip_ssh_settings_check"): + validation_errors = [] + for vals in vals_list: + if vals.get("ssh_auth_mode") == "p" and not vals.get("ssh_password"): + server_name = vals["name"] + validation_errors.append( + _("Please provide SSH password for %(srv)s", srv=server_name) + ) + + if validation_errors: + raise ValidationError("\n".join(validation_errors)) + + return super().create(vals_list) + + def unlink(self): + """Run post-delete flight plan""" + servers_to_delete = self.env["cx.tower.server"] + flight_plan_log_obj = self.env["cx.tower.plan.log"] + + for server in self: + # If forced, no delete plan, or already in deleting state, + # skip plan running + if ( + self._context.get("server_force_delete") + or not server.plan_delete_id + or server._is_being_deleted() + ): + servers_to_delete |= server + continue + + plan_label = generate_random_id(4) + server.plan_delete_id._run_single( + server=server, **{"plan_log": {"label": plan_label}} + ) + plan_log = flight_plan_log_obj.search( + [ + ("server_id", "=", server.id), + ("plan_id", "=", server.plan_delete_id.id), + ("label", "=", plan_label), + ] + ) + + # If plan has finished, either mark for deletion or set an error + if plan_log and plan_log.finish_date: + if plan_log.plan_status == 0: + servers_to_delete |= server + else: + server.status = "delete_error" + else: + # Plan still in progress + server.status = "deleting" + + return super(CxTowerServer, servers_to_delete).unlink() + + @api.returns("self", lambda value: value.id) + def copy(self, default=None): + default = default or {} + default["status"] = None + + file_ids = self.env["cx.tower.file"] + for file in self.file_ids: + file_ids |= file.copy( + { + "auto_sync": False, + "keep_when_deleted": True, + } + ) + default["file_ids"] = file_ids.ids + + # Copy SSH password and host key + default["ssh_password"] = self._get_secret_value("ssh_password") + default["host_key"] = self._get_secret_value("host_key") + result = super().copy(default=default) + + # Copy server secrets + for secret in self.secret_ids: + secret.sudo().copy({"server_id": result.id}) + + for var_value in self.variable_value_ids: + # Duplicating a server with variable values and then duplicating the + # duplicate causes a uniqueness constraint error for the 'reference' field + # in 'cx.tower.variable.value'. This happens because 'reference' is + # generated from the 'name' field, which is a related field fetching the + # same value across duplications. To avoid this, we pass the existing + # 'reference' as 'name' during duplication, ensuring unique 'reference' + # generation for each copy. + var_value.copy({"server_id": result.id, "name": var_value.reference}) + + for server_log in self.server_log_ids: + server_log.copy({"server_id": result.id}) + + return result + + # ------------------------------ + # ---- Actions + # ------------------------------ + + @check_identity + def action_show_host_key(self): + """Show host key""" + self.ensure_one() + try: + host_key = self._get_host_key_from_host() + is_error = False + except Exception as error: + is_error = True + host_key = error + context = { + "default_host_key": host_key, + "default_is_error": is_error, + "default_server_id": self.id, + } + return { + "type": "ir.actions.act_window", + "name": _("Host Key"), + "res_model": "cx.tower.server.host.key.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_update_server_logs(self): + """Update selected log from its source.""" + for server in self: + if server.server_log_ids: + server.server_log_ids.action_update_log() + + def action_open_command_logs(self): + """ + Open current server command log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + action["domain"] = [("server_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_plan_logs(self): + """ + Open current server flightplan log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + action["domain"] = [("server_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_run_command(self): + """ + Returns wizard action to select command and run it + """ + context = self.env.context.copy() + context.update( + { + "default_server_ids": self.ids, + } + ) + return { + "type": "ir.actions.act_window", + "name": _("Run Command"), + "res_model": "cx.tower.command.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_run_flight_plan(self): + """ + Returns wizard action to select flightplan and run it + """ + context = self.env.context.copy() + context.update( + { + "default_server_ids": self.ids, + } + ) + return { + "type": "ir.actions.act_window", + "name": _("Run Flight Plan"), + "res_model": "cx.tower.plan.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_new_jet(self): + """ + Returns wizard action to launch a jet + """ + self.ensure_one() + context = self.env.context.copy() + context.update( + { + "default_server_id": self.id, + } + ) + return { + "type": "ir.actions.act_window", + "name": _("Launch New Jet"), + "res_model": "cx.tower.jet.create.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_open_files(self): + """ + Open files of the current server + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_action" + ) + action["domain"] = [("server_id", "=", self.id)] # pylint: disable=no-member + + context = self._context.copy() + if "context" in action and isinstance((action["context"]), str): + context.update(ast.literal_eval(action["context"])) + else: + context.update(action.get("context", {})) + + context.update( + { + "default_server_id": self.id, # pylint: disable=no-member + } + ) + action["context"] = context + return action + + def action_open_jets(self): + """ + Open jets of the current server + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + action["domain"] = [("server_id", "=", self.id)] # pylint: disable=no-member + + context = self._context.copy() + if "context" in action and isinstance((action["context"]), str): + context.update(ast.literal_eval(action["context"])) + else: + context.update(action.get("context", {})) + + context.update( + { + "default_server_id": self.id, # pylint: disable=no-member + } + ) + action["context"] = context + return action + + def action_install_jet_template(self): + """Action to install the Jet Template on the selected servers.""" + self.ensure_one() + # Open the wizard to install the template on the selected servers + return { + "type": "ir.actions.act_window", + "name": _("Install Jet Template"), + "res_model": "cx.tower.jet.template.install.wiz", + "view_mode": "form", + "target": "new", + "context": { + "default_server_ids": self.ids, # pylint: disable=no-member + }, + } + + def action_uninstall_jet_template(self): + """ + Uninstall jet template from the current server + """ + self.ensure_one() + jet_template_id = self.env.context.get("jet_template_id") + if jet_template_id and jet_template_id in self.jet_template_ids.ids: + jet_template = self.env["cx.tower.jet.template"].browse(jet_template_id) + if jet_template: + jet_template.uninstall_from_servers(self) + + # ------------------------------ + # ---- Connectivity + # ------------------------------ + + def _get_ssh_client(self, raise_on_error=False, timeout=5000, skip_host_key=False): + """Create a new SSH client instance + + Args: + raise_on_error (bool, optional): If true will raise exception + in case or error, otherwise False will be returned + Defaults to True. + timeout (int, optional): SSH connection timeout in seconds. + skip_host_key (bool, optional): If true will skip host key verification. + Defaults to False. + + Raises: + ValidationError: If the provided server reference is invalid or + the server cannot be found. + + Returns: + SSH: SSH manager instance or False and exception content + """ + self.ensure_one() + self = self.sudo() + try: + host_key = self._get_secret_value("host_key") + + # Check host only if IP address is present + skip_host_key = skip_host_key or self.skip_host_key + if ( + not host_key + and not skip_host_key + and (self.ip_v4_address or self.ip_v6_address) + ): + raise ValidationError( + _("Host key not found for server %(server)s", server=self.name) + ) + + connection = SSHConnection( + host=self.ip_v4_address or self.ip_v6_address, + port=self.ssh_port, + username=self.ssh_username, + password=self._get_ssh_password(), + ssh_key=self._get_ssh_key(), + host_key=host_key if host_key and not self.skip_host_key else None, + mode=self.ssh_auth_mode, + timeout=timeout, + ) + client = SSHManager(connection) + + except Exception as e: + if raise_on_error: + raise ValidationError(_("SSH connection error %(err)s", err=e)) from e + return False, e + return client + + def test_ssh_connection( + self, + raise_on_error=True, + return_notification=True, + try_command=True, + try_file=True, + timeout=60, + ): + """Test SSH connection. + + Args: + raise_on_error (bool, optional): Raise exception in case of error. + Defaults to True. + return_notification (bool, optional): Show sticky notification + Defaults to True. + try_command (bool, optional): Try to run a command. + Defaults to True. + try_file (bool, optional): Try file operations. + Defaults to True. + timeout (int, optional): SSH connection timeout in seconds. + Defaults to 60. + + Raises: + ValidationError: In case of SSH connection error. + ValidationError: In case of no output received. + ValidationError: In case of file operations error. + + Returns: + dict: { + "status": int, + "response": str, + "error": str, + } + """ + self.ensure_one() + client = self._get_ssh_client(raise_on_error=raise_on_error, timeout=timeout) + + if not try_command and not try_file: + try: + client.connection.connect() + return { + "status": 0, + "response": _("Connection successful."), + "error": "", + } + except Exception as e: + if raise_on_error: + raise ValidationError( + _("SSH connection error %(err)s", err=e) + ) from e + return { + "status": SSH_CONNECTION_ERROR, + "response": _("Connection failed."), + "error": e, + } + + # Initialize test_result to None - will be set by try_command or try_file + test_result = None + + # Try command + if try_command: + command = self._get_connection_test_command() + test_result = self._run_command_using_ssh( + client, command_code=command, **{"raise_on_error": raise_on_error} + ) + status = test_result.get("status", 0) + response = test_result.get("response") or "" + error = test_result.get("error") or "" + + # Got an error + if raise_on_error and (status != 0 or error): + raise ValidationError( + _( + "Cannot run command\n. CODE: %(status)s. " + "RESULT: %(res)s. ERROR: %(err)s", + status=status, + res=response, + err=error, + ) + ) + + # No output received + if raise_on_error and not response: + raise ValidationError( + _( + "No output received." + " Please log in manually and check for any issues.\n" + "===\nCODE: %(status)s", + status=status, + ) + ) + + if try_file: + # test upload file + self.upload_file("test", "/tmp/cetmix_tower_test_connection.txt") + + # test download loaded file + self.download_file("/tmp/cetmix_tower_test_connection.txt") + + # remove file from server + file_test_result = self._run_command_using_ssh( + client, command_code="rm -rf /tmp/cetmix_tower_test_connection.txt" + ) + file_status = file_test_result.get("status", 0) + file_error = file_test_result.get("error") or "" + + # In case of an error, raise or replace command result with file test result + if file_status != 0 or file_error: + if raise_on_error: + raise ValidationError( + _( + "Cannot remove test file using command.\n " + "CODE: %(status)s. ERROR: %(err)s", + err=file_error, + status=file_status, + ) + ) + + # Replace command result with file test result + test_result = file_test_result + + # Return notification + if return_notification: + # Ensure test_result is set before accessing it + if test_result is None: + # Fallback: create a default success result + test_result = { + "status": 0, + "response": _("Connection test passed."), + "error": "", + } + response = test_result.get("response") or "" + return self._get_notification_action( + _( + "Connection test passed! \n%(res)s", + res=response.rstrip(), + ), + notification_type="info", + title=_("Success"), + sticky=False, + ) + + # Ensure test_result is set before returning + if test_result is None: + # Fallback: create a default success result + test_result = { + "status": 0, + "response": _("Connection test passed."), + "error": "", + } + + return test_result + + def _get_connection_test_command(self): + """Get command used to test SSH connection + + Returns: + Char: SSH command + """ + command = "uname -a" + return command + + def _get_ssh_password(self): + """Get ssh password + This function prepares and returns ssh password for the ssh connection + Override this function to implement own password algorithms + + Returns: + Char: password ready to be used for connection parameters + """ + self.ensure_one() + password = self._get_secret_value("ssh_password") + return password + + def _get_ssh_key(self): + """Get SSH key + Get private key for an SSH connection + + Returns: + Char: SSH private key + """ + self.ensure_one() + # To ensure that key will be read + # regardless of access rights + if self.sudo().ssh_key_id: + # Use context key to read secret value + ssh_key = self.ssh_key_id._get_secret_value("secret_value") + else: + ssh_key = None + return ssh_key + + @ensure_ssh_disconnect + def _get_host_key_from_host(self, raise_on_error=False, timeout=60): + """Get host key + + Args: + raise_on_error (bool, optional): If true will raise exception + in case or error, otherwise False will be returned + Defaults to True. + timeout (int, optional): SSH connection timeout in seconds. + + Raises: + ValidationError: If the provided server reference is invalid or + the server cannot be found. + + Returns: + Host key: Host key of the server + """ + self.ensure_one() + + # Check access before getting host key + # This is needed to avoid possible access violations + self.check_access("read") + + try: + # Skip host key verification to obtain the server's real host key. + client = self._get_ssh_client( + raise_on_error=raise_on_error, timeout=timeout, skip_host_key=True + ) + + # Disable host key verification for this connection only, to obtain the + # server's real host key. If a pre-configured host key is incorrect using + # it would cause a key mismatch error. By setting host_key to False + # here, we trigger AutoAddPolicy for this connection, which automatically + # accepts the server's actual host key. + client.connection.host_key = False + + ssh_client = client.connection.connect() + transport = ssh_client.get_transport() + remote_key = transport.get_remote_server_key() + host_key = remote_key.get_base64() + return host_key + except Exception as e: + if raise_on_error: + raise ValidationError( + _("Error retrieving host key: %(err)s", err=e) + ) from e + + # ------------------------------ + # ---- Command execution + # ------------------------------ + + def run_command( + self, + command, + path=None, + sudo=None, + ssh_connection=None, + jet_template=None, + jet=None, + **kwargs, + ): + """This is the main function to use for running commands. + It renders command code, creates log record and calls command runner. + + Args: + command (cx.tower.command()): Command record + path (Char): directory where command is run. + Provide in case you need to override default command value + sudo (Boolean): use sudo + Defaults to None + ssh_connection (SSH client instance, optional): SSH connection. + Pass to reuse existing connection. + This is useful in case you would like to speed up + the ssh command running. + jet_template (cx.tower.jet.template()) Jet Template record + Pass to run for specific jet template + jet (cx.tower.jet()): Jet record + Pass to run for specific jet + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "waypoint", cx.tower.jet.waypoint(): waypoint record + when running for a waypoint (e.g. from waypoint plan) + - "log", dict(): values passed to logger + - "key", dict(): values passed to key parser + - "variable_values", dict(): custom variable values + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Will be applied only if user has write access to the server. + Context: + no_command_log (Bool): set this context key to `True` + to disable log creation. + Command running results will be returned instead. + If any non command related error occurs in the command running flow + an exception will be raised. + IMPORTANT: be aware when running commands with `no_command_log=True` + because no `Allow Parallel Run` check will be done! + Returns: + dict(): command running result if `no_command_log` + context value == True else None + """ + self.ensure_one() + + # Check if jet belongs to the server + if jet and not jet.server_id == self: + raise ValidationError( + _( + "Jet '%(jet)s' doesn't belong to the server '%(server)s'.", + jet=jet.name, + server=self.name, # pylint: disable=no-member + ) + ) + + # Force set jet template from jet if jet is provided + if jet: + jet_template = jet.jet_template_id + + # Populate `sudo` value from the server settings if not provided explicitly + if self.sudo().ssh_username == "root": + sudo = False + elif sudo is None or sudo: + sudo = self.sudo().use_sudo + + # Prepare log object + log_obj = self.env["cx.tower.command.log"] + log_vals = kwargs.get("log", {}) + log_vals.update( + { + "use_sudo": sudo, + "variable_values": kwargs.get("variable_values", {}), + "jet_template_id": jet_template.id if jet_template else None, + "jet_id": jet.id if jet else None, + } + ) + # Check if no log record should be created + no_command_log = self._context.get("no_command_log") + + # Check if command can be run on this server: + # 1. Server is listed in command's server_ids + # 2. There are no server_ids at all (command is not server specific) + if not command._check_server_compatibility(self): + error = _("Command is not compatible with the server") + if no_command_log: + return { + "status": COMMAND_NOT_COMPATIBLE_WITH_SERVER, + "response": None, + "error": error, + } + log_obj.record( + server_id=self.id, # pylint: disable=no-member + command_id=command.id, + status=COMMAND_NOT_COMPATIBLE_WITH_SERVER, + error=error, + **log_vals, + ) + return + + # Check if another instance of the same command is running + another_command_running_domain = [ + ("server_id", "=", self.id), + ("command_id", "=", command.id), + ("is_running", "=", True), + ] + if jet_template: + another_command_running_domain.append( + ("jet_template_id", "=", jet_template.id) + ) + if jet: + another_command_running_domain.append(("jet_id", "=", jet.id)) + another_command_running_block = ( + not command.allow_parallel_run + and log_obj.sudo().search_count(domain=another_command_running_domain) + ) + # Another command is running, return error + if another_command_running_block: + if no_command_log: + return { + "status": ANOTHER_COMMAND_RUNNING, + "response": None, + "error": _("Another instance of the command is already running"), + } + log_obj.record( + server_id=self.id, # pylint: disable=no-member + command_id=command.id, + status=ANOTHER_COMMAND_RUNNING, + error=_("Another instance of the command is already running"), + **log_vals, + ) + return + + # Render command + custom_variable_values = kwargs.get("variable_values", {}) + rendered_command = self._render_command( + command=command, + path=path, + jet_template=jet_template, + jet=jet, + custom_variable_values=custom_variable_values, + ) + rendered_command_code = rendered_command["rendered_code"] + rendered_command_path = rendered_command["rendered_path"] + + # Prepare key renderer values + key_vals = kwargs.get("key", {}) # Get vals from kwargs + key_vals.update({"server_id": self.id}) # pylint: disable=no-member + if self.partner_id: + key_vals.update({"partner_id": self.partner_id.id}) + kwargs.update({"key": key_vals}) + + # Save rendered code to log + if no_command_log: + log_record = None + else: + log_vals.update( + {"code": rendered_command_code, "path": rendered_command_path} + ) + # Create log record + log_record = log_obj.start(self.id, command.id, **log_vals) # pylint: disable=no-member + # If on command we have the flag + if command.no_split_for_sudo: + kwargs["no_split_for_sudo"] = True + return self._command_runner_wrapper( + command=command, + log_record=log_record, + rendered_command_code=rendered_command_code, + sudo=sudo, + rendered_command_path=rendered_command_path, + ssh_connection=ssh_connection, + **kwargs, + ) + + def _render_command( + self, + command, + path=None, + jet_template=None, + jet=None, + custom_variable_values=None, + ): + """Renders command code for selected command for current server + + Args: + command (cx.tower.command): Command to render + path (Char): Path where to run the command. + Provide in case you need to override default command path + jet (cx.tower.jet()): Jet to render command for + custom_variable_values (dict): Custom variable values to render command + Returns: + dict: rendered values + { + "rendered_code": rendered command code, + "rendered_path": rendered command path + } + """ + self.ensure_one() + + variable_references = [] + + # Get variables from code + if command.code: + variables_extracted = command.get_variables_from_code(command.code) + for ve in variables_extracted: + if ve not in variable_references: + variable_references.append(ve) + + # Get variables from path + path = path if path else command.path + if path: + variables_extracted = command.get_variables_from_code(path) + for ve in variables_extracted: + if ve not in variable_references: + variable_references.append(ve) + + # If there are variables to render, get variable values + if variable_references: + # Get the variable values + variable_values = ( + self.env["cx.tower.variable"] + .sudo() + ._get_variable_values_by_references( + variable_references, server=self, jet_template=jet_template, jet=jet + ) + ) + + # Apply custom variable values only if user has write access to the server + has_write_access = self._have_access_to_server("write") + if custom_variable_values and has_write_access: + variable_values.update(custom_variable_values) + + # Render command code and path using variables + if variable_values: + if command.action == "python_code": + variable_values["pythonic_mode"] = True + + rendered_code = ( + command.render_code_custom(command.code, **variable_values) + if command.code + else False + ) + rendered_path = ( + command.render_code_custom(path, **variable_values) + if path + else False + ) + + else: + rendered_code = command.code + rendered_path = path + + return {"rendered_code": rendered_code, "rendered_path": rendered_path} + + def _have_access_to_server(self, operation): + """Check access to the server. + This is a wrapper function over the Odoo built-in ones. + It's used in order we need to implement custom access checks. + + Args: + operation (Char): Operation to check access + same format as `check_access` + Returns: + Bool: True if access is granted, False otherwise + """ + # Check access rights first + return self.has_access(operation) + + def run_flight_plan(self, flight_plan, jet_template=None, jet=None, **kwargs): + """ + Runs flight plan on the current server. + + Args: + flight_plan (cx.tower.plan()): flight plan to run + jet_template (cx.tower.jet.template()): jet template + to run the flight plan on + jet (cx.tower.jet()): jet to run the flight plan on + kwargs (dict): Optional arguments + Following are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "variable_values", dict(): custom variable values + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Will be applied only if user has write access to the server. + Returns: + log_record (cx.tower.plan.log()): plan log record + """ + + self.ensure_one() + + # Check if jet belongs to the server + if jet and not jet.server_id == self: + raise ValidationError( + _( + "Jet '%(jet)s' doesn't belong to the server '%(server)s'.", + jet=jet.name, + server=self.name, # pylint: disable=no-member + ) + ) + + # Set jet template from jet if jet is provided + if jet: + jet_template = jet.jet_template_id + + # Run flight plan + return flight_plan._run_single( + self, jet_template=jet_template, jet=jet, **kwargs + ) + + def _command_runner_wrapper( + self, + command, + log_record, + rendered_command_code, + sudo=None, + rendered_command_path=None, + ssh_connection=None, + **kwargs, + ): + """Used to implement custom runner mechanisms. + Use it in case you need to redefine the entire command running engine. + Eg it's used in `cetmix_tower_server_queue` OCA `queue_job` implementation. + + Args: + command (cx.tower.command()): Command + log_record (cx.tower.command.log()): Command log record + rendered_command_code (Text): Rendered command code. + We are passing in case it differs from command code in the log record. + sudo (Selection): Command sudo mode. Defaults to None. + rendered_command_path (Char, optional): Rendered command path. + ssh_connection (SSH client instance, optional): SSH connection to reuse. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + + Context: + use_sudo (Bool): use sudo for command running + + Returns: + dict(): command running result if `log_record` is defined else None + """ + return self._command_runner( + command=command, + log_record=log_record, + rendered_command_code=rendered_command_code, + sudo=sudo, + rendered_command_path=rendered_command_path, + ssh_connection=ssh_connection, + **kwargs, + ) + + def _command_runner( + self, + command, + log_record, + rendered_command_code, + sudo=None, + rendered_command_path=None, + ssh_connection=None, + **kwargs, + ): + """Top level command runner function. + Calls command type specific runners. + + Args: + command (cx.tower.command()): Command + log_record (cx.tower.command.log()): Command log record + rendered_command_code (Text): Rendered command code. + We are passing in case it differs from command code in the log record. + sudo (Selection): Command sudo mode. Defaults to None. + rendered_command_path (Char, optional): Rendered command path. + ssh_connection (SSH client instance, optional): SSH connection to reuse. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + Returns: + dict(): command running result if `log_record` is defined else None + """ + response = None + need_check_server_status = True + if command.action == "ssh_command": + response = self._command_runner_ssh( + log_record=log_record, + rendered_command_code=rendered_command_code, + sudo=sudo, + rendered_command_path=rendered_command_path, + ssh_connection=ssh_connection, + **kwargs, + ) + elif command.action == "file_using_template": + response = self._command_runner_file_using_template( + log_record, + rendered_command_path, + **kwargs, + ) + elif command.action == "python_code": + response = self._command_runner_python_code( + log_record, + rendered_command_code, + **kwargs, + ) + elif command.action == "jet_action": + response = self._command_runner_jet_action( + log_record, + **kwargs, + ) + elif command.action == "create_waypoint": + response = self._command_runner_create_waypoint( + log_record, + **kwargs, + ) + elif command.action == "plan": + response = self.with_context( + prevent_plan_recursion=True + )._command_runner_flight_plan( + log_record=log_record, + flight_plan=command.flight_plan_id, + **kwargs, + ) + need_check_server_status = True + else: + need_check_server_status = False + + if ( + need_check_server_status + and command.server_status + and ( + (log_record and log_record.command_status == 0) + or (response and response["status"] == 0) + ) + ): + self.write({"status": command.server_status}) + + if need_check_server_status: + return response + + error_message = _( + "No runner found for command action '%(cmd_action)s'", + cmd_action=command.action, + ) + if log_record: + log_record.finish( + finish_date=fields.Datetime.now(), + status=NO_COMMAND_RUNNER_FOUND, + response=None, + error=error_message, + ) + else: + raise ValidationError(error_message) + + def _command_runner_file_using_template_create_file( + self, log_record, server_dir, **kwargs + ): + """ + Creates a file on the server using the specified file template. + + This method is intended to allow overriding the file creation logic + and provides access to the created file object. + Args: + log_record (recordset): Command log record. + server_dir (str): The directory on the server where the file should be + created. + **kwargs: Additional keyword arguments. + + Returns: + record: The created file record. + """ + file_template_id = log_record.command_id.file_template_id + return file_template_id.create_file( + server=self, + server_dir=server_dir, + if_file_exists=log_record.command_id.if_file_exists, + jet_template=log_record.jet_template_id, + jet=log_record.jet_id, + ) + + def _command_runner_file_using_template( + self, + log_record, + server_dir, + **kwargs, + ): + """ + Run the command to create a file from a template and push to server if source + is 'tower' and pull to tower if source is 'server'. + + This function attempts to create a new file on the server/tower using the + specified file template. If the file creation is successful, it uploads + the file to the server/tower. The function logs the status of the operation + in the provided log record. + + Args: + log_record (recordset): The log record to update with the command's + status. + server_dir (str): The directory on the server where the file should be + created. + **kwargs: Additional keyword arguments. + + Returns: + None + + Raises: + Exception: If any error occurs during the file creation or upload + process, it logs the error and the exception message in the + log record. + """ + try: + # Attempt to create a new file using the template for the current server + file = self._command_runner_file_using_template_create_file( + log_record=log_record, + server_dir=server_dir, + ) + + # If file creation failed, log the failure and exit + if not file: + command_result = { + "status": FILE_CREATION_FAILED, + "response": None, + "error": _("File already exists"), + } + if log_record: + return log_record.finish( + finish_date=fields.Datetime.now(), + status=command_result["status"], + response=command_result["response"], + error=command_result["error"], + ) + else: + return command_result + + # Context is used to detect a retry + # and avoid handling skip logic on first attempt + is_creation_skipped = file._context.get("file_creation_skipped") + + if not is_creation_skipped: + if file.source == "server": + file.action_pull_from_server() + elif file.source == "tower": + file.action_push_to_server() + else: + raise UserError( + _( + "File source cannot be determined: '%(source)s'", + source=file.source, + ) + ) + + if log_record.command_id.disconnect_file: + file.action_unlink_from_template() + + if is_creation_skipped: + return log_record.finish( + fields.Datetime.now(), + 0, + _("File already exists on server. Upload skipped"), + None, + ) + + # Log the successful creation and upload of the file + return log_record.finish( + finish_date=fields.Datetime.now(), + status=0, + response=_("File created and uploaded successfully"), + error=None, + ) + + except Exception as e: + # Log any exception that occurs during the process + log_record.finish( + finish_date=fields.Datetime.now(), + status=FILE_CREATION_FAILED, + response=None, + error=_("An error occurred: %(error)s", error=str(e)), + ) + + def _command_runner_ssh( + self, + log_record, + rendered_command_code, + sudo=None, + rendered_command_path=None, + ssh_connection=None, + **kwargs, + ): + """Run SSH command. + Updates the record in the Command Log (cx.tower.command.log) + + Args: + log_record (cx.tower.command.log()): Command log record + rendered_command_code (Text): Rendered command code. + We are passing in case it differs from command code in the log record. + sudo (Selection): Command sudo mode. Defaults to None. + rendered_command_path (Char, optional): Rendered command path. + ssh_connection (SSH client instance, optional): SSH connection to reuse. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "raise_on_error": Raise exception on error. + + Returns: + dict(): command running result if `log_record` is defined else None + """ + raise_on_error = kwargs.pop("raise_on_error", False) + if not ssh_connection: + ssh_connection = self._get_ssh_client(raise_on_error=raise_on_error) + + # Run command + command_result = self._run_command_using_ssh( + client=ssh_connection, + command_code=rendered_command_code, + command_path=rendered_command_path, + raise_on_error=raise_on_error, + sudo=sudo, + **kwargs, + ) + + # Log result + if log_record: + log_record.finish( + finish_date=fields.Datetime.now(), + status=command_result["status"], + response=command_result["response"], + error=command_result["error"], + ) + else: + return command_result + + def _command_runner_flight_plan( + self, log_record, flight_plan, raise_on_error=False, **kwargs + ): + """ + Run Flight plan from command. + Updates the record in the Command Log (cx.tower.command.log) + Args: + log_record (cx.tower.command.log()): Command log record. + flight_plan (cx.tower.plan()): Flight Plan to be run. + raise_on_error (bool, optional): raise error on error. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + Returns: + dict(): flight plan running result if `log_record` is + not defined else None + """ + response = None + error = None + status = 0 + plan_log_record = None + try: + # Generate custom label and add values for log + kwargs["plan_log"] = { + "label": generate_random_id(4), + "parent_flight_plan_log_id": log_record.plan_log_id.id, + } + # add executed command with action "plan" to save link to plan log + kwargs["flight_plan_command_log"] = log_record + plan_log_record = flight_plan.with_context(from_command=True)._run_single( + server=self, + jet_template=log_record.jet_template_id, + jet=log_record.jet_id, + **kwargs, + ) + except Exception as e: # pylint: disable=broad-exception-caught + if raise_on_error: + raise ValidationError( + _("Flight plan running error %(err)s", err=e) + ) from e + status = GENERAL_ERROR + error = e + else: + if plan_log_record.plan_status != 0: + status = plan_log_record.plan_status + error = _("Flight plan running error") + + result = {"status": status, "response": response, "error": error} + if log_record: + log_record.finish( + finish_date=fields.Datetime.now(), + status=result["status"], + response=result["response"], + error=result["error"], + variable_values=plan_log_record.variable_values + if plan_log_record + else None, + ) + return result + + def _command_runner_python_code( + self, + log_record, + rendered_code, + **kwargs, + ): + """ + Run Python code. + Updates the record in the Command Log (cx.tower.command.log) + + Args: + log_record (cx.tower.command.log()): Command log record + rendered_code (Text): Rendered python code. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + + Returns: + dict(): python code running result if `log_record` is + not defined else None + """ + # Run python code + result = self._run_python_code( + code=rendered_code, + raise_on_error=False, + **kwargs, + ) + + # Log result + if log_record: + log_record.finish( + finish_date=fields.Datetime.now(), + status=result["status"], + response=result["response"], + error=result["error"], + variable_values=result["variable_values"], + ) + else: + return result + + def _command_runner_jet_action( + self, + log_record, + **kwargs, + ): + """ + Run Jet action. + Updates the record in the Command Log (cx.tower.command.log) + + Args: + log_record (cx.tower.command.log()): Command log record + + Returns: + dict(): jet action running result if `log_record` is + not defined else None + + Raises: + ValidationError: if `log_record` is not defined + """ + if not log_record: + raise ValidationError( + _("Command log is required for 'Jet Action' commands!") + ) + + # Initialize result values + status = 0 + response = None + error = None + dependent_jets = None + + # Get the action from the command + action = log_record.command_id.jet_action_id + if not action: + status = GENERAL_ERROR + error = _("Jet action is not found.") + log_record.finish( + status=status, + response=response, + error=error, + ) + return {"status": status, "response": response, "error": error} + + jet_for_which_command_is_run = log_record.jet_id + requested_jet_template = log_record.command_id.jet_template_id + + if not jet_for_which_command_is_run: + status = JET_NOT_FOUND + error = _("Jet for which command is run is not found.") + log_record.finish( + status=status, + response=response, + error=error, + ) + return {"status": status, "response": response, "error": error} + if not requested_jet_template: + status = JET_TEMPLATE_NOT_FOUND + error = _("Jet template is not found.") + log_record.finish( + status=status, + response=response, + error=error, + ) + return {"status": status, "response": response, "error": error} + + # Trigger for the jet itself if the same jet template is used + # This is used when you want to trigger an action for + # the same jet for which the command is run. + if jet_for_which_command_is_run.jet_template_id == requested_jet_template: + dependent_jets = jet_for_which_command_is_run + else: + # Get dependent jets by template + dependent_jets = ( + jet_for_which_command_is_run._get_dependent_jets_by_template( + requested_jet_template + ) + ) + + if dependent_jets: + # Trigger the action for all dependent jets; aggregate failures as + # "ref: message, ref2: message2" for the command log. + error_parts = [] + for jet in dependent_jets: + result = jet._trigger_action( + action=action, + raise_if_not_available=False, + ) + if not result: + continue + jet_status = result.get("status", 0) + jet_error = result.get("error") + if jet_status == 0 and not jet_error: + continue + if jet_error: + error_parts.append(f"{jet.reference}: {jet_error}") + else: + error_parts.append( + _( + "%(jet)s: action failed (status %(status)s)", + jet=jet.reference, + status=jet_status, + ) + ) + # Compose the main message + jet_references = ", ".join(jet.reference for jet in dependent_jets) + + main_message = _( + "Action triggered for %(jet_references)s", + jet_references=jet_references, + ) + + if error_parts: + status = GENERAL_ERROR + error = "\n".join( + [ + main_message, + ( + error_parts[0] + if len(error_parts) == 1 + else ", ".join(error_parts) + ), + ] + ) + response = None + else: + response = main_message + log_record.finish( + status=status, + response=response, + error=error, + ) + # If no dependent jets, finish the command + else: + status = 0 # no dependent jets, so the command is finished with no error + response = _( + "Jet %(jet)s has no dependent jets with template %(template)s.", + jet=jet_for_which_command_is_run.name, + template=requested_jet_template.name, + ) + + log_record.finish( + status=status, + response=response, + error=error, + ) + # Return result + return {"status": status, "response": response, "error": error} + + def _command_runner_create_waypoint(self, log_record, **kwargs): + """Run Create a Waypoint command. + + Creates a waypoint for the plan's jet from the command's waypoint template. + The flight plan sets the jet as busy, so we pass ignore_busy=True so + creation is allowed. The command log is not finished here when a waypoint + is created; the waypoint callback (_finalize_create_waypoint_command_log) + finishes it when the waypoint reaches ready/current or error. + + Args: + log_record (cx.tower.command.log): Command log record. + + Returns: + dict: status, response (e.g. waypoint id when created), error. + When waypoint is created, status 0 and response indicate + deferred completion; the log stays running until the callback. + """ + if not log_record: + raise ValidationError( + _("Command log is required for 'Create a Waypoint' commands!") + ) + command = log_record.command_id + jet = log_record.jet_id + waypoint_template = command.waypoint_template_id + + status = 0 + response = None + error = None + + if not jet: + status = JET_NOT_FOUND + error = _("Jet for which command is run is not found.") + log_record.finish( + status=status, + response=response, + error=error, + ) + return {"status": status, "response": response, "error": error} + + if not waypoint_template: + status = WAYPOINT_TEMPLATE_NOT_FOUND + error = _("Waypoint template is not set.") + log_record.finish( + status=status, + response=response, + error=error, + ) + return {"status": status, "response": response, "error": error} + + try: + waypoint = jet.create_waypoint( + waypoint_template, + fly_here=command.fly_here, + ignore_busy=True, + created_from_command_log=log_record, + ) + except ValidationError: + waypoint = False + + if not waypoint: + status = WAYPOINT_CREATE_FAILED + error = _( + "Waypoint creation failed (e.g. waypoint template " + "does not match jet template)." + ) + log_record.finish( + status=status, + response=response, + error=error, + ) + return {"status": status, "response": response, "error": error} + + # Do not finish the log; waypoint callback will finish it when + # ready/current/error + response = {"waypoint_id": waypoint.id} + return {"status": 0, "response": response, "error": None} + + @ensure_ssh_disconnect + def _run_command_using_ssh( + self, + client, + command_code, + command_path=None, + raise_on_error=False, + sudo=None, + **kwargs, + ): + """This is a low level method for running an SSH command. + Use it in case you need to get direct output of an SSH command. + Otherwise call `run_command()` + + Args: + client (Connection): valid server ssh connection object + command_code (Text): command text + command_path (Char, optional): directory where command should be run + raise_on_error (bool, optional): raise error on error + sudo (Selection): Command sudo mode. Defaults to None. Defaults to None. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + + Raises: + ValidationError: if client is not valid + ValidationError: command run error + + Returns: + dict: { + "status": , + "response": Text, + "error": Text + } + """ + if not client: + if raise_on_error: + raise ValidationError(_("SSH Client is not defined.")) + return { + "status": SSH_CONNECTION_ERROR, + "response": False, + "error": _("SSH Client is not defined."), + } + + # Client contains a result of _get_ssh_client() + # If it's a tuple, it means there was an error getting the client + if isinstance(client, tuple): + error = client[1] + if raise_on_error: + raise ValidationError(error) + return { + "status": SSH_CONNECTION_ERROR, + "response": False, + "error": error, + } + + # Parse inline secrets + code_and_secrets = self.env["cx.tower.key"]._parse_code_and_return_key_values( + command_code, **kwargs.get("key", {}) + ) + command_code = code_and_secrets["code"] + secrets = code_and_secrets["key_values"] + + # Prepare ssh command + prepared_command_code = self._prepare_ssh_command( + command_code, + command_path, + sudo, + **kwargs, + ) + + try: + status = [] + response = [] + error = [] + + # Command is a single sting. No 'sudo' or 'sudo' w/o password + if isinstance(prepared_command_code, str): + status, response, error = client.command_executor.exec_command( + prepared_command_code, sudo=sudo + ) + + # Multiple commands: sudo with password + elif isinstance(prepared_command_code, list): + for cmd in prepared_command_code: + st, resp, err = client.command_executor.exec_command(cmd, sudo=sudo) + status.append(st) + response += resp + error += err + + # Something weird )) + else: + status = [GENERAL_ERROR] + + except Exception as e: + if raise_on_error: + _logger.error("SSH run command error: %s", e) + raise ValidationError(_("SSH run command error %(err)s", err=e)) from e + status = [GENERAL_ERROR] + response = [] + error = [e] + + result = self._parse_command_results(status, response, error, secrets, **kwargs) + return result + + def _run_python_code( + self, + code, + raise_on_error=False, + **kwargs, + ): + """ + This is a low level method for Python code running. + + Args: + code (Text): python code + raise_on_error (bool, optional): raise error on error + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + + Raises: + ValidationError: python code running error + + Returns: + dict: { + "status": , + "response": Text, + "error": Text + } + """ + response = None + error = None + status = 0 + secrets = None + + try: + # Parse inline secrets + code_and_secrets = self.env[ + "cx.tower.key" + ]._parse_code_and_return_key_values( + code, pythonic_mode=True, **kwargs.get("key", {}) + ) + secrets = code_and_secrets.get("key_values") + command_code = code_and_secrets["code"] + + code = self.env["cx.tower.key"]._parse_code( + command_code, pythonic_mode=True, **kwargs.get("key", {}) + ) + + # Check if code contains banned keywords + banned_keywords = self.env[ + "cx.tower.command" + ]._get_banned_python_code_keywords() + for banned_keyword in banned_keywords: + if banned_keyword in code: + raise ValidationError( + _( + "Following keyword is not allowed in Python code:" + " '%(banned_keyword)s'", + banned_keyword=banned_keyword, + ) + ) + # Get jet template, jet and waypoint from kwargs or log + log_vals = kwargs.get("log", {}) + if log_vals: + jet_template_id = log_vals.get("jet_template_id") + jet_id = log_vals.get("jet_id") + else: + jet_template_id = kwargs.get("jet_template_id") + jet_id = kwargs.get("jet_id") + waypoint = kwargs.get("waypoint") + + jet_template = ( + self.env["cx.tower.jet.template"].browse(jet_template_id) + if jet_template_id + else None + ) + jet = self.env["cx.tower.jet"].browse(jet_id) if jet_id else None + + # Get the evaluation context for the python command + eval_context = self.env[ + "cx.tower.command" + ]._get_python_command_eval_context( + server=self, + jet_template=jet_template, + jet=jet, + waypoint=waypoint, + variable_values=kwargs.get("variable_values", {}), + ) + + safe_eval( + code, + eval_context, + mode="exec", + nocopy=True, + ) + kwargs["variable_values"] = eval_context.get("custom_values", {}) + result = eval_context.get("result") + if result: + status = result.get("exit_code", 0) + if status == 0: + response = [result.get("message")] + else: + error = [result.get("message")] + + except Exception as e: # pylint: disable=broad-exception-caught + if raise_on_error: + raise ValidationError( + _("Python code running error: %(err)s", err=e) + ) from e + status = PYTHON_COMMAND_ERROR + error = [e] + + result = self._parse_command_results(status, response, error, secrets, **kwargs) + result["variable_values"] = kwargs.get("variable_values", {}) + return result + + def _prepare_ssh_command(self, command_code, path=None, sudo=None, **kwargs): + """Prepare ssh command + IMPORTANT: + Commands run with sudo will be run separately one after another + even if there is a single command separated with '&&' + Examples: + # Default (sudo with splitting): + "pwd && ls -l" becomes: + sudo pwd + sudo ls -l + + # With no_split_for_sudo=True: + sudo pwd && ls -l + + Args: + command_code (str): initial command + path (str, optional): directory where command should be run + sudo (str, optional): sudo mode ('n' or 'p') + 'n' — sudo without password + 'p' — sudo with password + kwargs (dict): extra arguments. Supported keys: + - "log": values passed to logger + - "key": values passed to key parser + - "no_split_for_sudo" (bool): if True, do not split on '&&' + + Returns: + list or str: if sudo='p' (with password), returns a list of commands; + if sudo='n', returns a single string (possibly joined by '&&'); + without sudo, returns the raw command_code. + """ + # Prepare command for sudo if needed + if sudo: + # Add location + sudo_prefix = "sudo -S -p ''" + + no_split = kwargs.get("no_split_for_sudo", False) + + separator = "&&" + # split only when '&&' is present AND splitting is not disabled + if separator in command_code and not no_split: + result = ( + command_code.replace("\\", "").replace("\n", "").split(separator) + ) + + # Sudo with password expects a list of commands + result = [f"{sudo_prefix} {cmd.strip()}" for cmd in result] + + # Merge back into a single command is sudo is without password + if sudo == "n": + result = f" {separator} ".join(result) + else: + # single command or no_split requested + result = f"{sudo_prefix} {command_code}" + # Sudo with password expects a list of commands + if sudo == "p": + result = [result] + else: + # Command without sudo is always run as is + result = command_code + # Add path change command + if path: + # Add sudo prefix if needed + cd_command = f"cd {path}" + + if isinstance(result, list): + result = [cd_command] + result + else: + result = f"{cd_command} && {result}" + + return result + + def _parse_command_results( + self, status, response, error, key_values=None, **kwargs + ): + """ + Parse results of a command run with sudo (either SSH or Python). + Removes secrets and formats the response and error messages. + + Paramiko returns SSH response and error as list. + When running a command with sudo with password we return status as a list too. + _ + + Args: + status (Int or list of int): Status or statuses + response (list): Response + error (list): Error + key_values (list): Secrets that were discovered in code. + Used to clean up command result. + kwargs (dict): extra arguments. Use to pass external values. + Following keys are supported by default: + - "log": {values passed to logger} + - "key": {values passed to key parser} + + Returns: + dict: { + "status": , + "response": , + "error": + } + """ + + # In case of several statuses we return the last one that is not 0 ("ok") + # Eg for [0,1,0,4,0] result will be 4 + if isinstance(status, list): + final_status = 0 + for st in status: + if st != 0: + final_status = st + + status = final_status + + # This is needed to remove keys + if key_values: + key_model = self.env["cx.tower.key"] + + # Compose response message + if response and isinstance(response, list): + # Replace secrets with spoiler + response_vals = [ + key_model._replace_with_spoiler(str(r), key_values) + if key_values + else str(r) + for r in response + ] + response = "".join(response_vals) + + elif not response: + # For not to save an empty list `[]` in log + response = None + + # Compose error message + if error and isinstance(error, list): + # Replace secrets with spoiler + error_vals = [ + key_model._replace_with_spoiler(str(e), key_values) + if key_values + else str(e) + for e in error + ] + error = "".join(error_vals) + elif not error: + # For not to save an empty list `[]` in log + error = None + + return { + "status": status, + "response": response, + "error": error, + } + + def _check_zombie_commands(self): + """ + Check commands that are running longer than the timeout + and mark them as finished + """ + timeout = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("cetmix_tower_server.command_timeout", 0) + ) + if not timeout: + return + + # SSH or Python command is running longer than the timeout + # We are not terminating Flight Plans and File Upload commands + domain = [ + ("is_running", "=", True), + ("start_date", "<", fields.Datetime.now() - timedelta(seconds=timeout)), + ("command_action", "in", ["ssh_command", "python_code"]), + ] + zombie_command_logs = self.env["cx.tower.command.log"].search(domain) + if zombie_command_logs: + zombie_command_logs.finish( + status=COMMAND_TIMED_OUT, + response=None, + error=COMMAND_TIMED_OUT_MESSAGE, + ) + + # ------------------------------ + # ---- File management + # ------------------------------ + + @ensure_ssh_disconnect + def delete_file(self, remote_path): + """ + Delete file from remote server + + Args: + remote_path (Text): full path file location with file type + (e.g. /test/my_file.txt). + """ + self.ensure_one() + client = self._get_ssh_client(raise_on_error=True) + client.sftp_service.delete_file(remote_path) + + @ensure_ssh_disconnect + def upload_file(self, data, remote_path, from_path=False): + """ + Upload file to remote server. + + Args: + data (Text, Bytes): If the data are text, they are converted to bytes, + contains a local file path if from_path=True. + remote_path (Text): full path file location with file type + (e.g. /test/my_file.txt). + from_path (Boolean): set True if `data` is file path. + + Raise: + TypeError: incorrect type of file. + + Returns: + Result (class paramiko.sftp_attr.SFTPAttributes): metadata of the + uploaded file. + """ + self.ensure_one() + client = self._get_ssh_client(raise_on_error=True) + if from_path: + result = client.sftp_service.upload_file(data, remote_path) + else: + # Convert string to bytes + if isinstance(data, str): + data = data.encode() + file = io.BytesIO(data) + result = client.sftp_service.upload_file(file, remote_path) + + return result + + @ensure_ssh_disconnect + def download_file(self, remote_path): + """ + Download file from remote server + + Args: + remote_path (Text): full path file location with file type + (e.g. /test/my_file.txt). + + Raise: + ValidationError: raise if file not found. + + Returns: + Result (Bytes): file content. + """ + self.ensure_one() + client = self._get_ssh_client(raise_on_error=True) + try: + result = client.sftp_service.download_file(remote_path) + + except FileNotFoundError as fe: + raise ValidationError( + _("The file %(f_path)s not found.", f_path=remote_path) + ) from fe + return result + + # ------------------------------ + # ---- Auxiliary functions + # ------------------------------ + + def get_variable_value(self, variable_reference, no_fallback=False): + """ + Return the value of a variable for the current server. + NB: this function follows the value application order. + So it will return the global value if server value is not set. + + Returns: + str: The value of the variable for the current record or None + """ + self.ensure_one() + variable = self.env["cx.tower.variable"].get_by_reference(variable_reference) + if not variable: + return None + values = variable._get_variable_values_by_references( + variable_references=[variable_reference], server=self + ) + return values[variable_reference] + + def server_toggle_active(self, self_active): + """ + Change active status of related records: + - files + - commands + - plans + - variable values + Add custom logic to your model if you want to change + the active status of other records. + + Args: + self_active (bool): active status of the record + """ + self.file_ids.filtered(lambda f: f.active == self_active).toggle_active() + self.command_log_ids.filtered(lambda c: c.active == self_active).toggle_active() + self.plan_log_ids.filtered(lambda p: p.active == self_active).toggle_active() + self.variable_value_ids.filtered( + lambda vv: vv.active == self_active + ).toggle_active() + + def toggle_active(self): + """Archive or unarchive related server""" + res = super().toggle_active() + server_active = self.with_context(active_test=False).filtered( + lambda x: x.active + ) + server_not_active = self - server_active + if server_active: + server_active.server_toggle_active(False) + if server_not_active: + server_not_active.server_toggle_active(True) + return res + + def _is_being_deleted(self): + """Check if the server is being deleted. + + Returns: + bool: True if the server is being deleted, False otherwise + """ + self.ensure_one() + return self.status and self.status == "deleting" + + def _get_post_create_fields(self): + """ + Add fields that should be populated after server creation + """ + res = super()._get_post_create_fields() + return res + ["variable_value_ids", "server_log_ids", "secret_ids"] + + def _get_notification_action( + self, message, notification_type="info", title=None, sticky=True + ): + """Get notification action + + Args: + message (str): Message + notification_type (str, optional): Notification type. Defaults to "info". + title (str, optional): Title. Defaults to None. + sticky (bool, optional): Sticky notification. Defaults to True. + + Returns: + dict: Notification action + """ + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "type": notification_type, + "title": title, + "message": message, + "sticky": sticky, + }, + } + + def _get_dependent_model_relation_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_dependent_model_relation_fields() + return res + ["variable_value_ids", "file_ids"] diff --git a/addons/cetmix_tower_server/models/cx_tower_server_log.py b/addons/cetmix_tower_server/models/cx_tower_server_log.py new file mode 100644 index 0000000..14512da --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_server_log.py @@ -0,0 +1,238 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from ansi2html import Ansi2HTMLConverter + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError + +_logger = logging.getLogger(__name__) + +html_converter = Ansi2HTMLConverter(inline=True) + + +class CxTowerServerLog(models.Model): + """Server log management. + Used to track various server logs. + N.B. Do not mistake for command of flight plan log! + """ + + _name = "cx.tower.server.log" + _inherit = ["cx.tower.access.mixin", "cx.tower.reference.mixin"] + _description = "Cetmix Tower Server Log" + + NO_LOG_FETCHED_MESSAGE = "" + + active = fields.Boolean(default=True) + server_id = fields.Many2one( + "cx.tower.server", + ondelete="cascade", + compute="_compute_server_id", + index=True, + store=True, + readonly=False, + copy=False, + ) + log_type = fields.Selection( + selection=lambda self: self._selection_log_type(), + required=True, + groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager", + default=lambda self: self._selection_log_type()[0][0], + ) + command_id = fields.Many2one( + "cx.tower.command", + domain="[('action', 'in', ['ssh_command', 'python_code']), " + "'|', ('server_ids', 'in', [server_id]), ('server_ids', '=', False)]", + groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager", + help="Command that will be executed to get the log data.\n" + "Be careful with commands that don't support parallel execution!", + ) + use_sudo = fields.Boolean( + groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager", + help="Will use sudo based on server settings." + "If no sudo is configured will run without sudo", + ) + file_id = fields.Many2one( + "cx.tower.file", + domain="[('server_id', '=', server_id)]", + groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager", + help="File that will be executed to get the log data", + copy=False, + ) + log_text = fields.Text(readonly=True, copy=False) + log_html = fields.Html(compute="_compute_log_html") + + # --- Server template related + server_template_id = fields.Many2one("cx.tower.server.template", ondelete="cascade") + file_template_id = fields.Many2one( + "cx.tower.file.template", + ondelete="cascade", + groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager", + help="This file template will be used to create log files" + " when server is created from a template", + ) + + # -- Jet Template related + jet_template_id = fields.Many2one( + "cx.tower.jet.template", + ondelete="cascade", + index=True, + help="This jet template will be used to create log files when jet is created", + ) + + # -- Jet related + jet_id = fields.Many2one( + "cx.tower.jet", + ondelete="cascade", + index=True, + ) + + @api.depends("jet_id") + def _compute_server_id(self): + for record in self: + if not record.server_id and record.jet_id: + record.server_id = record.jet_id.server_id.id + + def _selection_log_type(self): + """Actions that can be run by a command. + + Returns: + List of tuples: available options. + """ + return [ + ("command", "Command"), + ("file", "File"), + ] + + @api.depends("log_text") + def _compute_log_html(self): + for record in self: + if record.log_text: + try: + record.log_html = html_converter.convert(record.log_text) + # We catch all exceptions to avoid breaking the log display + except Exception as e: + _logger.error("Error converting log text to HTML: %s", e) + record.log_html = False + else: + record.log_html = False + + def copy(self, default=None): + return super( + CxTowerServerLog, self.with_context(reference_mixin_skip_self=True) + ).copy(default) + + def action_open_log(self): + """ + Open log record in current window + """ + self.ensure_one() + self.action_update_log() + return { + "type": "ir.actions.act_window", + "name": self.name, + "res_model": "cx.tower.server.log", + "res_id": self.id, # pylint: disable=no-member + "view_mode": "form", + "target": "current", + } + + def write(self, vals): + """Override to protect log_text from direct modifications. + Bypass with context key 'cx_allow_log_text_update' for internal updates. + """ + if "log_text" in vals and not self.env.context.get("cx_allow_log_text_update"): + raise AccessError(_("You are not allowed to modify the server log output.")) + return super().write(vals) + + def action_update_log(self): + """Update log text from source""" + + # We are using `sudo` to override command/file access limitations + for rec in self.sudo().with_context(cx_allow_log_text_update=True): + rec.log_text = rec._get_formatted_log_text() + + def _get_log_text(self): + """ + Get log text from source + Use this function to get pure log text from source. + + Returns: + Text: log text + """ + self.ensure_one() + if self.log_type == "file" and self.file_id: + return self._get_log_from_file() + elif self.log_type == "command" and self.command_id: + return self._get_log_from_command() + + def _get_formatted_log_text(self): + """ + Get formatted log text. + Use this function to get formatted log text. + + Returns: + Text: formatted log text + """ + log_text = self._get_log_text() + if log_text: + return self._format_log_text(log_text) + return self.NO_LOG_FETCHED_MESSAGE + + def _format_log_text(self, log_text): + """ + Format log text. + Use this function to format log text. + + Returns: + Text: formatted log text + """ + # Remove the null bytes + return log_text.replace("\x00", "") + + def _get_log_from_file(self): + """Get log from a file. + Override this function to implement custom log handler + + Returns: + Text: log text + """ + self.ensure_one() + if self.file_id.source == "server": + self.file_id.download(raise_error=False) + return self.file_id.code + if self.file_id.source == "tower": + result = self.file_id.action_get_current_server_code() + if isinstance(result, dict): + return + return self.file_id.code_on_server + + def _get_log_from_command(self): + """Get log from a command. + Returns: + Text: log text + """ + self.ensure_one() + + use_sudo = self.use_sudo and self.server_id.use_sudo + command_result = self.server_id.with_context(no_command_log=True).run_command( + self.command_id, + jet=self.jet_id, + jet_template=self.jet_template_id, + sudo=use_sudo, + ) + log_text = self.NO_LOG_FETCHED_MESSAGE + if command_result: + response = command_result["response"] + error = command_result["error"] + if response: + log_text = response + elif error: + log_text = error + return log_text + + def _get_copied_name(self, force_name=None): + # Original name is preserved when log is duplicated + self.ensure_one() + return force_name or self.name diff --git a/addons/cetmix_tower_server/models/cx_tower_server_template.py b/addons/cetmix_tower_server/models/cx_tower_server_template.py new file mode 100644 index 0000000..6806698 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_server_template.py @@ -0,0 +1,653 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerServerTemplate(models.Model): + """Server Template. Used to simplify server creation""" + + _name = "cx.tower.server.template" + _inherit = [ + "cx.tower.reference.mixin", + "mail.thread", + "mail.activity.mixin", + "cx.tower.access.role.mixin", + "cx.tower.tag.mixin", + ] + _description = "Cetmix Tower Server Template" + _order = "name" + + active = fields.Boolean(default=True) + + # --- Connection + ssh_port = fields.Integer(string="SSH port", default=22) + ssh_username = fields.Char(string="SSH Username") + ssh_password = fields.Char(string="SSH Password") + ssh_key_id = fields.Many2one( + comodel_name="cx.tower.key", + string="SSH Private Key", + domain=[("key_type", "=", "k")], + ) + ssh_auth_mode = fields.Selection( + string="SSH Auth Mode", + selection=[ + ("p", "Password"), + ("k", "Key"), + ], + ) + use_sudo = fields.Selection( + string="Use sudo", + selection=[("n", "Without password"), ("p", "With password")], + help="Run commands using 'sudo'", + ) + + # --- Attributes + color = fields.Integer(help="For better visualization in views") + os_id = fields.Many2one(string="Operating System", comodel_name="cx.tower.os") + tag_ids = fields.Many2many( + relation="cx_tower_server_template_tag_rel", + column1="server_template_id", + column2="tag_id", + ) + + # --- Variables + # We are not using variable mixin because we don't need to parse values + variable_value_ids = fields.One2many( + string="Variable Values", + comodel_name="cx.tower.variable.value", + auto_join=True, + inverse_name="server_template_id", + ) + + # --- Server logs + server_log_ids = fields.One2many( + comodel_name="cx.tower.server.log", inverse_name="server_template_id" + ) + + # --- Shortcuts + shortcut_ids = fields.Many2many( + comodel_name="cx.tower.shortcut", + relation="cx_tower_server_template_shortcut_rel", + column1="server_template_id", + column2="shortcut_id", + string="Shortcuts", + ) + + # --- Scheduled Tasks + scheduled_task_ids = fields.Many2many( + comodel_name="cx.tower.scheduled.task", + relation="cx_tower_server_template_scheduled_task_rel", + column1="server_template_id", + column2="scheduled_task_id", + string="Scheduled Tasks", + ) + + # --- Flight Plan + flight_plan_id = fields.Many2one( + "cx.tower.plan", + help="This flight plan will be run upon server creation", + domain="[('server_ids', '=', False)]", + ) + + # ---- Delete plan + plan_delete_id = fields.Many2one( + "cx.tower.plan", + string="On Delete Plan", + groups="cetmix_tower_server.group_manager", + help="This Flightplan will be executed when the server is deleted", + ) + + # --- Created Servers + server_ids = fields.One2many( + comodel_name="cx.tower.server", + inverse_name="server_template_id", + ) + server_count = fields.Integer( + compute="_compute_server_count", + ) + + # -- Other + note = fields.Text() + + # ---- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_server_template_user_rel", + domain=lambda self: [ + ("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id]) + ], + ) + manager_ids = fields.Many2many( + relation="cx_tower_server_template_manager_rel", + ) + + @api.depends("server_ids") + def _compute_server_count(self): + """ + Compute total server counts created from the templates + """ + for template in self: + template.server_count = len(template.server_ids) + + def copy(self, default=None): + """Duplicate the server template along with variable values and server logs.""" + default = dict(default or {}) + + # Duplicate the server template itself + new_template = super().copy(default) + + # Duplicate variable values + for variable_value in self.variable_value_ids: + variable_value.with_context(reference_mixin_skip_self=True).copy( + {"server_template_id": new_template.id} + ) + + # Duplicate server logs + for server_log in self.server_log_ids: + server_log.copy({"server_template_id": new_template.id}) + + return new_template + + def action_create_server(self): + """ + Returns wizard action to create new server + """ + self.ensure_one() + context = self.env.context.copy() + context.update( + { + "default_server_template_id": self.id, # pylint: disable=no-member + "default_color": self.color, + "default_ssh_port": self.ssh_port, + "default_ssh_username": self.ssh_username, + "default_ssh_password": self.ssh_password, + "default_ssh_key_id": self.ssh_key_id.id, + "default_ssh_auth_mode": self.ssh_auth_mode, + "default_plan_delete_id": self.plan_delete_id.id, + } + ) + if self.variable_value_ids: + context.update( + { + "default_line_ids": [ + ( + 0, + 0, + { + "variable_value_id": line.id, + }, + ) + for line in self.variable_value_ids + ] + } + ) + return { + "type": "ir.actions.act_window", + "name": _("Create Server"), + "res_model": "cx.tower.server.template.create.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_open_servers(self): + """ + Return action to open related servers + """ + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_server" + ) + action.update( + { + "domain": [("server_template_id", "=", self.id)], # pylint: disable=no-member + } + ) + return action + + @api.model + def create_server_from_template(self, template_reference, server_name, **kwargs): + """This is a wrapper function that is meant to be called + when we need to create a server from specific server template + + Args: + template_reference (Char): Server template reference + server_name (Char): Name of the new server + + Kwargs: + partner (res.partner(), optional): Partner this server belongs to. + ipv4 (Char, optional): IP v4 address. Defaults to None. + ipv6 (Char, optional): IP v6 address. + Must be provided in case IP v4 is not. Defaults to None. + ssh_password (Char, optional): SSH password. Defaults to None. + ssh_key (Char, optional): SSH private key record reference. + Defaults to None. + configuration_variables (Dict, optional): Custom configuration variable. + Following format is used: + `variable_reference`: `variable_value_char` + eg: + {'branch': 'prod', 'odoo_version': '16.0'} + pick_all_template_variables (bool): This parameter ensures that the server + being created considers existing variables from the template. + If enabled, the template variables will also be included in the server + variables. The default value is True. + + Returns: + cx.tower.server: newly created server record + """ + template = self.get_by_reference(template_reference) + return template._create_new_server(server_name, **kwargs) + + def _create_new_server(self, name, **kwargs): + """Creates a new server from template + + Args: + name (Char): Name of the new server + + Kwargs: + partner (res.partner(), optional): Partner this server belongs to. + ipv4 (Char, optional): IP v4 address. Defaults to None. + ipv6 (Char, optional): IP v6 address. + Must be provided in case IP v4 is not. Defaults to None. + ssh_password (Char, optional): SSH password. Defaults to None. + ssh_key (Char, optional): SSH private key record reference. + Defaults to None. + configuration_variables (Dict, optional): Custom configuration variable. + Following format is used: + `variable_reference`: `variable_value_char` + eg: + {'branch': 'prod', 'odoo_version': '16.0'} + pick_all_template_variables (bool): This parameter ensures that the server + being created considers existing variables from the template. + If enabled, the template variables will also be included in the server + variables. The default value is True. + + Returns: + cx.tower.server: newly created server record + """ + self.ensure_one() + + # Retrieve the passed variables + configuration_variables = kwargs.get("configuration_variables", {}) + + # We validate mandatory variables + if not kwargs.get("pick_all_template_variables"): + self._validate_required_variables(configuration_variables) + + # We are using sudo to ensure all values are copied + server_values = self.sudo()._prepare_server_values( + name=name, + server_template_id=self.id, # pylint: disable=no-member + **kwargs, + ) + + # Pop variable values to add them after server creation. + # This is needed to ensure that access rules are applied properly. + variable_values = server_values.pop("variable_value_ids") + + # Prepare context for server creation + context = self.env.context.copy() + + # SSH setting may be added after server creation. + context.update({"skip_ssh_settings_check": True}) + # We need to remove default_server_template_id to avoid it being used + # in variable values. + context.pop("default_server_template_id", None) + + # Create server + server = ( + self.env["cx.tower.server"] # pylint: disable=context-overridden # new need a new clean context + .sudo() + .with_context(context) + .create(server_values) + .sudo() + ) + + # Add variable values + if variable_values: + server.with_context(context).write({"variable_value_ids": variable_values}) # pylint: disable=context-overridden # new need a new clean context + + # Create server logs + logs = server.server_log_ids.filtered(lambda rec: rec.log_type == "file") + for log in logs.sudo(): + log.file_id = log.file_template_id.create_file( + server=server, if_file_exists="skip" + ).id + + flight_plan = server.server_template_id.flight_plan_id + if flight_plan: + server.run_flight_plan(flight_plan) + + return server + + def _get_post_create_fields(self): + """ + Add fields that should be populated after server template creation + """ + res = super()._get_post_create_fields() + return res + ["variable_value_ids", "server_log_ids"] + + def _get_fields_tower_server(self): + """ + Return field name list to read from template and create new server + """ + return [ + "ssh_username", + "ssh_password", + "ssh_key_id", + "ssh_auth_mode", + "use_sudo", + "color", + "os_id", + "plan_delete_id", + "tag_ids", + "variable_value_ids", + "server_log_ids", + "shortcut_ids", + "scheduled_task_ids", + ] + + def _prepare_server_values(self, pick_all_template_variables=True, **kwargs): + """ + Prepare the server values to create a new server based on + the current template. It reads all fields from the template, copies them, + and processes One2many fields to create new related records. Magic fields + like 'id', concurrency fields, and audit fields are excluded from the copied + data. + + Args: + pick_all_template_variables (bool): This parameter ensures that the server + being created considers existing variables from the template. + If enabled, the template variables will also be included in the server + variables. The default value is True. + **kwargs: Additional values to update in the final server record. + + Returns: + list: A list of dictionaries representing the values for the new server + records. + """ + model_fields = self._fields + field_o2m_type = fields.One2many + + # define the magic fields that should not be copied + # (including ID) + MAGIC_FIELDS = models.MAGIC_COLUMNS + + # read all values required to create a new server from the template + values = self.read(self._get_fields_tower_server(), load=False)[0] + + # prepare server config values from kwargs + server_config_values = self._parse_server_config_values(kwargs) + template = self.browse(values["id"]) + + # Process each field in the template + for field in values.keys(): + if isinstance(model_fields[field], field_o2m_type): + # get related records for One2many field + related_records = getattr(template, field) + new_records = [] + # for each related record, read its data and prepare it for copying + for record in related_records: + record_data = { + k: v + for k, v in record.read(load=False)[0].items() + if k not in MAGIC_FIELDS + } + # set the inverse field (link back to the template) + # to False to unlink from the original template + record_data[model_fields[field].inverse_name] = False + new_records.append((0, 0, record_data)) + + values[field] = new_records + + # Handle configuration variables if provided. + configuration_variables = kwargs.pop("configuration_variables", None) + configuration_variable_options = kwargs.pop( + "configuration_variable_options", {} + ) + + if configuration_variables: + # Validate required variables + self._validate_required_variables(configuration_variables) + + # Search for existing variable options. + option_references = list(configuration_variable_options.values()) + existing_options = option_references and self.env[ + "cx.tower.variable.option" + ].search([("reference", "in", option_references)]) + missing_options = list( + set(option_references) + - {option.reference for option in existing_options} + ) + + if missing_options: + # Map variable references to their corresponding + # invalid option references. + missing_options_to_variables = { + var_ref: opt_ref + for var_ref, opt_ref in configuration_variable_options.items() + if opt_ref in missing_options + } + # Generate a detailed error message for invalid variable options. + detailed_message = "\n".join( + _( + "Variable reference '%(var_ref)s' has an invalid " + "option reference '%(opt_ref)s'.", + var_ref=var_ref, + opt_ref=opt_ref, + ) + for var_ref, opt_ref in missing_options_to_variables.items() + ) + raise ValidationError( + _( + "Some variable options are invalid:\n%(detailed_message)s", + detailed_message=detailed_message, + ) + ) + + # Map variable options to their IDs. + configuration_variable_options_dict = { + option.variable_id.id: option for option in existing_options + } + + variable_obj = self.env["cx.tower.variable"] + variable_references = list(configuration_variables.keys()) + + # Search for existing variables or create new ones if missing. + exist_variables = variable_obj.search( + [("reference", "in", variable_references)] + ) + missing_references = list( + set(variable_references) + - {variable.reference for variable in exist_variables} + ) + variable_vals_list = [ + {"name": reference} for reference in missing_references + ] + new_variables = variable_obj.create(variable_vals_list) + all_variables = exist_variables | new_variables + + # Build a dictionary {variable: variable_value}. + configuration_variable_dict = { + variable: configuration_variables[variable.reference] + for variable in all_variables + } + + server_variable_vals_list = [] + for variable, variable_value in configuration_variable_dict.items(): + variable_option = configuration_variable_options_dict.get(variable.id) + + server_variable_vals_list.append( + ( + 0, + 0, + { + "variable_id": variable.id, + "value_char": variable_option + and variable_option.value_char + or variable_value, + "option_id": variable_option and variable_option.id, + }, + ) + ) + + if pick_all_template_variables: + # update or add variable values + existing_variable_values = values.get("variable_value_ids", []) + variable_id_to_index = { + cmd[2]["variable_id"]: idx + for idx, cmd in enumerate(existing_variable_values) + if cmd[0] == 0 and "variable_id" in cmd[2] + } + + # Update exist variable options + for exist_variable_id, index in variable_id_to_index.items(): + option = configuration_variable_options_dict.get(exist_variable_id) + if not option: + continue + existing_variable_values[index][2].update( + { + "option_id": option.id, + "value_char": option.value_char, + } + ) + + # Prepare new command values for server variables + for new_command in server_variable_vals_list: + variable_id = new_command[2]["variable_id"] + if variable_id in variable_id_to_index: + idx = variable_id_to_index[variable_id] + # update exist command + existing_variable_values[idx] = new_command + else: + # add new command + existing_variable_values.append(new_command) + + values["variable_value_ids"] = existing_variable_values + else: + values["variable_value_ids"] = server_variable_vals_list + + # remove the `id` field to ensure a new record is created + # instead of updating the existing one + del values["id"] + # update the values with additional arguments from kwargs + values.update(kwargs) + # update server configs + values.update(server_config_values) + # Add current user as user/manager to the newly created server + values.update( + { + "user_ids": [(6, 0, self._default_user_ids())], + "manager_ids": [(6, 0, self._default_manager_ids())], + } + ) + + return values + + def _parse_server_config_values(self, config_values): + """ + Prepares server configuration values. + + Args: + config_values (dict): A dictionary containing server configuration values. + Keys and their expected values: + - partner (res.partner, optional): The partner this server + belongs to. + - ipv4 (str, optional): IPv4 address. Defaults to None. + - ipv6 (str, optional): IPv6 address. Must be provided if IPv4 is + not specified. Defaults to None. + - ssh_key (str, optional): Reference to an SSH private key record. + Defaults to None. + + Returns: + dict: A dictionary containing parsed server configuration values with the + following keys: + - partner_id (int, optional): ID of the partner. + - ssh_key_id (int, optional): ID of the associated SSH key. + - ip_v4_address (str, optional): Parsed IPv4 address. + - ip_v6_address (str, optional): Parsed IPv6 address. + """ + values = {} + + # This field is always populated from Server Template and + # cannot be altered with function params. + config_values.pop("plan_delete_id", None) + + partner = config_values.pop("partner", None) + if partner: + values["partner_id"] = partner.id + + ssh_key_reference = config_values.pop("ssh_key", None) + if ssh_key_reference: + ssh_key = self.env["cx.tower.key"].get_by_reference(ssh_key_reference) + if ssh_key: + values["ssh_key_id"] = ssh_key.id + + ipv4 = config_values.pop("ipv4", None) + if ipv4: + values["ip_v4_address"] = ipv4 + + ipv6 = config_values.pop("ipv6", None) + if ipv6: + values["ip_v6_address"] = ipv6 + + return values + + def _validate_required_variables(self, configuration_variables): + """ + Validate that all required variables are present, not empty, + and that no required variable is entirely missing from the configuration. + + Args: + configuration_variables (dict): A dictionary of variable references + and their values. + + Raises: + ValidationError: If all required variables are + missing from the configuration, + or if any required variable is empty or missing. + """ + required_variables = self.variable_value_ids.filtered("required") + if not required_variables: + return + + required_refs = [var.variable_reference for var in required_variables] + config_refs = list(configuration_variables.keys()) + + missing_variables = [ref for ref in required_refs if ref not in config_refs] + empty_variables = [ + ref + for ref in required_refs + if ref in config_refs and not configuration_variables[ref] + ] + + if not (missing_variables or empty_variables): + return + + error_parts = [ + _("Please resolve the following issues with configuration variables:") + ] + + if missing_variables: + error_parts.append( + _( + " - Missing variables: %(variables)s", + variables=", ".join(missing_variables), + ) + ) + + if empty_variables: + error_parts.append( + _( + " - Empty values for variables: %(variables)s", + variables=", ".join(empty_variables), + ) + ) + + raise ValidationError("\n".join(error_parts)) + + def _get_dependent_model_relation_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_dependent_model_relation_fields() + return res + ["variable_value_ids"] diff --git a/addons/cetmix_tower_server/models/cx_tower_shortcut.py b/addons/cetmix_tower_server/models/cx_tower_shortcut.py new file mode 100644 index 0000000..7418d7e --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_shortcut.py @@ -0,0 +1,97 @@ +# Copyright (C) 2024 Cetmix OÜ +# License OPL-1 (https://apps.odoocdn.com/loempia/static/examples/LICENSE). +from odoo import _, fields, models + + +class CxTowerShortcut(models.Model): + """ + Cetmix Tower Shortcut. + Used to run commands or flight plans with a single click. + """ + + _name = "cx.tower.shortcut" + _inherit = ["cx.tower.access.mixin", "cx.tower.reference.mixin"] + _description = "Cetmix Tower Shortcut" + _order = "sequence, name" + + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + server_ids = fields.Many2many( + string="Servers", + comodel_name="cx.tower.server", + relation="cx_tower_server_shortcut_rel", + column1="shortcut_id", + column2="server_id", + ) + server_template_ids = fields.Many2many( + string="Server Templates", + comodel_name="cx.tower.server.template", + relation="cx_tower_server_template_shortcut_rel", + column1="shortcut_id", + column2="server_template_id", + ) + action = fields.Selection( + selection=[("command", "Command"), ("plan", "Flight Plan")], required=True + ) + command_id = fields.Many2one(comodel_name="cx.tower.command") + use_sudo = fields.Boolean( + help="Run command using 'sudo'", + ) + plan_id = fields.Many2one(string="Flight Plan", comodel_name="cx.tower.plan") + note = fields.Text() + + def run(self, server=None): + """Runs related shortcut action + + Args: + server (cx.tower.server): Server to run the shortcut. + """ + self.ensure_one() + + # Try to obtain server from context if not provided as an argument + if server is None: + server_id = self.env.context.get("server_id") + + # Just return, no exceptions for now + if not server_id: + return + + server = self.env["cx.tower.server"].browse(server_id) + + # Just return, no exceptions for now + if not server: + return + + # Use the first server record if several are passed + if len(server) > 1: + server = server[0] + if self.action == "command" and self.command_id: + server.run_command(self.sudo().command_id, sudo=self.use_sudo) + elif self.action == "plan" and self.plan_id: + server.run_flight_plan(self.sudo().plan_id) + + # Notify + return self._notify_on_run(server) + + def _notify_on_run(self, server): + """Send notification when shortcut is triggered. + Override to implement custom notifications. + + Args: + server (cx.tower.server()): Server action was triggered for + + Returns: + Boolean: True if notification was sent. + """ + self.ensure_one() + + self.env.user.notify_info( + title=server.name, + message=_( + "Shortcut '%(shr)s' triggered. Check %(t)s log for result", + shr=self.name, + t="flight plan" if self.action == "plan" else "command", + ), + sticky=False, + ) + return True diff --git a/addons/cetmix_tower_server/models/cx_tower_tag.py b/addons/cetmix_tower_server/models/cx_tower_tag.py new file mode 100644 index 0000000..f7fd206 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_tag.py @@ -0,0 +1,91 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerTag(models.Model): + """ + Cetmix Tower Tag. + Tags are used to group servers, commands, flight plans, etc. + """ + + _name = "cx.tower.tag" + _inherit = [ + "cx.tower.reference.mixin", + ] + _description = "Cetmix Tower Tag" + _order = "name" + + color = fields.Integer(help="For better visualization in views") + + # --- Relations + server_ids = fields.Many2many( + comodel_name="cx.tower.server", + relation="cx_tower_server_tag_rel", + column1="tag_id", + column2="server_id", + string="Servers", + ) + command_ids = fields.Many2many( + comodel_name="cx.tower.command", + relation="cx_tower_command_tag_rel", + column1="tag_id", + column2="command_id", + string="Commands", + ) + plan_ids = fields.Many2many( + comodel_name="cx.tower.plan", + relation="cx_tower_plan_tag_rel", + column1="tag_id", + column2="plan_id", + string="Plans", + ) + server_template_ids = fields.Many2many( + comodel_name="cx.tower.server.template", + relation="cx_tower_server_template_tag_rel", + column1="tag_id", + column2="server_template_id", + string="Server Templates", + ) + file_template_ids = fields.Many2many( + comodel_name="cx.tower.file.template", + relation="cx_tower_file_template_tag_rel", + column1="tag_id", + column2="file_template_id", + string="File Templates", + ) + + def unlink(self): + """ + Prevent deletion of tags that are in use + unless user is root or using sudo. + """ + if not self.env.is_superuser() and not self.env.user.has_group( + "cetmix_tower_server.group_root" + ): + self._check_tags_can_be_deleted() + return super().unlink() + + def _check_tags_can_be_deleted(self): + """Check if tags can be deleted. + + Raises: + ValidationError: If tag is in use + """ + + for tag in self: + if ( + tag.server_ids + or tag.command_ids + or tag.plan_ids + or tag.server_template_ids + or tag.file_template_ids + ): + raise ValidationError( + _( + "Cannot delete tag '%(tag_name)s' because" + " it is used in related records.", + tag_name=tag.name, + ) + ) diff --git a/addons/cetmix_tower_server/models/cx_tower_tag_mixin.py b/addons/cetmix_tower_server/models/cx_tower_tag_mixin.py new file mode 100644 index 0000000..b36a929 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_tag_mixin.py @@ -0,0 +1,116 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class CxTowerTagMixin(models.AbstractModel): + """ + Cetmix Tower Tag Mixin. + Used to add tag functionality to models. + """ + + _name = "cx.tower.tag.mixin" + _description = "Cetmix Tower Tag Mixin" + + tag_ids = fields.Many2many( + comodel_name="cx.tower.tag", + string="Tags", + ) + + def add_tags(self, tag_names): + """Add tags to the record + + Args: + tag_names (list of Char or Char): List of tag names to add + or single tag name + """ + # Single tag name is given, convert to list + if isinstance(tag_names, str): + tag_names = [tag_names] + # Invalid type is given, return True + elif not isinstance(tag_names, list): + return True + + tags = self.env["cx.tower.tag"].search([("name", "in", tag_names)]) + if tags: + self.write({"tag_ids": [(4, tag.id) for tag in tags]}) + return True + + def remove_tags(self, tag_names): + """Remove tags from the record + + Args: + tag_names (list of Char or Char): List of tag names to remove + or single tag name. + """ + # Single tag name is given, convert to list + if isinstance(tag_names, str): + tag_names = [tag_names] + # Invalid type is given, return True + elif not isinstance(tag_names, list): + return True + + tags = self.env["cx.tower.tag"].search([("name", "in", tag_names)]) + if tags: + self.write({"tag_ids": [(3, tag.id) for tag in tags]}) + return True + + def has_tags(self, tag_name, search_all=False): + """Get all records from the recordset that have any of the given tags + + Args: + tag_name (Char or List of Char): Tag name or list of tag names to check + search_all (bool): If True, search all records in the model + """ + + # Empty recordset is returned as is + if not self and not search_all: + return self + + # Check argument type + if isinstance(tag_name, str): + single_tag = True + elif isinstance(tag_name, list): + single_tag = False + else: + return self.browse() + + if search_all: + if single_tag: + domain = [("tag_ids.name", "=", tag_name)] + else: + domain = [("tag_ids.name", "in", tag_name)] + return self.env[self._name].search(domain) + + if single_tag: + return self.filtered( + lambda record: tag_name in record.tag_ids.mapped("name") + ) + return self.filtered( + lambda record: set(tag_name) & set(record.tag_ids.mapped("name")) + ) + + def has_all_tags(self, tag_names, search_all=False): + """Get all records from the recordset that have all of the given tags + + Args: + tag_names (list of Char): List of tag names to check + search_all (bool): If True, search all records in the model + """ + # No value or invalid type is given, return empty recordset + if not tag_names or not isinstance(tag_names, list): + return self.browse() + + # Empty recordset is returned as is + if not self and not search_all: + return self + + if search_all: + records = self.env[self._name].search([("tag_ids.name", "in", tag_names)]) + else: + records = self + + tag_names_set = set(tag_names) + return records.filtered( + lambda record: tag_names_set.issubset(record.tag_ids.mapped("name")) + ) diff --git a/addons/cetmix_tower_server/models/cx_tower_template_mixin.py b/addons/cetmix_tower_server/models/cx_tower_template_mixin.py new file mode 100644 index 0000000..05b21ab --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_template_mixin.py @@ -0,0 +1,217 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from jinja2 import Environment, Template, meta +from jinja2.exceptions import TemplateSyntaxError + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class CxTowerTemplateMixin(models.AbstractModel): + """Used to implement template rendering functions. + Inherit in your model in case you want to render variable values in it. + """ + + _name = "cx.tower.template.mixin" + _description = "Cetmix Tower template rendering mixin" + + code = fields.Text(help="This field will be rendered using variables") + variable_ids = fields.Many2many( + string="Variables", + comodel_name="cx.tower.variable", + compute="_compute_variable_ids", + store=True, + copy=False, + ) + + @classmethod + def _get_depends_fields(cls): + """ + Define dependent fields for the `variable_ids` computation. + + This method should be overridden in inheriting models to provide + a list of fields that influence the computation of `variable_ids`. + These fields are used in the `@api.depends` decorator to trigger + recomputation when their values change. + + Returns: + list: A list of field names (str) that are dependencies for + the `variable_ids` computation. Default is an empty list. + + Example: + In a subclass, override as follows: + >>> @classmethod + >>> def _get_depends_fields(cls): + >>> return ["code", "path"] + """ + return [] + + @api.depends(lambda self: self._get_depends_fields()) + def _compute_variable_ids(self): + """ + Compute the values of the `variable_ids` + field based on model-specific dependencies. + + This method retrieves the dependent fields using `_get_depends_fields` + and dynamically calculates the values of `variable_ids` using the + `_prepare_variable_commands` method. + + If no dependent fields or relation parameters are defined, the field + is reset to an empty list. + + Example: + If dependent fields include `code` and `path`, and the model-specific + logic links them to variables, this method will update the `variable_ids` + field accordingly. + + Raises: + ValidationError: If the field metadata is incorrectly defined or + missing required attributes. + + Returns: + None: The field `variable_ids` is updated in-place for each record. + """ + depends_fields = self._get_depends_fields() + + for record in self: + if depends_fields: + record.variable_ids = record._prepare_variable_commands(depends_fields) + else: + record.variable_ids = [(5, 0, 0)] + + def render_code(self, pythonic_mode=False, **kwargs): + """Render record 'code' field using variables from kwargs + Call to render recordset of the inheriting models + Args: + pythonic_mode (Bool): If True, all variables in kwargs are converted to + strings and wrapped in double quotes. + Default is False. + **kwargs (dict): {variable: value, ...} + Returns: + dict {record_id: rendered_code, ...} + """ + return { + rec.id: self.render_code_custom(rec.code, pythonic_mode, **kwargs) + for rec in self + } + + def render_code_custom(self, code, pythonic_mode=False, **kwargs): + """ + Render custom code using variables from kwargs + + This method renders a template string (code) using the variables provided + in kwargs. If pythonic_mode is enabled, all variables are automatically + converted to strings and enclosed in double quotes before rendering. + + Args: + code (Text): code to render (eg 'some {{ custom }} text') + pythonic_mode (Bool): If True, all variables in kwargs are converted to + strings and wrapped in double quotes. + Default is False. + **kwargs (dict): {variable: value, ...} + Returns: + rendered_code (text): The resulting string after rendering the template with + the provided variables. + """ + + # Return the original code if it's empty. + # So if it's False then we preserve the original 'False' value. + if not code: + return code + + try: + if pythonic_mode: + kwargs = { + key: self._make_value_pythonic(value) + for key, value in kwargs.items() + } + return Template(code, trim_blocks=True).render(kwargs) + except Exception as e: + raise UserError(str(e)) from e + + def get_variables(self): + """Get the list of variables for templates + Call to get variables for recordset of the inheriting models + + Returns: + dict {'record_id': {variables}...} + NB: 'record_id' is String + """ + res = {} + for rec in self: + res[str(rec.id)] = self.get_variables_from_code(rec.code) + return res + + def get_variables_from_code(self, code): + """Get the list of variables for templates + Call to get variables from custom code string + + Args: + code (Text) custom code (eg 'Custom {{ var }} {{ var2 }} ...') + Returns: + variables (List) variables (eg ['var','var2',..]) + """ + if not code: + return [] + env = Environment() + try: + ast = env.parse(code) + undeclared_variables = meta.find_undeclared_variables(ast) + return list(undeclared_variables) if undeclared_variables else [] + except TemplateSyntaxError as e: + raise ValidationError(_("Variable syntax error: %s", e)) from e + + def _prepare_variable_commands(self, field_names, force_record=None): + """ + Prepares commands to set variable references from the given fields. + + Args: + field_names (list): List of field names to extract variable references from. + force_record (record, optional): A record to use instead of the current one. + + Returns: + list: An Odoo command to assign or clear variable references. + """ + record = force_record or self + record.ensure_one() + + all_references = set() + for field_name in field_names: + value = getattr(record, field_name, None) + if value: + all_references.update(self.get_variables_from_code(value)) + + if all_references: + variables = self.env["cx.tower.variable"].search( + [("reference", "in", list(all_references))] + ) + command = [(6, 0, variables.ids)] + else: + command = [(5, 0, 0)] + + return command + + def _make_value_pythonic(self, value): + """Prepares value for use in 'pythonic' mode + by enclosing strings into double quotes + + Args: + value (Char): value to process + + Returns: + Char: processed value + """ + + # Nothing to do here + if isinstance(value, bool) or value is None: + result = value + + # Handle nested dicts such as system variables + elif isinstance(value, dict): + result = {} + for key, val in value.items(): + result.update({key: self._make_value_pythonic(val)}) + else: + # Enclose in double quotes + result = f'"{value}"' + return result diff --git a/addons/cetmix_tower_server/models/cx_tower_variable.py b/addons/cetmix_tower_server/models/cx_tower_variable.py new file mode 100644 index 0000000..62544bc --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_variable.py @@ -0,0 +1,900 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +import uuid +from urllib.parse import urlparse + +from odoo import api, fields, models +from odoo.tools import LazyTranslate +from odoo.tools.safe_eval import safe_eval, wrap_module + +_lt = LazyTranslate(__name__, default_lang="en_US") + + +_logger = logging.getLogger(__name__) + +re = wrap_module( + __import__("re"), + [ + "match", + "fullmatch", + "search", + "sub", + "subn", + "split", + "findall", + "finditer", + "compile", + "template", + "escape", + "error", + ], +) + +# Maximum recursion depth for variable value rendering +# to prevent infinite loops +MAX_DEPTH = 10 + + +class TowerVariable(models.Model): + """Variables""" + + _name = "cx.tower.variable" + _description = "Cetmix Tower Variable" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.access.mixin", + "cx.tower.tag.mixin", + ] + + _order = "name" + + DEFAULT_VALIDATION_MESSAGE = _lt("Invalid value!") + SYSTEM_VARIABLE_REFERENCE = "tower" + + value_ids = fields.One2many( + string="Values", + comodel_name="cx.tower.variable.value", + inverse_name="variable_id", + ) + value_ids_count = fields.Integer( + string="Value Count", compute="_compute_variable_counters" + ) + option_ids = fields.One2many( + comodel_name="cx.tower.variable.option", + inverse_name="variable_id", + string="Options", + auto_join=True, + ) + variable_type = fields.Selection( + selection=[("s", "String"), ("o", "Options")], + default="s", + required=True, + string="Type", + ) + applied_expression = fields.Text( + help="Python expression to apply to the variable value. \n" + "You can use general python sting functions and 're' module " + "for regex operations. " + "Use 'value' variable to refer to the variable value, use 'result'" + " to assign the final result that will be used as a variable value.\n" + "Eg 'result = value.lower().replace(' ', '_')'", + ) + validation_pattern = fields.Char( + help="Regex pattern to validate the variable values using the " + "'re.match' function. Eg. ^[a-z0-9]+$ \n" + "If empty, the variable values will not be validated.", + ) + validation_message = fields.Char( + translate=True, + help="Message to display when the variable value is invalid. \n" + "First line will be added automatically: " + "`Variable:, Value: `\n" + "Eg: `Variable: Customer Name, Value: Test\nInvalid value!`\n" + "If empty, the default message will be used.", + ) + note = fields.Text( + help="Additional notes about the variable. \n" + "This field will be displayed in the variable form.", + ) + + # --- Link to records where the variable is used + command_ids = fields.Many2many( + comodel_name="cx.tower.command", + relation="cx_tower_command_variable_rel", + column1="variable_id", + column2="command_id", + copy=False, + ) + command_ids_count = fields.Integer( + string="Command Count", compute="_compute_variable_counters" + ) + plan_line_ids = fields.Many2many( + comodel_name="cx.tower.plan.line", + relation="cx_tower_plan_line_variable_rel", + column1="variable_id", + column2="plan_line_id", + copy=False, + ) + plan_line_ids_count = fields.Integer( + string="Plan Line Count", compute="_compute_variable_counters" + ) + file_ids = fields.Many2many( + comodel_name="cx.tower.file", + relation="cx_tower_file_variable_rel", + column1="variable_id", + column2="file_id", + copy=False, + ) + file_ids_count = fields.Integer( + string="File Count", compute="_compute_variable_counters" + ) + file_template_ids = fields.Many2many( + comodel_name="cx.tower.file.template", + relation="cx_tower_file_template_variable_rel", + column1="variable_id", + column2="file_template_id", + copy=False, + ) + file_template_ids_count = fields.Integer( + string="File Template Count", compute="_compute_variable_counters" + ) + variable_value_ids = fields.Many2many( + comodel_name="cx.tower.variable.value", + relation="cx_tower_variable_value_variable_rel", + column1="variable_id", + column2="variable_value_id", + copy=False, + ) + variable_value_ids_count = fields.Integer( + string="Variable Value Count", compute="_compute_variable_counters" + ) + + _sql_constraints = [("name_uniq", "unique (name)", "Variable names must be unique")] + + def _compute_variable_counters(self): + """Count number of variable values for the variable""" + for rec in self: + rec.update( + { + "variable_value_ids_count": len(rec.variable_value_ids), + "command_ids_count": len(rec.command_ids), + "plan_line_ids_count": len(rec.plan_line_ids), + "file_ids_count": len(rec.file_ids), + "file_template_ids_count": len(rec.file_template_ids), + "value_ids_count": len(rec.value_ids), + } + ) + + def action_open_values(self): + """Open the variable values""" + self.ensure_one() + context = self.env.context.copy() + context.update( + { + "default_variable_id": self.id, + } + ) + + return { + "type": "ir.actions.act_window", + "name": self.env._("Variable Values"), + "res_model": "cx.tower.variable.value", + "views": [[False, "list"]], + "target": "current", + "context": context, + "domain": [("variable_id", "=", self.id)], + } + + def action_open_commands(self): + """Open the commands where the variable is used""" + + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command" + ) + action.update( + { + "domain": [("variable_ids", "in", self.ids)], + } + ) + return action + + def action_open_plan_lines(self): + """Open the plan lines where the variable is used""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.env._("Plan Lines"), + "res_model": "cx.tower.plan.line", + "views": [ + [False, "tree"], + [ + self.env.ref("cetmix_tower_server.cx_tower_plan_line_view_form").id, + "form", + ], + ], + "target": "current", + "domain": [("variable_ids", "in", self.ids)], + } + + def action_open_files(self): + """Open the files where the variable is used""" + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_action" + ) + action.update( + { + "domain": [("variable_ids", "in", self.ids)], + } + ) + return action + + def action_open_file_templates(self): + """Open the file templates where the variable is used""" + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_template_action" + ) + action.update( + { + "domain": [("variable_ids", "in", self.ids)], + } + ) + return action + + def action_open_variable_values(self): + """Open the variable values where the variable is used""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.env._("Variable Values"), + "res_model": "cx.tower.variable.value", + "views": [[False, "list"]], + "target": "current", + "domain": [("variable_ids", "in", self.ids)], + } + + @api.model + def _get_eval_context(self, value_char=None): + """ + Evaluation context to pass to safe_eval to evaluate + the Python expression used in the `applied_expression` field + + Args: + value_char (Char): variable value + + Returns: + dict: evaluation context + """ + return { + "re": re, + "value": value_char, + } + + # Reference rename propagation + + def write(self, vals): + """Override the write method to propagate variable reference updates. + + Records the old reference values, performs the write, and if the reference + field has changed, initiates propagation to update related records. + """ + old_refs = ( + {rec.id: rec.reference for rec in self} if "reference" in vals else {} + ) + res = super().write(vals) + if "reference" in vals: + for rec in self: + old_ref = old_refs.get(rec.id) + if old_ref and old_ref != rec.reference: + rec._propagate_reference_change(old_ref, rec.reference) + return res + + def _propagate_reference_change(self, old_ref, new_ref): + """Replace all occurrences of an old variable reference with a new one. + + Compiles a pattern matching the old Jinja-style reference, then searches across + configured models and fields to substitute any matches, preserving formatting. + """ + pattern = re.compile(r"(\{\{\s*)" + re.escape(old_ref) + r"(\s*\}\})") + + def _replace(text): + """Helper to replace old_ref with new_ref in the given text.""" + return pattern.sub(lambda m: f"{m.group(1)}{new_ref}{m.group(2)}", text) + + model_fields_map = self._get_propagation_field_mapping() + + for model_name, field_names in model_fields_map.items(): + Model = self.env[model_name] + + if model_name == "cx.tower.variable.value": + domain = [("variable_id", "=", self.id)] + else: + domain = [("variable_ids", "in", self.ids)] + + for record in Model.search(domain): + vals = {} + for field_name in field_names: + value = record[field_name] + if isinstance(value, str) and old_ref in value: + new_value = _replace(value) + if new_value != value: + vals[field_name] = new_value + + if vals: + record.with_context(skip_reference_propagation=True).write(vals) + _logger.debug( + "Variable reference updated in %s(%s): %s", + model_name, + record.id, + ", ".join(vals.keys()), + ) + + def _get_propagation_field_mapping(self): + """Return the mapping of models to fields for reference change propagation. + + The returned dict maps each model name to a list of field names + that may contain variable references requiring updates. + """ + return { + "cx.tower.command": ["code", "path"], + "cx.tower.file": ["code", "server_dir", "name"], + "cx.tower.file.template": ["code", "server_dir", "file_name"], + "cx.tower.variable.value": ["value_char"], + "cx.tower.plan.line": ["condition"], + } + + def _get_dependent_model_relation_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_dependent_model_relation_fields() + return res + ["value_ids"] + + def _validate_value(self, value_char=None): + """ + Validate the variable value + + Args: + value_char (Char): variable value + + Returns: + (Boolean, Char): (is_valid, validation_message) + """ + self.ensure_one() + if ( + not self.validation_pattern + or not value_char + or re.match(self.validation_pattern, value_char) # pylint: disable=no-member + ): + return True, None + message = self.validation_message or self.DEFAULT_VALIDATION_MESSAGE + return ( + False, + self.env._( + "Variable: %(var)s, Value: %(val)s\n%(msg)s", + msg=message, + var=self.name, # pylint: disable=no-member + val=value_char, + ), + ) + + # ------------------------------ + # ---- Managing variable values + # ------------------------------ + def _get_value( + self, + server=None, + server_template=None, + plan_line_action=None, + jet_template=None, + jet=None, + ): + """Get the value of the variable. + + 0. No arguments: return the global value. + 1. Server Template: return the Server Template specific value + or the global value. + 2. Server: return the Server specific value or the global value. + 3. Jet Template: return the Jet Template specific value + or the Server value + or the global value. + 4. Jet: return the Jet specific value + or the Jet Template value + or the Server value + or the global value. + 5. Plan Line Action: return the Plan Line Action specific value. + + Args: + server (cx.tower.server): Server + server_template (cx.tower.server.template): Server Template + plan_line_action (cx.tower.plan.line.action): Plan Line Action + jet_template (cx.tower.jet.template): Jet Template + jet (cx.tower.jet): Jet + + Returns: + Char: The value of the variable or None if no value is found. + """ + self.ensure_one() + values = self.value_ids + + # 0. Set server and jet template from jet + # if jet is provided + if jet: + server = jet.server_id + jet_template = jet.jet_template_id + + # 1. Prepare the values + + # Initialize all values to None + global_value_char = server_value_char = server_template_value_char = ( + plan_line_action_value_char + ) = jet_template_value_char = jet_value_char = None + + # Get origin id's in case we are dealing with onchange() + server_id = ( + server._origin.id + if server and hasattr(server, "_origin") + else server.id + if server + else None + ) + server_template_id = ( + server_template._origin.id + if server_template and hasattr(server_template, "_origin") + else server_template.id + if server_template + else None + ) + plan_line_action_id = ( + plan_line_action._origin.id + if plan_line_action and hasattr(plan_line_action, "_origin") + else plan_line_action.id + if plan_line_action + else None + ) + jet_template_id = ( + jet_template._origin.id + if jet_template and hasattr(jet_template, "_origin") + else jet_template.id + if jet_template + else None + ) + jet_id = ( + jet._origin.id + if jet and hasattr(jet, "_origin") + else jet.id + if jet + else None + ) + + # Check all values for the variable and assign them. + # Note: we are not using filtered() to avoid multiple iterations + # on the same recordset. + for variable_value in values: + # Fetch the server value + if ( + server + and server_value_char is None + and variable_value.server_id.id == server_id + ): + server_value_char = variable_value.value_char + continue + # Fetch the server template value + if ( + server_template + and server_template_value_char is None + and variable_value.server_template_id.id == server_template_id + ): + server_template_value_char = variable_value.value_char + continue + # Fetch the plan line action value + if ( + plan_line_action + and plan_line_action_value_char is None + and variable_value.plan_line_action_id.id == plan_line_action_id + ): + plan_line_action_value_char = variable_value.value_char + continue + # Fetch the jet template value + if ( + jet_template + and jet_template_value_char is None + and variable_value.jet_template_id.id == jet_template_id + ): + jet_template_value_char = variable_value.value_char + continue + # Fetch the jet value + if jet and jet_value_char is None and variable_value.jet_id.id == jet_id: + jet_value_char = variable_value.value_char + continue + # Fetch the global value + if global_value_char is None and variable_value.is_global: + global_value_char = variable_value.value_char + + # 2. Compose the response + # 2.1. Server Template + if server_template: + return server_template_value_char or global_value_char + + # 2.2. Jet + if jet: + return ( + jet_value_char + if jet_value_char is not None + else jet_template_value_char + if jet_template_value_char is not None + else server_value_char + if server_value_char is not None + else global_value_char + ) + + # 2.3. Jet Template + if jet_template: + return ( + jet_template_value_char + if jet_template_value_char is not None + else server_value_char + if server_value_char is not None + else global_value_char + ) + + # 2.4. Server + if server: + return ( + server_value_char + if server_value_char is not None + else global_value_char + ) + + # 2.5. Plan Line Action + if plan_line_action: + return plan_line_action_value_char + + # 2.6. Global + return global_value_char + + @api.model + def _get_variable_values_by_references( + self, + variable_references, + apply_modifiers=True, + **kwargs, + ): + """Get variable values for multiple references. + This method is designed to be used for template rendering. + It also includes system variable values in the result. + + Args: + variable_references (list of Char): variable names + apply_modifiers (bool): apply Python modifiers to the values + **kwargs: keyword arguments to pass to the _get_value method + - server (cx.tower.server): Server + - server_template (cx.tower.server.template): Server Template + - plan_line_action (cx.tower.plan.line.action): Plan Line Action + - jet_template (cx.tower.jet.template): Jet Template + - jet (cx.tower.jet): Jet + - _depth (int): Depth of the recursion + Returns: + dict {variable_reference: value} + """ + # 0. Get keyword arguments + server = kwargs.get("server") + server_template = kwargs.get("server_template") + plan_line_action = kwargs.get("plan_line_action") + jet_template = kwargs.get("jet_template") + jet = kwargs.get("jet") + _depth = kwargs.get("_depth", 0) + + # 0. Update server and jet template from jet + if jet: + server = jet.server_id + jet_template = jet.jet_template_id + + # 1. Get system variable values + variable_values = {} + system_vars = self._get_system_variable_values( + server=server, jet_template=jet_template, jet=jet + ) + if system_vars: + variable_values[self.SYSTEM_VARIABLE_REFERENCE] = system_vars + + # Return just system variable values if no references are provided + # or the only one is the system variable + # Need a fallback in case system variable is provides several times + if not variable_references or ( + all( + reference == self.SYSTEM_VARIABLE_REFERENCE + for reference in variable_references + ) + ): + return variable_values + + # 2. Get variable value records + for reference in variable_references: + # Do not overwrite system variable values + if reference == self.SYSTEM_VARIABLE_REFERENCE: + continue + variable = self.get_by_reference(reference) # pylint: disable=no-member + + # Assign the value to the variable values dictionary + variable_value = ( + variable._get_value( + server=server, + server_template=server_template, + plan_line_action=plan_line_action, + jet_template=jet_template, + jet=jet, + ) + if variable + else None + ) + variable_values[reference] = variable_value + + # 3. Render templates in values + self._render_variable_values( + variable_values, + server=server, + jet_template=jet_template, + jet=jet, + _depth=_depth, + ) + + # 4. Apply modifiers + if apply_modifiers: + self._apply_modifiers(variable_values) + + return variable_values + + def _render_variable_values(self, variable_values, **kwargs): + """Renders variable values using other variable values. + For example we have the following values: + "server_root": "/opt/server" + "server_assets": "{{ server_root }}/assets" + + This function will render the "server_assets" variable: + "server_assets": "/opt/server/assets" + + Args: + variable_values (dict): variable values to complete + **kwargs: keyword arguments to pass to the _get_value method + - server (cx.tower.server): Server + - server_template (cx.tower.server.template): Server Template + - plan_line_action (cx.tower.plan.line.action): Plan Line Action + - jet_template (cx.tower.jet.template): Jet Template + - jet (cx.tower.jet): Jet + - _depth (int): Depth of the recursion + """ + # 0. Get keyword arguments + server = kwargs.get("server") + jet_template = kwargs.get("jet_template") + jet = kwargs.get("jet") + _depth = kwargs.get("_depth", 0) + + # Control recursion depth + _depth += 1 + if _depth > MAX_DEPTH: + _logger.error("Max depth %d reached for variable %s", _depth, self.name) + return + + TemplateMixin = self.env["cx.tower.template.mixin"] + for key, var_value in variable_values.items(): + # Skip system variable values + if not var_value or key == self.SYSTEM_VARIABLE_REFERENCE: + continue + + # Render only if template is found + if "{{" in var_value and "}}" in var_value: + # Get variables used in value + value_vars = TemplateMixin.get_variables_from_code(var_value) + + # Render variables used in value + values_for_value = self._get_variable_values_by_references( + value_vars, + apply_modifiers=True, + server=server, + jet_template=jet_template, + jet=jet, + _depth=_depth, + ) + + # Render value using variables + variable_values[key] = TemplateMixin.render_code_custom( + var_value, **values_for_value + ) + + def _apply_modifiers(self, variable_values): + """Apply pre-defined Python expression to the dictionary + of variable values. + + Args: + variable_values (dict): variable values + {variable_reference: value} + """ + + for variable_reference, value in variable_values.items(): + if not value: + continue + + # ORM should cache resolved variables + variable = self.get_by_reference(variable_reference) + + # Should never happen.. anyway + if not variable: + continue + + # Skip if no expression to apply + if not variable.applied_expression: + continue + + # Evaluate expression + eval_context = variable._get_eval_context(value) + try: + safe_eval( + variable.applied_expression, + eval_context, + mode="exec", + nocopy=True, + ) + variable_values[variable_reference] = eval_context.get("result", value) + except Exception as e: + _logger.error( + "Error evaluating applied expression for " + "variable %s value %s: %s", + variable.name, + value, + str(e), + ) + + @api.model + def _get_system_variable_values(self, server=None, jet_template=None, jet=None): + """ + Get the values for the `tower` system variable. + This variable uses `tower..` format. + E.g. `tower.server.ipv6`, `tower.tools.uuid`, + `tower.jet_template.reference`, `tower.tools.now_underscore` etc. + + + Args: + server (cx.tower.server()): server record + jet_template (cx.tower.jet.template()): jet template record + jet (cx.tower.jet()): jet record + + Returns: + dict(): `tower` values. + { + 'tools': {..helper tools vals...} + 'server': {..server vals..}, + 'jet_template': {..jet template vals..}, + 'jet': {..jet vals..}, + } + """ + return { + "tools": self._parse_system_variable_tools(), + "server": self._parse_system_variable_server(server), + "jet_template": self._parse_system_variable_jet_template(jet_template), + "jet": self._parse_system_variable_jet(jet), + } + + def _parse_system_variable_server(self, server=None): + """Parser system variable of `server` type. + + Args: + server (cx.tower.server()): server record + + Returns: + dict(): `server` values of the `tower` variable. + """ + # Get current server + values = {} + if server: + # Using sudo() to get all fields + server = server.sudo() + values = { + "name": server.name, + "reference": server.reference, + "username": server.ssh_username, + "partner_name": server.partner_id.name if server.partner_id else False, + "ipv4": server.ip_v4_address, + "ipv6": server.ip_v6_address, + "status": server.status, + "os": server.os_id.name if server.os_id else False, + "url": server.url, + } + if server.url: + url_parts = urlparse(server.url) + values.update( + { + "hostname": url_parts.hostname, + "netloc": url_parts.netloc, + "port": url_parts.port, + } + ) + return values + + def _parse_system_variable_jet_template(self, jet_template=None): + """Parser system variable of `server` type. + + Args: + jet_template (cx.tower.jet.template()): jet template record + + Returns: + dict(): `jet_template` values of the `tower` variable. + """ + # Get current server + values = {} + if jet_template: + # Using sudo() to get all fields + jet_template = jet_template.sudo() + values = { + "name": jet_template.name, + "reference": jet_template.reference, + } + return values + + def _parse_system_variable_jet(self, jet=None): + """Parser system variable of `jet` type. + + Args: + jet (cx.tower.jet()): jet record + """ + values = {} + if jet: + # Using sudo() to get all fields + jet = jet.sudo() + values = { + "name": jet.name, + "reference": jet.reference, + "url": jet.url, + "state": jet.state, + "cloned_from": jet.jet_cloned_from_id.reference + if jet.jet_cloned_from_id + else False, + } + # Add URL parts if URL is set + if jet.url: + url_parts = urlparse(jet.url) + else: + url_parts = False + values.update( + { + "hostname": url_parts.hostname + if url_parts and url_parts.hostname + else False, + "netloc": url_parts.netloc + if url_parts and url_parts.netloc + else False, + "port": url_parts.port if url_parts and url_parts.port else False, + } + ) + # Add waypoint values if waypoint is set + waypoint_data = { + "reference": jet.waypoint_id.reference if jet.waypoint_id else False, + "type": jet.waypoint_id.waypoint_template_id.reference + if jet.waypoint_id + else False, + } + # Add each metadata key-value pair to the waypoint data + metadata = jet.waypoint_id.metadata if jet.waypoint_id else False + if metadata: + for key, value in metadata.items(): + waypoint_data[key] = value + values.update({"waypoint": waypoint_data}) + return values + + def _parse_system_variable_tools(self): + """Parser system variable of `tools` type. + + Returns: + dict(): `tools` values of the `tower` variable. + """ + today = fields.Date.to_string(fields.Date.today()) + now = fields.Datetime.to_string(fields.Datetime.now()) + values = { + "uuid": uuid.uuid4(), + "today": today, + "now": now, + "today_underscore": re.sub(r"[-: .\/]", "_", today), + "now_underscore": re.sub(r"[-: .\/]", "_", now), + } + return values diff --git a/addons/cetmix_tower_server/models/cx_tower_variable_mixin.py b/addons/cetmix_tower_server/models/cx_tower_variable_mixin.py new file mode 100644 index 0000000..382aae2 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_variable_mixin.py @@ -0,0 +1,82 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class TowerVariableMixin(models.AbstractModel): + """Used to implement variables and variable values. + Inherit in your model if you want to use variables in it. + """ + + _name = "cx.tower.variable.mixin" + _description = "Tower Variables mixin" + + variable_value_ids = fields.One2many( + string="Variable Values", + comodel_name="cx.tower.variable.value", + auto_join=True, + help="Variable values for selected record", + ) + + def get_variable_value(self, variable_reference, no_fallback=False): + """Get the value of a variable. + IMPORTANT: This is the generic method that returns the value of the variable + for the current record. + It doesn't evaluate fallback values,eg "jet->template->server->global". + Inherit and override this method to implement a proper value parsing logic. + + Args: + variable_reference (str): The reference of the variable to get the value for + no_fallback (bool): If True, return current record value + without checking fallback values. + Returns: + str: The value of the variable for the current record or None + """ + self.ensure_one() + + # Get the variable value for the current record + variable_value = self.variable_value_ids.filtered( + lambda v: v.variable_reference == variable_reference + ) + if variable_value: + return variable_value.value_char + + def set_variable_value(self, variable_reference, value): + """Set the value of a variable. + + Args: + variable_reference (str): The reference of the variable to set the value for + value (str): The value to set for the variable + """ + self.ensure_one() + + # Check if the variable value exists and update it + variable_value = self.variable_value_ids.filtered( + lambda v: v.variable_reference == variable_reference + ) + if variable_value: + # Do nothing if the value is the same + if variable_value.value_char == value: + return + variable_value.value_char = value + return + + # Get the variable + variable = self.env["cx.tower.variable"].get_by_reference(variable_reference) + if not variable: + raise ValidationError( + _( + "Variable '%(variable_reference)s' not found", + variable_reference=variable_reference, + ) + ) + + # Create a new variable value + self.write( + { + "variable_value_ids": [ + (0, 0, {"variable_id": variable.id, "value_char": value}) + ] + } + ) diff --git a/addons/cetmix_tower_server/models/cx_tower_variable_option.py b/addons/cetmix_tower_server/models/cx_tower_variable_option.py new file mode 100644 index 0000000..b5b98e0 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_variable_option.py @@ -0,0 +1,117 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class TowerVariableOption(models.Model): + """ + Model to manage variable options in the Cetmix Tower. + + The model allows defining options + that are linked to tower variables and can be used to + manage configurations or settings for those variables. + """ + + _name = "cx.tower.variable.option" + _description = "Cetmix Tower Variable Options" + _inherit = ["cx.tower.reference.mixin", "cx.tower.access.mixin"] + _order = "sequence, name" + + access_level = fields.Selection( + compute="_compute_access_level", + readonly=False, + store=True, + default=None, + ) + name = fields.Char(required=True) + value_char = fields.Char(string="Value", required=True) + variable_id = fields.Many2one( + comodel_name="cx.tower.variable", + required=True, + ondelete="cascade", + ) + sequence = fields.Integer(default=10) + + # Define SQL constraints to ensure uniqueness of + # 'value_char' and 'name' per variable + _sql_constraints = [ + ( + "unique_variable_option", + "unique (value_char, variable_id)", + "The combination of Value and Variable must be unique.", + ), + ( + "unique_variable_option_name", + "unique (name, variable_id)", + "The combination of Name and Variable must be unique.", + ), + ] + + @api.depends("variable_id", "variable_id.access_level") + def _compute_access_level(self): + """ + Automatically set the access_level based on Variable access level + """ + for rec in self: + if rec.variable_id: + rec.access_level = rec.variable_id.access_level + + @api.constrains("access_level", "variable_id") + def _check_access_level_consistency(self): + """ + Ensure that the access level of the variable value is not lower than + the access level of the associated variable. + """ + access_level_dict = dict( + self.fields_get(["access_level"])["access_level"]["selection"] + ) + for rec in self: + if not rec.variable_id: + continue + if not rec.access_level: + raise ValidationError( + _( + "Access level is not defined for '%(option)s'", + option=rec.name, + ) + ) + if rec.access_level < rec.variable_id.access_level: + raise ValidationError( + _( + "The access level for Variable Option '%(value)s' " + "cannot be lower than the access level of its " + "Variable '%(variable)s'.\n" + "Variable Access Level: %(var_level)s\n" + "Variable Option Access Level: %(val_level)s", + value=rec.name, + variable=rec.variable_id.name, + var_level=access_level_dict[rec.variable_id.access_level], + val_level=access_level_dict[rec.access_level], + ) + ) + + # Workaround for the default value not being set + @api.model_create_multi + def create(self, vals_list): + variable_obj = self.env["cx.tower.variable"] + for vals in vals_list: + # Set access level from the variable + # if not provided explicitly + access_level = vals.get("access_level") + if access_level: + continue + variable_id = vals.get("variable_id") + if variable_id: + variable = variable_obj.browse(variable_id) + vals["access_level"] = variable.access_level + return super().create(vals_list) + + def _get_pre_populated_model_data(self): + """ + Define the model relationships for reference generation. + """ + res = super()._get_pre_populated_model_data() + res.update({"cx.tower.variable.option": ["cx.tower.variable", "variable_id"]}) + return res diff --git a/addons/cetmix_tower_server/models/cx_tower_variable_value.py b/addons/cetmix_tower_server/models/cx_tower_variable_value.py new file mode 100644 index 0000000..06709a5 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_variable_value.py @@ -0,0 +1,584 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +# Context keys to remove on record creation. +# This is needed to avoid values being set from context keys +CONTEXT_KEYS_TO_REMOVE = [ + "default_server_id", + "default_jet_template_id", + "default_plan_line_action_id", + "default_jet_id", + "default_server_template_id", +] + + +class TowerVariableValue(models.Model): + """ + This model is used to store variable values. + """ + + _name = "cx.tower.variable.value" + _description = "Cetmix Tower Variable Values" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.access.mixin", + ] + _rec_name = "variable_reference" + _order = "sequence, variable_reference" + + sequence = fields.Integer(default=10) + access_level = fields.Selection( + compute="_compute_access_level", + readonly=False, + store=True, + default=None, + ) + variable_id = fields.Many2one( + string="Variable", + comodel_name="cx.tower.variable", + required=True, + ondelete="cascade", + ) + name = fields.Char(related="variable_id.name", readonly=True) + variable_reference = fields.Char( + string="Variable Reference", + related="variable_id.reference", + store=True, + index=True, + ) + is_global = fields.Boolean( + string="Global", + compute="_compute_is_global", + inverse="_inverse_is_global", + store=True, + ) + note = fields.Text(related="variable_id.note", readonly=True) + active = fields.Boolean(default=True) + variable_type = fields.Selection( + related="variable_id.variable_type", + readonly=True, + ) + option_id = fields.Many2one( + comodel_name="cx.tower.variable.option", + ondelete="restrict", + domain="[('variable_id', '=', variable_id)]", + ) + value_char = fields.Char( + string="Value", + compute="_compute_value_char", + inverse="_inverse_value_char", + store=True, + readonly=False, + ) + + # Direct model relations. + # Following functions should be updated when a new m2o field is added: + # - `_used_in_models()` + # - `_compute_is_global()`: add you field to 'depends' + # Define a `unique` constraint for new model too. + server_id = fields.Many2one( + comodel_name="cx.tower.server", index=True, ondelete="cascade" + ) + plan_line_action_id = fields.Many2one( + comodel_name="cx.tower.plan.line.action", index=True, ondelete="cascade" + ) + server_template_id = fields.Many2one( + comodel_name="cx.tower.server.template", index=True, ondelete="cascade" + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + string="Jet", + ondelete="cascade", + index=True, + ) + + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + string="Jet Template", + ondelete="cascade", + index=True, + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_variable_value_variable_rel", + column1="variable_value_id", + column2="variable_id", + string="Variables", + compute="_compute_variable_ids", + store=True, + copy=False, + ) + required = fields.Boolean() + + _sql_constraints = [ + ( + "tower_variable_value_uniq", + "unique (variable_id, server_id, server_template_id, " + "plan_line_action_id, is_global)", + "Variable can be declared only once for the same record!", + ), + ( + "unique_variable_value_template", + "unique (variable_id, server_template_id)", + ( + "A variable value cannot be assigned multiple" + " times to the same server template!" + ), + ), + ( + "unique_variable_value_action", + "unique (variable_id, plan_line_action_id)", + ( + "A variable value cannot be assigned multiple" + " times to the same plan line action!" + ), + ), + ( + "unique_variable_value_jet_template", + "unique (variable_id, jet_template_id)", + "A variable value cannot be assigned multiple times to " + "the same jet template!", + ), + ( + "unique_variable_value_jet", + "unique (variable_id, jet_id)", + "A variable value cannot be assigned multiple times to the same jet!", + ), + ] + + # -- Compute fields -- + + @api.depends("variable_id", "variable_id.access_level") + def _compute_access_level(self): + """ + Automatically set the access_level based on Variable access level + """ + for rec in self: + if rec.variable_id: + rec.access_level = rec.variable_id.access_level + + @api.depends( + "server_id", + "server_template_id", + "plan_line_action_id", + "jet_id", + "jet_template_id", + ) + def _compute_is_global(self): + """ + If variable considered `global` when it's not linked to any record. + """ + for rec in self: + rec.is_global = rec._check_is_global() + + @api.depends("option_id", "variable_id.option_ids") + def _compute_value_char(self): + """ + Compute the 'value_char' field, which holds the string representation + of the selected option for the variable. + """ + for rec in self: + if not rec.variable_id.option_ids: + rec.value_char = rec.value_char or False + rec.option_id = False + continue + if rec.option_id: + rec.value_char = rec.option_id.value_char + else: + rec.value_char = False + + @api.depends("value_char") + def _compute_variable_ids(self): + """ + Compute variable_ids based on value_char field. + """ + template_mixin_obj = self.env["cx.tower.template.mixin"] + for record in self: + record.variable_ids = template_mixin_obj._prepare_variable_commands( + ["value_char"], force_record=record + ) + + # -- Constraints -- + + @api.constrains("access_level", "variable_id") + def _check_access_level_consistency(self): + """ + Ensure that variable value access level is defined. + Ensure that the access level of the variable value is not lower than + the access level of the associated variable. + """ + access_level_dict = dict( + self.fields_get(["access_level"])["access_level"]["selection"] + ) + for rec in self: + if not rec.variable_id: + continue + if not rec.access_level: + raise ValidationError( + _( + "Access level is not defined for '%(variable)s'", + variable=rec.name, + ) + ) + if rec.access_level < rec.variable_id.access_level: + raise ValidationError( + _( + "The access level for Variable Value '%(value)s' " + "cannot be lower than the access level of its " + "Variable '%(variable)s'.\n" + "Variable Access Level: %(var_level)s\n" + "Variable Value Access Level: %(val_level)s", + value=rec.value_char, + variable=rec.variable_id.name, + var_level=access_level_dict[rec.variable_id.access_level], + val_level=access_level_dict[rec.access_level], + ) + ) + + @api.constrains("is_global", "value_char") + def _constraint_global_unique(self): + """Ensure that there is only one global value exist for the same variable + + Hint to devs: + `unique nulls not distinct (variable_id,server_id,global_id)` + can be used instead in PG 15.0+ + """ + for rec in self: + if rec.is_global: + val_count = self.search_count( + [("variable_id", "=", rec.variable_id.id), ("is_global", "=", True)] + ) + if val_count > 1: + # NB: there is a value check in tests for this message. + # Update `test_variable_value_toggle_global` + # if you modify this message in your code. + raise ValidationError( + _( + "Only one global value can be defined" + " for variable '%(var)s'", + var=rec.variable_id.name, + ) + ) + + @api.constrains("value_char", "option_id") + def _check_value_char_and_option_id(self): + """ + Check if the value_char is valid for the variable. + """ + for rec in self: + if not rec.variable_id: + continue + valid, message = rec.variable_id._validate_value(rec.value_char) + if not valid: + raise ValidationError(message) + if rec.option_id: + if rec.option_id.variable_id != rec.variable_id: + raise ValidationError( + _( + "Option '%(val)s' is not available for variable '%(var)s'", + val=rec.value_char, + var=rec.variable_id.name, + ) + ) + + @api.constrains( + "server_id", + "server_template_id", + "plan_line_action_id", + "jet_id", + "jet_template_id", + ) + def _check_assignment(self): + """Ensure that a variable is only assigned to one model at a time.""" + for record in self: + # Check how many of the fields are set + count_assigned = ( + bool(record.server_id) + + bool(record.server_template_id) + + bool(record.plan_line_action_id) + + bool(record.jet_id) + + bool(record.jet_template_id) + ) + if count_assigned > 1: + raise ValidationError( + _( + "Variable '%(var)s' can only be assigned to one of the models " + "at a time: " + "Server, Jet, Jet Template, Server Template, or " + "Plan Line Action.", + var=record.variable_id.name, + ) + ) + + @api.constrains( + "server_id", "server_template_id", "jet_id", "jet_template_id", "variable_id" + ) + def _check_unique_for_server_no_jet_no_jet_template(self): + """Ensure uniqueness of variable+server when both jet fields are empty""" + # Filter records that have both jet fields empty + records_to_check = self.filtered( + lambda r: not r.jet_id and not r.jet_template_id + ) + + if not records_to_check: + return + + # Use read_group to find duplicates efficiently + domain = [ + ("jet_id", "=", False), + ("jet_template_id", "=", False), + ("variable_id", "in", records_to_check.mapped("variable_id").ids), + ("server_id", "in", records_to_check.mapped("server_id").ids), + ] + + grouped_data = self._read_group( + domain=domain, + groupby=["variable_id", "server_id"], + aggregates=["__count"], + ) + + # Odoo 17+: _read_group returns rows as + # (groupby_1, ..., aggregate_1, ...); many2one groups are recordsets. + for variable_rs, server_rs, row_count in grouped_data: + if row_count > 1: + variable_name = variable_rs.display_name if variable_rs else "Unknown" + server_name = server_rs.display_name if server_rs else "Unknown" + raise ValidationError( + _( + "Multiple records found with Variable '%(variable_name)s'" + " and Server '%(server_name)s' " + "with both Jet and Jet Template empty.", + variable_name=variable_name, + server_name=server_name, + ) + ) + + # -- Onchange -- + + @api.onchange("variable_id") + def _onchange_variable_id(self): + """ + Reset option_id when variable changes or + doesn't have options + """ + for rec in self: + rec.update({"option_id": False, "value_char": False}) + + @api.onchange("value_char") + def _onchange_value_char(self): + """ + Check value before saving + """ + if not (self.variable_id and self.value_char): + return + try: + self.variable_id._validate_value(self.value_char) + except ValidationError as e: + return {"warning": {"title": _("Value is invalid"), "message": str(e)}} + + # -- Inverse -- + + def _inverse_is_global(self): + """Triggered when `is_global` is updated""" + global_values = self.filtered("is_global") + if global_values: + values_to_set = {} + + # Set m2o fields related to variable using models to 'False' + for related_model_info in self._used_in_models().values(): + m2o_field = related_model_info[0] + values_to_set.update({m2o_field: False}) + global_values.write(values_to_set) + + # Check if we are trying to remove 'global' from value + # that doesn't belong to any record. + record_related_values = self - global_values + for record in record_related_values: + if record._check_is_global(): + # NB: there is a value check in tests for this message. + # Update `test_variable_value_toggle_global` if you modify this message. + raise ValidationError( + _( + "Cannot change 'global' status for " + "'%(var)s' with value '%(val)s'." + "\nTry to assigns it to a record instead.", + var=record.variable_id.name, + val=record.value_char, + ) + ) + + def _inverse_value_char(self): + """Set option_id based on value_char""" + for rec in self: + if rec.variable_type == "o" and ( + not rec.option_id or rec.option_id.value_char != rec.value_char + ): + option = rec.variable_id.option_ids.filtered( + lambda x, v=rec.value_char: x.value_char == v + ) + rec.option_id = option and option.id + + # -- Create/write/unlink -- + + @api.model_create_multi + def create(self, vals_list): + """ + Workaround for the default value not being set + """ + # Remove all 'default_' keys from context + # This is needed to avoid values being set from context keys + # Eg 'default_server_id' will set the server_id even if it's + # not provided in vals_list. + # This is a workaround to avoid the issue. + + self = self._self_with_clean_context() + + variable_obj = self.env["cx.tower.variable"] + for vals in vals_list: + # Set access level from the variable + # if not provided explicitly + access_level = vals.get("access_level") + if access_level: + continue + variable_id = vals.get("variable_id") + if variable_id: + variable = variable_obj.browse(variable_id) + vals["access_level"] = variable.access_level + return super().create(vals_list) + + # -- Business logic -- + + def _self_with_clean_context(self): + """ + Clean context to avoid values being set from context keys + + Returns: + self: with context cleaned + """ + context = self.env.context.copy() + for key in CONTEXT_KEYS_TO_REMOVE: + context.pop(key, None) + return self.with_context(context) # pylint: disable=context-overridden + + def _used_in_models(self): + """Returns information about models which use this mixin. + + Returns: + dict(): of the following format: + {"model.name": ("m2o_field_name", "model_description")} + Eg: + {"my.custom.model": ("much_model_id", "Much Model")} + """ + return { + "cx.tower.server": ("server_id", "Server"), + "cx.tower.plan.line.action": ("plan_line_action_id", "Action"), + "cx.tower.server.template": ("server_template_id", "Server Template"), + "cx.tower.jet.template": ("jet_template_id", "Jet Template"), + "cx.tower.jet": ("jet_id", "Jet"), + } + + def _check_is_global(self): + """ + This is a helper function used to define + which variables are considered 'Global' + Override it to implement your custom logic. + + Returns: + bool: True if global else False + """ + + self.ensure_one() + is_global = True + + # Get m2o field values for all models that use variables. + # If none of them is set such value is considered 'global'. + for related_model_info in self._used_in_models().values(): + m2o_field = related_model_info[0] + if self[m2o_field]: + is_global = False + break + return is_global + + def _get_extra_vals_fields(self): + """Check cx.tower.reference.mixin for the function documentation""" + + # Use _used_in_models as a source of truth + return [fld_val[0] for fld_val in self._used_in_models().values()] + + def _pre_populate_references(self, model_name, field_name, vals_list): + """ + Generate model-scoped references for variable values. + + Overrides the mixin method to implement a model-dependent reference pattern. + + Pattern: + ___ + Global: + __global + """ + # Collect parent variable references + parent_record_refs = self._prepare_references(model_name, field_name, vals_list) + model_reference = self._get_model_generic_reference() + + # Prepare mappings for linked models defined in _used_in_models + used_models = self._used_in_models() or {} + # Map m2o field -> model name + m2o_to_model = {info[0]: model for model, info in used_models.items()} + # Precompute linked model generic refs and record refs + linked_generic_by_field = {} + linked_refs_by_field = {} + for model, (m2o_field, _desc) in used_models.items(): + linked_generic_by_field[m2o_field] = self.env[ + model + ]._get_model_generic_reference() + linked_refs_by_field[m2o_field] = self._prepare_references( + model, m2o_field, vals_list + ) + + for vals in vals_list: + # Respect explicitly provided references with at least one valid symbol + existing_reference = vals.get("reference") + if existing_reference and bool( + re.search(self.REFERENCE_PRELIMINARY_PATTERN, existing_reference) + ): + continue + + variable_id = vals.get(field_name) + variable_reference = parent_record_refs.get(variable_id) + if not variable_reference: + # Fallback to generic variable reference if parent reference missing + variable_reference = self.env[model_name]._get_model_generic_reference() + + # Determine which related model the value is linked to + linked_m2o_field = next( + (f for f in m2o_to_model.keys() if vals.get(f)), None + ) + + if linked_m2o_field: + linked_model_generic = linked_generic_by_field.get(linked_m2o_field) + linked_record_id = vals.get(linked_m2o_field) + linked_record_reference = linked_refs_by_field.get( + linked_m2o_field, {} + ).get(linked_record_id) + vals["reference"] = ( + f"{variable_reference}_" + f"{model_reference}_" + f"{linked_model_generic}_" + f"{linked_record_reference}" + ) + else: + # Global value (not linked to any record) + vals["reference"] = f"{variable_reference}_{model_reference}_global" + + return vals_list + + def _get_pre_populated_model_data(self): + """Check cx.tower.reference.mixin for the function documentation""" + res = super()._get_pre_populated_model_data() + res.update({"cx.tower.variable.value": ["cx.tower.variable", "variable_id"]}) + return res diff --git a/addons/cetmix_tower_server/models/cx_tower_vault.py b/addons/cetmix_tower_server/models/cx_tower_vault.py new file mode 100644 index 0000000..5fedf56 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_vault.py @@ -0,0 +1,52 @@ +from odoo import fields, models + +from odoo.addons.rpc_helper.decorator import disable_rpc + + +@disable_rpc() +class CxTowerVault(models.Model): + """Vault for storing secret data. + + This model is used to store secret data for various resources. + + The data is stored in the database and can be accessed using the + `_get_secret_values` method. + + Do not use this model directly, use the `VaultMixin` instead. + """ + + _name = "cx.tower.vault" + _description = "Cetmix Tower Vault" + + res_model = fields.Char( + string="Resource Model", + required=True, + copy=False, + help="Model name of the resource that uses this vault", + ) + res_id = fields.Many2oneReference( + string="Resource ID", + model_field="res_model", + help="ID of the resource that uses this vault", + required=True, + copy=False, + ) + field_name = fields.Char( + required=True, + help="Name of the field that contains the secret value", + copy=False, + ) + data = fields.Text( + string="Secret Data", + required=True, + copy=False, + help="The secret data to be stored in the vault", + ) + + _sql_constraints = [ + ( + "vault_unique_key", + "UNIQUE(res_model, res_id, field_name)", + "Each secret (model, record, field) must be unique in the vault.", + ), + ] diff --git a/addons/cetmix_tower_server/models/cx_tower_vault_mixin.py b/addons/cetmix_tower_server/models/cx_tower_vault_mixin.py new file mode 100644 index 0000000..a26def6 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_vault_mixin.py @@ -0,0 +1,416 @@ +from collections import defaultdict + +from odoo import api, models + + +class CxTowerVaultMixin(models.AbstractModel): + """Mixin for vault functionality. + + This mixin provides methods to securely store and retrieve sensitive data + in the vault. Inheriting models must define SECRET_FIELDS list with field + names that should be stored in the vault. + """ + + _name = "cx.tower.vault.mixin" + _description = "Cetmix Tower Vault Mixin" + + SECRET_VALUE_PLACEHOLDER = "*****" + SECRET_FIELDS = [] + + def _fetch_query(self, query, fields): + """Substitute fields based on api. + + This method replaces values of secret fields with a placeholder value + when they are read from the database. + + Args: + query (str): Query to fetch records + fields (list): List of fields to read + """ + records = super()._fetch_query(query, fields) + + # Replace secret field values with placeholders + for secret_field in self.SECRET_FIELDS: + if not fields or secret_field in [f.name for f in fields]: + # Use cache to set placeholder values without triggering field access + for record in records: + field = self._fields[secret_field] + self.env.cache.set(record, field, self.SECRET_VALUE_PLACEHOLDER) + return records + + @api.model_create_multi + def create(self, vals_list): + """Override create to handle secret values securely. + + Extracts secret fields, stores them in vault, and prevents + actual secret values from being saved in the main table. + + Args: + vals_list (list): List of dictionaries containing field values + for record creation + + Returns: + recordset: Created records with secret values stored in vault + + Note: + Secret fields are automatically processed and stored securely. + The main database table never contains actual secret values. + """ + + # Step 1: Extract secret fields and generate temporary IDs + secret_vals = self._extract_and_replace_secret_fields(vals_list) + + # Step 2: Create records with batch operation + records = super().create(vals_list) + + # Step 3: Update vault records with real IDs + if secret_vals: + self._process_secret_values_after_creation(records, secret_vals) + + return records + + def write(self, vals): + """Override write to handle secret fields. + + Extracts secret field values from vals dictionary and stores them securely + in the vault instead of the main database table. The remaining non-secret + fields are processed by the standard write method. + + Args: + vals (dict): Dictionary of field values to write to records + + Returns: + bool: Result of the parent write operation + + Note: + Secret fields defined in SECRET_FIELDS are automatically intercepted + and stored in vault. Cache is invalidated for all secret fields when + any secret field is modified. + """ + # Extract secret fields + secret_values = {} + for secret_field in self.SECRET_FIELDS: + if secret_field in vals: + secret_values[secret_field] = vals.pop(secret_field) + + res = super().write(vals) + + if secret_values: + self._set_secret_values(secret_values) + # Invalidate cache for all secret fields + self.invalidate_recordset(self.SECRET_FIELDS) + + return res + + def unlink(self): + """Override unlink to delete vault records. + + Automatically removes all associated vault records after deleting + the main records to prevent orphaned secret data in the vault. + + Returns: + bool: Result of the parent unlink operation + + Note: + Vault cleanup is performed automatically and cannot be bypassed. + """ + ids = self.ids + + res = super().unlink() + + # Find all vault records for these records + vault_records = ( + self.env["cx.tower.vault"] + .sudo() + .search([("res_model", "=", self._name), ("res_id", "in", ids)]) + ) + + # Delete vault records + if vault_records: + vault_records.sudo().unlink() + + return res + + def _get_secret_value(self, field_name): + """Retrieves the actual secret value for a specific field for a single record. + + This method is the only way to get the real secret field value because: + - Direct field access (e.g., self.secret_field) + returns placeholder due to _read() override + - The actual field in the main table is empty/NULL + as values are stored in vault + + Args: + field_name (str): Name of the secret field to retrieve + + Returns: + str or None: The actual secret value, or None if not found or field + is not in SECRET_FIELDS + + Note: + This method bypasses Odoo's ORM field access to avoid getting + placeholder values returned by the overridden _read() method. + """ + + self.ensure_one() + + return self._get_secret_values([field_name]).get(self.id, {}).get(field_name) + + def _get_secret_values(self, fields_list=None): + """Retrieve secret values from the vault for specified fields. + + This method fetches secret values stored in the vault for all records + in the current recordset and specified fields (or all SECRET_FIELDS). + + Args: + fields_list (list, optional): List of field names to retrieve. + Defaults to all SECRET_FIELDS. + + Returns: + dict: Dictionary mapping record IDs to their secret field values. + Structure: {res_id: {field_name: secret_value}} + + Example: + {1: {'ssh_password': 'secret123', 'host_key': 'key456'}, + 2: {'ssh_password': 'secret789'}} + + Note: + This method searches vault records using standard domain filtering + by res_id, and field_name for reliable record matching. + If a record has no secret values this record is not included in the result. + """ + # If no records, return empty dict + if not self: + return {} + + # Prepare fields to fetch + fields_to_fetch = ( + [f for f in fields_list if f in self.SECRET_FIELDS] + if fields_list + else self.SECRET_FIELDS + ) + # If no fields to fetch, return empty dict + if not fields_to_fetch: + return {} + + # Search vault records for all records and all secret fields + domain = [ + ("res_model", "=", self._name), + ("res_id", "in", self.ids), + ("field_name", "in", fields_to_fetch), + ] + vault_records = ( + self.env["cx.tower.vault"] + .sudo() + .search_read( + domain, + ["res_id", "field_name", "data"], + ) + ) + res = defaultdict(dict) + for record in vault_records: + res[record["res_id"]][record["field_name"]] = record["data"] + + return dict(res) + + def _set_secret_values(self, vals): + """Store secret values in the vault. + + This method stores sensitive data in the vault for all records in the recordset. + It either updates existing vault records or creates new ones for each + record-field pair in the vals dictionary. + + This method can be overridden to implement custom storage mechanisms + for secret values, such as external key management systems or + encryption services. + + Args: + vals (dict): Dictionary mapping field names to their secret values + to be stored in the vault for all records + + Returns: + None + """ + if not vals or not self: + return + + # Get all existing vault records in ONE SQL query + domain = [ + ("res_model", "=", self._name), + ("res_id", "in", self.ids), + ("field_name", "in", list(vals.keys())), + ] + existing_vault_records = self.env["cx.tower.vault"].sudo().search(domain) + + # Prepare data for batch operations + vals_to_update_records = defaultdict(lambda: self.env["cx.tower.vault"]) + records_to_unlink = self.env["cx.tower.vault"] + records_to_create = [] + + # Index existing records by (res_id, field_name) for O(1) lookups + existing_map = {(v.res_id, v.field_name): v for v in existing_vault_records} + + # Only allow known secret fields to be set + allowed_fields = set(self.SECRET_FIELDS) + + # Process each record and field combination + for record in self: + for field, value in vals.items(): + if field not in allowed_fields: + continue + # Fast lookup for existing record + existing_record = existing_map.get((record.id, field)) + if existing_record: + if value is False or value is None: + records_to_unlink |= existing_record + else: + vals_to_update_records[value] |= existing_record + + else: + if value is False or value is None: + continue + + records_to_create.append( + { + "res_model": self._name, + "res_id": record.id, + "field_name": field, + "data": value, + } + ) + + # Batch operations + for value, records in vals_to_update_records.items(): + records.sudo().write({"data": value}) + + if records_to_create: + self.env["cx.tower.vault"].sudo().create(records_to_create) + if records_to_unlink: + records_to_unlink.sudo().unlink() + + def _extract_and_replace_secret_fields(self, vals_list): + """Extract secret fields and replace with temporary identifiers. + + Processes value dictionaries for record creation, replacing secret field values + with unique temporary identifiers. The actual secret values are mapped to these + temporary identifiers for later secure storage in the vault system. + + Args: + vals_list (list): List of value dictionaries for record creation. + + Returns: + dict: Mapping of temporary identifiers to secret values. + Note: vals_list is modified in-place to contain temp identifiers. + + Note: + Used during record creation as part of the secure secret storage workflow. + """ + temp_id_counter = 0 + secret_vals = {} + + for vals in vals_list: + for secret_field in self.SECRET_FIELDS: + if ( + secret_field in vals + and vals[secret_field] is not False + and vals[secret_field] is not None + ): + temp_id_counter += 1 + temp_identifier = str(temp_id_counter) + secret_vals[temp_identifier] = vals[secret_field] + vals[secret_field] = temp_identifier + + return secret_vals + + def _process_secret_values_after_creation(self, records, secret_vals): + """Process secret values after records creation. + + Replaces temporary identifiers with actual secret values in the vault + and invalidates cache for affected fields. + + Args: + records (recordset): Newly created records with temporary identifiers + secret_vals (dict): Mapping of temporary identifiers to secret values + + Returns: + None + + Note: + Called automatically during create() process. Should not be used directly. + """ + fields_str = ", ".join(self.SECRET_FIELDS) + query = f"SELECT id, {fields_str} FROM {self._table} WHERE id in %s" + self.env.cr.execute(query, (tuple(records.ids),)) + records_dict = self.env.cr.dictfetchall() + + for record_dict in records_dict: + self._process_single_record_secrets(record_dict, secret_vals) + + records._clear_temp_values() + records.invalidate_recordset(self.SECRET_FIELDS) + + def _process_single_record_secrets(self, record_dict, secret_vals): + """Process secrets for a single record. + + Replaces temporary identifiers with actual secret values for one record, + clears temporary values from main table and stores secrets in vault. + + Args: + record_dict (dict): Dictionary with record data + including temporary identifiers + secret_vals (dict): Mapping of temporary identifiers to actual secret values + + Returns: + None + + Note: + Internal method used by _process_secret_values_after_creation. + """ + record_id = record_dict.get("id") + vault_vals = {} + field_temp_id_pairs = ( + (field_name, record_dict[field_name]) for field_name in self.SECRET_FIELDS + ) + + # Collect secret values and fields to clear + for field_name, temp_identifier in field_temp_id_pairs: + secret_value = secret_vals.get(temp_identifier) + if secret_value: + vault_vals[field_name] = secret_value + + # Update database and vault if needed + if vault_vals: + record = self.browse(record_id) + record._set_secret_values(vault_vals) + + def _clear_temp_values(self): + """Clear temporary values from main table. + + Sets all SECRET_FIELDS to NULL in the database to remove temporary + identifiers after secret values have been stored in vault. + Works with multiple records in the recordset. + + Returns: + None + + Note: + Internal method used during secret processing workflow. + Clears all SECRET_FIELDS for all records in the current recordset. + """ + set_clause = ", ".join(f"{field} = NULL" for field in self.SECRET_FIELDS) + query = f"UPDATE {self._table} SET {set_clause} WHERE id in %s" + self.env.cr.execute(query, (tuple(self.ids),)) + + def _is_secret_value_set(self, field_name): + """ + Check if a secret value is set for a specific field for a single record. + This method is preferable to _get_secret_value because it doesn't require + to expose the secret value to the caller. + + Args: + field_name (str): Name of the secret field to check + + Returns: + bool: True if the secret value is set, False otherwise + """ + return self._get_secret_value(field_name) is not None diff --git a/addons/cetmix_tower_server/models/ir_actions_server.py b/addons/cetmix_tower_server/models/ir_actions_server.py new file mode 100644 index 0000000..c3bd1d2 --- /dev/null +++ b/addons/cetmix_tower_server/models/ir_actions_server.py @@ -0,0 +1,24 @@ +from odoo import _, models +from odoo.exceptions import AccessError + + +class IrActionsServer(models.Model): + _inherit = "ir.actions.server" + + def run(self): + """ + We override this method to return more + user friendly error messages. + """ + if self.sudo().model_name == "cx.tower.server": + try: + res = super().run() + return res + except AccessError as e: + raise AccessError( + _( + "You need to have 'write' access to all servers " + "you want to run this action on." + ) + ) from e + return super().run() diff --git a/addons/cetmix_tower_server/models/res_config_settings.py b/addons/cetmix_tower_server/models/res_config_settings.py new file mode 100644 index 0000000..36da516 --- /dev/null +++ b/addons/cetmix_tower_server/models/res_config_settings.py @@ -0,0 +1,79 @@ +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class ResConfigSettings(models.TransientModel): + """ + Inherit res.config.settings to add new settings + """ + + _inherit = "res.config.settings" + + cetmix_tower_command_timeout = fields.Integer( + string="Command Timeout", + config_parameter="cetmix_tower_server.command_timeout", + help="Timeout for commands in seconds after which" + " the command will be terminated", + ) + cetmix_tower_notification_type_error = fields.Selection( + string="Error Notifications", + selection=lambda self: self._selection_notifications_type(), + config_parameter="cetmix_tower_server.notification_type_error", + help="Type of error notifications", + ) + cetmix_tower_notification_type_success = fields.Selection( + string="Success Notifications", + selection=lambda self: self._selection_notifications_type(), + config_parameter="cetmix_tower_server.notification_type_success", + help="Type of success notifications", + ) + + def _selection_notifications_type(self): + """ + Selection of notifications type + """ + return [ + ("sticky", _("Sticky")), + ("non_sticky", _("Non-sticky")), + ] + + def action_configure_cron_pull_files_from_server(self): + """ + Configure cron job to pull files from server + """ + return self._get_cron_job_action( + "cetmix_tower_server.ir_cron_auto_pull_files_from_server" + ) + + def action_configure_zombie_commands_cron(self): + """ + Configure cron job to check zombie commands + """ + return self._get_cron_job_action( + "cetmix_tower_server.ir_cron_check_zombie_commands" + ) + + def action_configure_run_scheduled_tasks_cron(self): + """ + Configure cron job to run scheduled tasks + """ + return self._get_cron_job_action( + "cetmix_tower_server.ir_cron_run_scheduled_tasks" + ) + + def _get_cron_job_action(self, cron_xml_id): + """ + Get action to configure cron job + """ + self.ensure_one() + cron_id = self.env.ref(cron_xml_id).id + if not cron_id: + raise ValidationError(_("Cron job not found")) + return { + "name": _("Cron Job"), + "views": [(False, "form")], + "res_model": "ir.cron", + "res_id": cron_id, + "type": "ir.actions.act_window", + "target": "new", + } diff --git a/addons/cetmix_tower_server/models/res_partner.py b/addons/cetmix_tower_server/models/res_partner.py new file mode 100644 index 0000000..053bba7 --- /dev/null +++ b/addons/cetmix_tower_server/models/res_partner.py @@ -0,0 +1,47 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + server_ids = fields.One2many( + "cx.tower.server", + "partner_id", + string="Servers", + groups="cetmix_tower_server.group_user", + ) + + server_count = fields.Integer( + compute="_compute_server_count", + recursive=True, + ) + + secret_ids = fields.One2many( + "cx.tower.key.value", + "partner_id", + string="Secrets", + domain=[("key_id.key_type", "=", "s")], + groups="cetmix_tower_server.group_manager", + ) + + @api.depends("server_ids", "child_ids.server_count") + def _compute_server_count(self): + for partner in self: + own_server_count = len(partner.server_ids) + child_server_count = sum(partner.child_ids.mapped("server_count")) + partner.server_count = own_server_count + child_server_count + + def action_view_partner_servers(self): + """Open server list filtered by partner and all its descendants.""" + self.ensure_one() + return { + "name": "Servers", + "type": "ir.actions.act_window", + "res_model": "cx.tower.server", + "view_mode": "kanban,list,form", + "domain": [("partner_id", "child_of", self.id)], + "context": {"default_partner_id": self.id}, + } diff --git a/addons/cetmix_tower_server/models/res_users.py b/addons/cetmix_tower_server/models/res_users.py new file mode 100644 index 0000000..5712739 --- /dev/null +++ b/addons/cetmix_tower_server/models/res_users.py @@ -0,0 +1,34 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + USER_ACCESS_LEVEL = "1" + MANAGER_ACCESS_LEVEL = "2" + ROOT_ACCESS_LEVEL = "3" + + cetmix_tower_show_jet_available_states = fields.Boolean( + help="Show available states in the jet view", + ) + + def _cetmix_tower_access_level(self): + """ + Returns the access level of the current logged-in user + Not the record user! + + Returns: + str: The access level of the user. + - "1": User + - "2": Manager + - "3": Root + False: No access + """ + + if self.env.user.has_group("cetmix_tower_server.group_root"): + return self.ROOT_ACCESS_LEVEL + if self.env.user.has_group("cetmix_tower_server.group_manager"): + return self.MANAGER_ACCESS_LEVEL + if self.env.user.has_group("cetmix_tower_server.group_user"): + return self.USER_ACCESS_LEVEL + return False diff --git a/addons/cetmix_tower_server/models/tools.py b/addons/cetmix_tower_server/models/tools.py new file mode 100644 index 0000000..a2475c4 --- /dev/null +++ b/addons/cetmix_tower_server/models/tools.py @@ -0,0 +1,75 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from random import choices +from urllib.parse import urlparse + +CHARS = "23456789acefhjkmnprtvwxyz" + + +def generate_random_id(sections=1, population=4, separator="-"): + """Generates random id + eg 'ahj2-jer83' + + Args: + sections (int, optional): number of sections. Defaults to 1. + population (int, optional): number of symbols per section. Defaults to 4. + separator (str, optional): section separator. Defaults to "-". + + Returns: + Str: generated id + """ + if sections < 1 or population < 0: + return None + + def get_section(): + return "".join(choices(CHARS, k=population)) + + # Single section + if sections == 1: + return get_section() + + # Multiple sections + result = [] + for _ in range(sections): + result.append(get_section()) + + return separator.join(result) + + +def is_valid_url(url: str, no_scheme_check: bool = False) -> bool: + """Check if a URL is valid. + + Args: + url (str): URL to check + no_scheme_check (bool, optional): + If True, the scheme check will be skipped. + Defaults to False. + Returns: + bool: True if URL is valid, False otherwise + """ + if not url: + return False + + # Add dummy scheme if missing so urlparse works + if no_scheme_check: + if "://" not in url: + url = "http://" + url + + parsed = urlparse(url) + + # Must have a domain or IP + if not parsed.netloc: + return False + + # Basic domain validation (at least one dot OR localhost OR IP) + host = parsed.hostname + if not host: + return False + + if host in ("localhost", "::1"): + return True + + if "." in host or ":" in host: + return True + + return False diff --git a/addons/cetmix_tower_server/pyproject.toml b/addons/cetmix_tower_server/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/addons/cetmix_tower_server/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower_server/readme/CONFIGURE.md b/addons/cetmix_tower_server/readme/CONFIGURE.md new file mode 100644 index 0000000..8c717e5 --- /dev/null +++ b/addons/cetmix_tower_server/readme/CONFIGURE.md @@ -0,0 +1 @@ +Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions. diff --git a/addons/cetmix_tower_server/readme/DESCRIPTION.md b/addons/cetmix_tower_server/readme/DESCRIPTION.md new file mode 100644 index 0000000..51ca687 --- /dev/null +++ b/addons/cetmix_tower_server/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +[Cetmix Tower](https://cetmix.com/tower) offers a streamlined solution for managing remote servers and applications via SSH or API calls directly from [Odoo](https://odoo.com). +It is designed for versatility across different operating systems and software environments, providing a practical option for those looking to manage servers without getting tied down by vendor or technology constraints. + +Please refer to the [official documentation](https://cetmix.com/tower) for detailed information. diff --git a/addons/cetmix_tower_server/readme/HISTORY.md b/addons/cetmix_tower_server/readme/HISTORY.md new file mode 100644 index 0000000..e04e277 --- /dev/null +++ b/addons/cetmix_tower_server/readme/HISTORY.md @@ -0,0 +1,49 @@ +## 18.0.2.0.0 (2026-04-07) + +- Features: Jets! (4700) + + +## 18.0.1.0.11 (2026-03-10) + +- Bugfixes: Last flight plan line post-run action was not triggered correctly. (5120) + + +## 18.0.1.0.10 (2026-03-10) + +- Features: Improve the 'File using template' command flow, fix the flight plan line view layout. (5197) + + +## 18.0.1.0.9 (2026-02-19) + +- Features: Blacklist filter for Python commands, value checker for Vault. (5253) + + +## 18.0.1.0.7 (2026-02-05) + +- Features: Scheduled tasks: allow to select specific days of week. (5190) + +- Bugfixes: Ensure custom values can be updated even if not provided initially. (5175) + + +## 18.0.1.0.6 (2026-01-20) + +- Bugfixes: Make pre-defined messages and command help translatable again. (5174) + + +## 18.0.1.0.4 (2025-12-23) + +- Bugfixes: Handle malformed expressions in flight plan line conditions. (5154) + + +## 18.0.1.0.3 (2025-12-17) + +- Features: Parse empty or missing key values as 'None' instead of leaving key reference as is. (5134) +- Features: Improve search views, implement the search panel for selected views. (5139) + +- Bugfixes: Custom values in flight plan are lost in a skipped command and are not available after it. (5129) + + +## 18.0.1.0.2 (2025-12-08) + +- Bugfixes: Make variables selectable in scheduled tasks (5105) +- Bugfixes: Save correct error message in log when SSH connection fails. (5109) diff --git a/addons/cetmix_tower_server/readme/USAGE.md b/addons/cetmix_tower_server/readme/USAGE.md new file mode 100644 index 0000000..901f5a6 --- /dev/null +++ b/addons/cetmix_tower_server/readme/USAGE.md @@ -0,0 +1 @@ +Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions. diff --git a/addons/cetmix_tower_server/readme/diagrams/jets.puml b/addons/cetmix_tower_server/readme/diagrams/jets.puml new file mode 100644 index 0000000..15e0c6e --- /dev/null +++ b/addons/cetmix_tower_server/readme/diagrams/jets.puml @@ -0,0 +1,77 @@ +@startuml new_jet_flow +title New Jet Flow + +start + +:New jet is created; +:Build jet dependencies; +while (For every dependency) + if (Required jet exists?) then (Yes) + if (In the required state?) then (Yes) + :Save jet in the dependency; + else (No) + :Create a jet request to bring\nthis jet to the required state; + endif + else (No) + :Create a jet request for a new jet; + endif +endwhile + +stop +@enduml + + +@startuml jet_request_flow +title Jet Request Flow +|Requesting Jet| +:Need for a jet in a specific state; + +if (Jet available?) then (Yes) + if (In the required state?) then (Yes) + if (Jet is busy?) then (no) + :Ask to go to the required state; + else (Yes) + :Create a jet request to bring\nthis jet to the required state; + |Requested Jet| + :Finalize the current operation; + :Check for pending requests; + if (Pending requests?) then (Yes) + while (Pending requests?) + :Process the request; + :Callback the request issuer; + endwhile + endif + endif + else (No) + :Ask to go to the required state; + endif +else (No) + |Requesting Jet| + :Create a jet request for a new jet; +endif +stop +@enduml + +@startuml jet_state_transition +title Jet State Transition + +start + +:Update dependencies; +if (All dependencies satisfied?) then (Yes) + while (Actions to reach the required state) + :Execute the action; + if (Pending requests?) then (Yes) + while (For every pending request) + :Process the request; + :Callback the request issuer; + endwhile +endif + + endwhile +else (No) + :Wait for dependencies; +endif + +stop +@enduml diff --git a/addons/cetmix_tower_server/readme/newsfragments/.gitkeep b/addons/cetmix_tower_server/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/addons/cetmix_tower_server/security/cetmix_tower_server_groups.xml b/addons/cetmix_tower_server/security/cetmix_tower_server_groups.xml new file mode 100644 index 0000000..015c2f6 --- /dev/null +++ b/addons/cetmix_tower_server/security/cetmix_tower_server_groups.xml @@ -0,0 +1,38 @@ + + + + Cetmix Tower + 199 + + + + + Access Level + + + + User + + + Basic actions for selected servers. + + + + + Manager + + + + Create and modify selected servers. + + + + + Root + + + + Full control over all servers. + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_command_log_security.xml b/addons/cetmix_tower_server/security/cx_tower_command_log_security.xml new file mode 100644 index 0000000..9e8348c --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_command_log_security.xml @@ -0,0 +1,33 @@ + + + + Tower command log: user access rule + + + [ + ("access_level", "=", "1"), + ("create_uid", "=", user.id), + ("server_id.user_ids", "in", [user.id]) + ] + + + + Tower command log: manager access rule + + + [ + "&", + ("access_level", "<=", "2"), + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + Tower command log: root access rule + + [(1, "=", 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_command_security.xml b/addons/cetmix_tower_server/security/cx_tower_command_security.xml new file mode 100644 index 0000000..515c0e2 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_command_security.xml @@ -0,0 +1,84 @@ + + + + + Command: User read + + + + ["&", + ("access_level", "=", "1"), + "|", + ("user_ids", "in", [user.id]), + ("server_ids.user_ids", "in", [user.id]) + ] + + + + + + + + + + Command: Manager read + + + + ["&", + ("access_level", "<=", "2"), + "|", + "|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id]), + "|", + ("server_ids", "=", False), + "|", + ("server_ids.user_ids", "in", [user.id]), + ("server_ids.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Command: Manager write & create + + + + [("access_level", "<=", "2"), ("manager_ids", "in", [user.id])] + + + + + + + + + + Command: Manager unlink + + + + [ + ("access_level", "<=", "2"), + ("create_uid", "=", user.id), + ("manager_ids", "in", [user.id]) + ] + + + + + + + + + + Command: Root unrestricted access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_file_security.xml b/addons/cetmix_tower_server/security/cx_tower_file_security.xml new file mode 100644 index 0000000..6401136 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_file_security.xml @@ -0,0 +1,52 @@ + + + + + File: User read via related server (user_ids) + + + [('server_id.user_ids', 'in', [user.id])] + + + + + + + + + File: Manager write & create via related server (manager_ids) + + + [('server_id.manager_ids', 'in', [user.id])] + + + + + + + + + File: Manager unlink via related server (manager_ids) and record creator + + + + [ ('server_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id) ] + + + + + + + + + + File: Root Unrestricted Access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_file_template_security.xml b/addons/cetmix_tower_server/security/cx_tower_file_template_security.xml new file mode 100644 index 0000000..4b43a07 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_file_template_security.xml @@ -0,0 +1,50 @@ + + + + + File: Manager read (user_ids or manager_ids) + + + + ["|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id])] + + + + + + + + + + File: Manager write & create (manager_ids) + + + [('manager_ids', 'in', [user.id])] + + + + + + + + + File: Manager unlink (manager_ids & creator) + + + + [("manager_ids", "in", [user.id]), ("create_uid", "=", user.id)] + + + + + + + + + + File: Root unrestricted access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_action_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_action_security.xml new file mode 100644 index 0000000..f8dcfff --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_action_security.xml @@ -0,0 +1,73 @@ + + + + + + Jet Action: User Read Access + + + + [ + ("access_level", "=", "1"), + "|", + ("jet_template_id.access_level", "=", "1"), + "|", + ("jet_template_id.user_ids", "in", [user.id]), + ("jet_template_id.jet_ids.user_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Action: Manager Read Access + + + + [ + ("access_level", "<=", "2"), + "|", + ("jet_template_id.access_level", "<=", "2"), + "|", + ("jet_template_id.user_ids", "in", [user.id]), + ("jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Action: Manager Write/Create/Unlink + + + + [ + ("access_level", "<=", "2"), + ("jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Action: Root Full Access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_dependency_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_dependency_security.xml new file mode 100644 index 0000000..f82df90 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_dependency_security.xml @@ -0,0 +1,50 @@ + + + + + + Jet Dependency: Manager Read Access + + + + ["&", + "|", + ("jet_id.user_ids", "in", [user.id]), + ("jet_id.manager_ids", "in", [user.id]), + "|", + ("jet_depends_on_id.user_ids", "in", [user.id]), + ("jet_depends_on_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Jet Dependency: Manager write & create & unlink + + + + [ + ("jet_id.manager_ids", "in", [user.id]), + "|", + ("jet_depends_on_id.user_ids", "in", [user.id]), + ("jet_depends_on_id.manager_ids", "in", [user.id]) + ] + + + + + + + Jet Dependency: Root Full Access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_security.xml new file mode 100644 index 0000000..b6590a3 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_security.xml @@ -0,0 +1,91 @@ + + + + + + Jet: User Read Access + + + + [ + ("user_ids", "in", [user.id]), + ("server_id.user_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet: Manager Read Access + + + + ["&", + "|", + ("user_ids", "in", [user.id]), + ("manager_ids", "in", [user.id]), + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet: Manager write & create + + + + [ + ("manager_ids", "in", [user.id]), + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet: Manager unlink + + + + [ + ("manager_ids", "in", [user.id]), + ("create_uid", "=", user.id), + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Jet: Root Full Access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_template_dependency_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_template_dependency_security.xml new file mode 100644 index 0000000..cdc52fb --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_template_dependency_security.xml @@ -0,0 +1,51 @@ + + + + + + Jet Template Dependency: Manager Read Access + + + + ["|", + ("template_id.access_level", "<=", "2"), + "|", + ("template_id.user_ids", "in", [user.id]), + ("template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Template Dependency: Manager write & create & unlink + + + + [("template_id.access_level", "<=", "2"), ("template_id.manager_ids", "in", [user.id])] + + + + + + + + + + Jet Template Dependency: Root Full Access + + + [(1, '=', 1)] + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_template_install_line_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_template_install_line_security.xml new file mode 100644 index 0000000..c6bde1a --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_template_install_line_security.xml @@ -0,0 +1,32 @@ + + + + + + Jet Template Install Line: Manager Read Access + + + + ["&", + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]), + "|", + ("jet_template_id.access_level", "<=", "2"), + ("jet_template_id.user_ids", "in", [user.id]) + ] + + + + + + + + + + Jet Template Install Line: Root Full Access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_template_install_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_template_install_security.xml new file mode 100644 index 0000000..1cc8b2e --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_template_install_security.xml @@ -0,0 +1,32 @@ + + + + + + Jet Template Install: Manager Read Access + + + + ["&", + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]), + "|", + ("jet_template_id.access_level", "<=", "2"), + ("jet_template_id.user_ids", "in", [user.id]) + ] + + + + + + + + + + Jet Template Install: Root Full Access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_template_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_template_security.xml new file mode 100644 index 0000000..62c2590 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_template_security.xml @@ -0,0 +1,85 @@ + + + + + + Jet Template: User Read Access + + + + ["|","|", + ("access_level", "=", "1"), + ("user_ids", "in", [user.id]), + ("jet_ids.user_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Template: Manager Read Access + + + + ["|", + ("access_level", "<=", "2"), + "|", "|", + ("user_ids", "in", [user.id]), + ("manager_ids", "in", [user.id]), + ("jet_ids.manager_ids", "in", [user.id]) + + ] + + + + + + + + + + + Jet Template: Manager write & create + + + + [("access_level", "<=", "2"), ("manager_ids", "in", [user.id])] + + + + + + + + + + + Jet Template: Manager unlink + + + + [("access_level", "<=", "2"), ("manager_ids", "in", [user.id]), ("create_uid", "=", user.id)] + + + + + + + + + + Jet Template: Root Full Access + + + [(1, '=', 1)] + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_waypoint_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_waypoint_security.xml new file mode 100644 index 0000000..54a21f7 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_waypoint_security.xml @@ -0,0 +1,68 @@ + + + + + + Jet Waypoint: Manager Read Access + + + + [("access_level", "<=", "2"), + "|", + ("jet_id.user_ids", "in", [user.id]), + ("jet_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Waypoint: Manager write & create + + + + [("access_level", "<=", "2"), + ("jet_id.jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Waypoint: Manager unlink + + + + [("access_level", "<=", "2"), + ("jet_id.jet_template_id.manager_ids", "in", [user.id]), + ("create_uid", "=", user.id) + ] + + + + + + + + + + Jet Waypoint: Root Full Access + + + [(1, '=', 1)] + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_jet_waypoint_template_security.xml b/addons/cetmix_tower_server/security/cx_tower_jet_waypoint_template_security.xml new file mode 100644 index 0000000..218e901 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_jet_waypoint_template_security.xml @@ -0,0 +1,68 @@ + + + + + + Jet Waypoint Template: Manager Read Access + + + + [("access_level", "<=", "2"), + "|", + ("jet_template_id.user_ids", "in", [user.id]), + ("jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Waypoint Template: Manager write & create + + + + [("access_level", "<=", "2"), + ("jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + + Jet Waypoint Template: Manager unlink + + + + [("access_level", "<=", "2"), + ("jet_template_id.manager_ids", "in", [user.id]), + ("create_uid", "=", user.id) + ] + + + + + + + + + + Jet Waypoint Template: Root Full Access + + + [(1, '=', 1)] + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_key_security.xml b/addons/cetmix_tower_server/security/cx_tower_key_security.xml new file mode 100644 index 0000000..5f53277 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_key_security.xml @@ -0,0 +1,99 @@ + + + + + Key: Manager Read Access - Users/Managers + + ['|', ('user_ids', 'in', [user.id]), ('manager_ids', 'in', [user.id])] + + + + + + + + + Key: Manager Read Access - Secret Type + + [('key_type', '=', 's')] + + + + + + + + + Key: Manager Read Access - SSH Key + + [('key_type', '=', 'k'), '|', + ('server_ssh_ids.user_ids', 'in', [user.id]), + ('server_ssh_ids.manager_ids', 'in', [user.id])] + + + + + + + + + + Key: Manager Write/Create Access - Managers + + [('manager_ids', 'in', [user.id])] + + + + + + + + + Key: Manager Write/Create Access - SSH Key + + ['&', ('key_type', '=', 'k'), + ('server_ssh_ids.manager_ids', 'in', [user.id])] + + + + + + + + + + Key: Manager Delete Access - Managers + + [('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] + + + + + + + + + Key: Manager Delete Access - SSH Key + + [('key_type', '=', 'k'), + ('server_ssh_ids.manager_ids', 'in', [user.id]), + ('create_uid', '=', user.id)] + + + + + + + + + + Key: Root Full Access + + [(1, '=', 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_key_value_security.xml b/addons/cetmix_tower_server/security/cx_tower_key_value_security.xml new file mode 100644 index 0000000..120309c --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_key_value_security.xml @@ -0,0 +1,92 @@ + + + + + Key Value: Manager Read Access - Key Users/Managers + + ['|', ('key_id.user_ids', 'in', [user.id]), ('key_id.manager_ids', 'in', [user.id])] + + + + + + + + + Key Value: Manager Read Access - Server Users/Managers + + [('key_id.key_type', '=', 's'), + '|', '|', ('server_id', '=', False), + ('server_id.user_ids', 'in', [user.id]), + ('server_id.manager_ids', 'in', [user.id])] + + + + + + + + + + Key Value: Manager Write/Create Access - Key Managers + + [('key_id.manager_ids', 'in', [user.id])] + + + + + + + + + Key Value: Manager Write/Create Access - Server Managers + + [('server_id.manager_ids', 'in', [user.id])] + + + + + + + + + + Key Value: Manager Delete Access - Key Managers + + [('key_id.key_type', '=', 's'),('key_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] + + + + + + + + + Key Value: Manager Delete Access - Server Managers + + [('key_id.key_type', '=', 's'),('server_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] + + + + + + + + + + Key Value: Root Full Access + + [(1, '=', 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_plan_line_action_security.xml b/addons/cetmix_tower_server/security/cx_tower_plan_line_action_security.xml new file mode 100644 index 0000000..3427094 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_plan_line_action_security.xml @@ -0,0 +1,90 @@ + + + + + Plan Line Action: User read + + + + ["&", + ("access_level", "=", "1"), + "|", + ("plan_id.user_ids", "in", [user.id]), + ("plan_id.server_ids.user_ids", "in", [user.id]) + ] + + + + + + + + + + Plan Line Action: Manager read + + + + + ["&", + ("access_level", "<=", "2"), + "|", + "|", ("plan_id.user_ids", "in", [user.id]), ("plan_id.manager_ids", "in", [user.id]), + "|", + ("plan_id.server_ids", "=", False), + "|", + ("plan_id.server_ids.user_ids", "in", [user.id]), + ("plan_id.server_ids.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Plan Line Action: Manager write & create + + + + ["&", ("access_level", "<=", "2"), ("plan_id.manager_ids", "in", [user.id])] + + + + + + + + + + Plan Line Action: Manager unlink + + + + [ + ("access_level", "<=", "2"), + ("create_uid", "=", user.id), + ("plan_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Plan Line Action: Root unrestricted access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_plan_line_security.xml b/addons/cetmix_tower_server/security/cx_tower_plan_line_security.xml new file mode 100644 index 0000000..21b2f87 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_plan_line_security.xml @@ -0,0 +1,90 @@ + + + + + Plan Line: User read + + + + ["&", + ("access_level", "=", "1"), + "|", + ("plan_id.user_ids", "in", [user.id]), + ("plan_id.server_ids.user_ids", "in", [user.id]) + ] + + + + + + + + + + Plan Line: Manager read + + + + + ["&", + ("access_level", "<=", "2"), + "|", + "|", ("plan_id.user_ids", "in", [user.id]), ("plan_id.manager_ids", "in", [user.id]), + "|", + ("plan_id.server_ids", "=", False), + "|", + ("plan_id.server_ids.user_ids", "in", [user.id]), + ("plan_id.server_ids.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Plan Line: Manager write & create + + + + ["&", ("access_level", "<=", "2"), ("plan_id.manager_ids", "in", [user.id])] + + + + + + + + + + Plan Line: Manager unlink + + + + [ + ("access_level", "<=", "2"), + ("create_uid", "=", user.id), + ("plan_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Plan Line: Root unrestricted access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_plan_log_security.xml b/addons/cetmix_tower_server/security/cx_tower_plan_log_security.xml new file mode 100644 index 0000000..2d49e2a --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_plan_log_security.xml @@ -0,0 +1,38 @@ + + + + Tower plan log: user access rule + + + [ + ("access_level", "=", "1"), + ("create_uid", "=", user.id), + ("server_id.user_ids", "in", [user.id]) + ] + + + + + + + + Tower plan log: manager access rule + + + [ + "&", + ("access_level", "<=", "2"), + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + Tower plan log: root access rule + + [(1, "=", 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_plan_security.xml b/addons/cetmix_tower_server/security/cx_tower_plan_security.xml new file mode 100644 index 0000000..4ba61d1 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_plan_security.xml @@ -0,0 +1,90 @@ + + + + + Plan: User read + + + + ["&", + ("access_level", "=", "1"), + "|", + ("user_ids", "in", [user.id]), + ("server_ids.user_ids", "in", [user.id]) + ] + + + + + + + + + + Plan: Manager read + + + + + ["&", + ("access_level", "<=", "2"), + "|", + "|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id]), + "|", + ("server_ids", "=", False), + "|", + ("server_ids.user_ids", "in", [user.id]), + ("server_ids.manager_ids", "in", [user.id]) + ] + + + + + + + + + + Plan: Manager write & create + + + + ["&", ("access_level", "<=", "2"), ("manager_ids", "in", [user.id])] + + + + + + + + + + Plan: Manager unlink + + + + [ + ("access_level", "<=", "2"), + ("create_uid", "=", user.id), + ("manager_ids", "in", [user.id]) + ] + + + + + + + + + + Plan: Root unrestricted access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_scheduled_task_cv_security.xml b/addons/cetmix_tower_server/security/cx_tower_scheduled_task_cv_security.xml new file mode 100644 index 0000000..a5fb0e0 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_scheduled_task_cv_security.xml @@ -0,0 +1,74 @@ + + + + + Scheduled Task CV: manager read access + + + + + + + + ['|', + '|', + ('scheduled_task_id.user_ids', 'in', [user.id]), + ('scheduled_task_id.manager_ids', 'in', [user.id]), + '|', + '|', + '|', + '|', + '|', + '|', + '|', + ('scheduled_task_id.server_ids.user_ids', 'in', [user.id]), + ('scheduled_task_id.server_ids.manager_ids', 'in', [user.id]), + ('scheduled_task_id.server_template_ids.user_ids', 'in', [user.id]), + ('scheduled_task_id.server_template_ids.manager_ids', 'in', [user.id]), + ('scheduled_task_id.jet_ids.user_ids', 'in', [user.id]), + ('scheduled_task_id.jet_ids.manager_ids', 'in', [user.id]), + ('scheduled_task_id.jet_template_ids.user_ids', 'in', [user.id]), + ('scheduled_task_id.jet_template_ids.manager_ids', 'in', [user.id]) + ] + + + + + + Scheduled Task CV: manager write access + + + + + + + + [('scheduled_task_id.manager_ids', 'in', [user.id])] + + + + + + Scheduled Task CV: manager unlink access + + + + + + + + [ + ('scheduled_task_id.manager_ids', 'in', [user.id]), + ('create_uid', '=', user.id) + ] + + + + + + Scheduled Task CV: root full access + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_scheduled_task_security.xml b/addons/cetmix_tower_server/security/cx_tower_scheduled_task_security.xml new file mode 100644 index 0000000..24feeaa --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_scheduled_task_security.xml @@ -0,0 +1,78 @@ + + + + + Scheduled Task: manager read access + + + + + + + + ['|', + '|', + ('user_ids', 'in', [user.id]), + ('manager_ids', 'in', [user.id]), + '|', + '|', + '|', + '|', + '|', + '|', + '|', + ('server_ids.user_ids', 'in', [user.id]), + ('server_ids.manager_ids', 'in', [user.id]), + ('server_template_ids.user_ids', 'in', [user.id]), + ('server_template_ids.manager_ids', 'in', [user.id]), + ('jet_ids.user_ids', 'in', [user.id]), + ('jet_ids.manager_ids', 'in', [user.id]), + ('jet_template_ids.user_ids', 'in', [user.id]), + ('jet_template_ids.manager_ids', 'in', [user.id]) + ] + + + + + + Scheduled Task: manager write access + + + + + + + + [('manager_ids', 'in', [user.id])] + + + + + + Scheduled Task: manager unlink access + + + + + + + + [ + ('manager_ids', 'in', [user.id]), + ('create_uid', '=', user.id) + ] + + + + + + Scheduled Task: root full access + + + + + + + [(1, '=', 1)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_server_log_security.xml b/addons/cetmix_tower_server/security/cx_tower_server_log_security.xml new file mode 100644 index 0000000..9018763 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_server_log_security.xml @@ -0,0 +1,204 @@ + + + + + Tower server log: user access rule + + + [ + ("access_level", "=", "1"), + ("server_id.user_ids", "in", [user.id]) + ] + + + + + Tower server log: manager read access rule + + + [ + ("access_level", "<=", "2"), + "|", + ("server_id.user_ids", "in", [user.id]), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + + + + Tower server log: manager write access rule + + + [ + ("access_level", "<=", "2"), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + + + + Tower server log: manager unlink access rule + + + [ + ("access_level", "<=", "2"), + ("create_uid", "=", user.id), + ("server_id.manager_ids", "in", [user.id]) + ] + + + + + + + + + Tower server log: root access rule + + [(1, "=", 1)] + + + + + + Tower server log: user jet access rule + + + [ + ("access_level", "=", "1"), + ("jet_id.user_ids", "in", [user.id]) + ] + + + + + + + + + Tower server log: manager jet read access rule + + + [ + ("access_level", "<=", "2"), + "|", + ("jet_id.user_ids", "in", [user.id]), + ("jet_id.manager_ids", "in", [user.id]) + ] + + + + + + + + Tower server log: manager jet write access rule + + + [ + ("access_level", "<=", "2"), + ("jet_id.manager_ids", "in", [user.id]) + ] + + + + + + + + Tower server log: manager jet unlink access rule + + + [ + ("access_level", "<=", "2"), + ("jet_id.manager_ids", "in", [user.id]), + ("create_uid", "=", user.id) + ] + + + + + + + + + Tower server log: user jet template access rule + + + [ + ("access_level", "=", "1"), + ("jet_template_id.user_ids", "in", [user.id]) + ] + + + + + + + + + Tower server log: manager jet template read access rule + + + [ + ("access_level", "<=", "2"), + "|", + ("jet_template_id.user_ids", "in", [user.id]), + ("jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + Tower server log: manager jet template write access rule + + + [ + ("access_level", "<=", "2"), + ("jet_template_id.manager_ids", "in", [user.id]) + ] + + + + + + + + Tower server log: manager jet template unlink access rule + + + [ + ("access_level", "<=", "2"), + ("jet_template_id.manager_ids", "in", [user.id]), + ("create_uid", "=", user.id) + ] + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_server_security.xml b/addons/cetmix_tower_server/security/cx_tower_server_security.xml new file mode 100644 index 0000000..f1f1247 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_server_security.xml @@ -0,0 +1,74 @@ + + + + + Tower Server: user visibility rule + + + + [('user_ids', 'in', [user.id])] + + + + + + + + + + Tower Server: Manager Read (if follower or in manager_ids) + + + + + ['|', ('user_ids', 'in', [user.id]), + ('manager_ids', 'in', [user.id])] + + + + + + + + + + Tower Server: Manager Write & Create (if in manager_ids) + + + + [('manager_ids', 'in', [user.id])] + + + + + + + + + Tower Server: Manager Delete (if in manager_ids and creator) + + + + [('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] + + + + + + + + + Tower Server: root visibility rule + + [(1, '=', 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_server_template_security.xml b/addons/cetmix_tower_server/security/cx_tower_server_template_security.xml new file mode 100644 index 0000000..628a104 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_server_template_security.xml @@ -0,0 +1,54 @@ + + + + + Server Template: Manager Read Access + + ['|', ('user_ids', 'in', [user.id]), ('manager_ids', 'in', [user.id])] + + + + + + + + + + Server Template: Manager Write Access + + [('manager_ids', 'in', [user.id])] + + + + + + + + + + Server Template: Manager Delete Access + + [('manager_ids', 'in', [user.id]), ('create_uid','=', user.id)] + + + + + + + + + + Server Template: Root Full Access + + [(1,'=',1)] + + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_server_wizard_access_rules.xml b/addons/cetmix_tower_server/security/cx_tower_server_wizard_access_rules.xml new file mode 100644 index 0000000..bdc5ffd --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_server_wizard_access_rules.xml @@ -0,0 +1,99 @@ + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + + + + Creator only + + + [('create_uid', '=', user.id)] + + diff --git a/addons/cetmix_tower_server/security/cx_tower_shortcut_security.xml b/addons/cetmix_tower_server/security/cx_tower_shortcut_security.xml new file mode 100644 index 0000000..b30603d --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_shortcut_security.xml @@ -0,0 +1,40 @@ + + + + + + Tower Shortcut: user visibility rule + + + [ + ('server_ids.user_ids', 'in', [user.id]), + ('access_level', '=', '1') + ] + + + + Tower shortcut: manager visibility rule + + + [ + ('access_level', '<=', '2'), + '|', '|', '|', + ('server_ids.user_ids', 'in', [user.id]), + ('server_ids.manager_ids', 'in', [user.id]), + ('server_template_ids.user_ids', 'in', [user.id]), + ('server_template_ids.manager_ids', 'in', [user.id]), + ] + + + + + Tower shortcut: root visibility rule + + [(1, '=', 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_tag_security.xml b/addons/cetmix_tower_server/security/cx_tower_tag_security.xml new file mode 100644 index 0000000..d7c4a35 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_tag_security.xml @@ -0,0 +1,32 @@ + + + + + Tower Tag: User can read any record + + [(1, '=', 1)] + + + + + + + + + Tower Tag: Manager can create/edit/delete own records + + [('create_uid', '=', user.id)] + + + + + + + + + Tower Tag: Root has full access + + [(1, '=', 1)] + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_variable_option_security.xml b/addons/cetmix_tower_server/security/cx_tower_variable_option_security.xml new file mode 100644 index 0000000..c961071 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_variable_option_security.xml @@ -0,0 +1,52 @@ + + + + + Variable Option: User Read Access + + [('access_level', '=', '1')] + + + + + + + + + + Variable Option: Manager Read Access + + [('access_level', '<=', '2')] + + + + + + + + + + Variable Option: Manager Write/Create/Unlink Access + + [('access_level', '<=', '2'), ('create_uid', '=', user.id)] + + + + + + + + + + Variable Option: Root Full Access + + [(1, '=', 1)] + + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_variable_security.xml b/addons/cetmix_tower_server/security/cx_tower_variable_security.xml new file mode 100644 index 0000000..caf584a --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_variable_security.xml @@ -0,0 +1,52 @@ + + + + + Variable: User Read Access + + [('access_level', '=', '1')] + + + + + + + + + + Variable: Manager Read Access + + [('access_level', '<=', '2')] + + + + + + + + + + Variable: Manager Write/Create/Unlink Access + + [('access_level', '<=', '2'), ('create_uid', '=', user.id)] + + + + + + + + + + Variable: Root Full Access + + [(1, '=', 1)] + + + + + + + diff --git a/addons/cetmix_tower_server/security/cx_tower_variable_value_security.xml b/addons/cetmix_tower_server/security/cx_tower_variable_value_security.xml new file mode 100644 index 0000000..330f072 --- /dev/null +++ b/addons/cetmix_tower_server/security/cx_tower_variable_value_security.xml @@ -0,0 +1,102 @@ + + + + + Variable Value: User Read Access + + [ + ('access_level', '=', '1'), + '|', '|', '|', '|', '|', + ('is_global', '=', True), + ('server_id.user_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.user_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.server_ids.user_ids', 'in', [user.id]), + ('jet_id.user_ids', 'in', [user.id]), + ('jet_template_id.user_ids', 'in', [user.id]) + ] + + + + + + + + + + Variable Value: Manager Read Access + + [ + ('access_level', '<=', '2'), + '|', '|', '|', '|', '|', '|', '|', '|', '|', '|', '|', '|', + ('is_global', '=', True), + ('server_id.user_ids', 'in', [user.id]), + ('server_id.manager_ids', 'in', [user.id]), + ('server_template_id.user_ids', 'in', [user.id]), + ('server_template_id.manager_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.user_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.manager_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.server_ids.user_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.server_ids.manager_ids', 'in', [user.id]), + ('jet_id.user_ids', 'in', [user.id]), + ('jet_id.manager_ids', 'in', [user.id]), + ('jet_template_id.user_ids', 'in', [user.id]), + ('jet_template_id.manager_ids', 'in', [user.id]) + ] + + + + + + + + + + Variable Value: Manager Write/Create Access + + [ + ('access_level', '<=', '2'), + '|', '|', '|', '|', '|', + ('server_id.manager_ids', 'in', [user.id]), + ('server_template_id.manager_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.manager_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.server_ids.manager_ids', 'in', [user.id]), + ('jet_id.manager_ids', 'in', [user.id]), + ('jet_template_id.manager_ids', 'in', [user.id]) + ] + + + + + + + + + + Variable Value: Manager Unlink Access + + [ + ('access_level', '<=', '2'), + ('create_uid', '=', user.id), + '|', '|', '|', '|', '|', + ('server_id.manager_ids', 'in', [user.id]), + ('server_template_id.manager_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.manager_ids', 'in', [user.id]), + ('plan_line_action_id.plan_id.server_ids.manager_ids', 'in', [user.id]), + ('jet_id.manager_ids', 'in', [user.id]), + ('jet_template_id.manager_ids', 'in', [user.id]) + ] + + + + + + + + + + Variable Value: Root Full Access + + [(1, '=', 1)] + + + diff --git a/addons/cetmix_tower_server/security/ir.model.access.csv b/addons/cetmix_tower_server/security/ir.model.access.csv new file mode 100644 index 0000000..31b20aa --- /dev/null +++ b/addons/cetmix_tower_server/security/ir.model.access.csv @@ -0,0 +1,100 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_variable_user,Variable->User,model_cx_tower_variable,group_user,1,0,0,0 +access_variable_manager,Variable->Manager,model_cx_tower_variable,group_manager,1,1,1,0 +access_variable_root,Variable->Root,model_cx_tower_variable,group_root,1,1,1,1 +access_variable_value_user,Variable Value->User,model_cx_tower_variable_value,group_user,1,0,0,0 +access_variable_value_manager,Variable Value->Manager,model_cx_tower_variable_value,group_manager,1,1,1,1 +access_variable_value_root,Variable Value->Root,model_cx_tower_variable_value,group_root,1,1,1,1 +access_os_user,OS->User,model_cx_tower_os,group_user,1,0,0,0 +access_os_root,OS->Root,model_cx_tower_os,group_root,1,1,1,1 +access_tag_user,Tag->User,model_cx_tower_tag,group_user,1,0,0,0 +access_tag_manager,Tag->Manager,model_cx_tower_tag,group_manager,1,1,1,1 +access_tag_root,Tag->Root,model_cx_tower_tag,group_root,1,1,1,1 +access_server_user,Server->User,model_cx_tower_server,group_user,1,0,0,0 +access_server_manager,Server->Manager,model_cx_tower_server,group_manager,1,1,1,1 +access_server_root,Server->Root,model_cx_tower_server,group_root,1,1,1,1 +access_command_user,Command->User,model_cx_tower_command,group_user,1,0,0,0 +access_command_manager,Command->Manager,model_cx_tower_command,group_manager,1,1,1,1 +access_command_root,Command->Root,model_cx_tower_command,group_root,1,1,1,1 +access_run_command_user,Run Command->User,model_cx_tower_command_run_wizard,group_user,1,1,1,1 +access_run_command_variable_value_user,Run Command Variable Value->User,model_cx_tower_command_run_wizard_variable_value,group_user,1,1,1,1 +access_execute_plan_user,Run Plan->User,model_cx_tower_plan_run_wizard,group_user,1,1,1,1 +access_execute_plan_variable_value_user,Run Plan Variable Value->User,model_cx_tower_plan_run_wizard_variable_value,group_user,1,1,1,1 +access_key_user,Key->User,model_cx_tower_key,group_user,0,0,0,0 +access_key_manager,Key->Manager,model_cx_tower_key,group_manager,1,1,1,1 +access_key_root,Key->Root,model_cx_tower_key,group_root,1,1,1,1 +access_key_value_manager,Key Value->Manager,model_cx_tower_key_value,group_manager,1,1,1,1 +access_key_value_root,Key Value->Root,model_cx_tower_key_value,group_root,1,1,1,1 +access_command_log_user,Command Log->User,model_cx_tower_command_log,group_user,1,0,0,0 +access_command_log_manager,Command Log->Manager,model_cx_tower_command_log,group_manager,1,0,0,0 +access_command_log_root,Command Log->Root,model_cx_tower_command_log,group_root,1,0,0,0 +access_plan_user,Plan->User,model_cx_tower_plan,group_user,1,0,0,0 +access_plan_manager,Plan->Manager,model_cx_tower_plan,group_manager,1,1,1,1 +access_plan_root,Plan->Root,model_cx_tower_plan,group_root,1,1,1,1 +access_plan_line_user,Plan Line->User,model_cx_tower_plan_line,group_user,1,0,0,0 +access_plan_line_manager,Plan Line->Manager,model_cx_tower_plan_line,group_manager,1,1,1,1 +access_plan_line_root,Plan Line->Root,model_cx_tower_plan_line,group_root,1,1,1,1 +access_plan_line_action_user,Plan Line Action->User,model_cx_tower_plan_line_action,group_user,1,0,0,0 +access_plan_line_action_manager,Plan Line Action->Manager,model_cx_tower_plan_line_action,group_manager,1,1,1,1 +access_plan_line_action_root,Plan Line Action->Root,model_cx_tower_plan_line_action,group_root,1,1,1,1 +access_plan_log_user,Plan Log->User,model_cx_tower_plan_log,group_user,1,0,0,0 +access_plan_log_manager,Plan Log->Manager,model_cx_tower_plan_log,group_manager,1,0,0,0 +access_plan_log_root,Plan Log->Root,model_cx_tower_plan_log,group_root,1,0,0,0 +access_file_user,File->User,model_cx_tower_file,group_user,1,0,0,0 +access_file_manager,File->Manager,model_cx_tower_file,group_manager,1,1,1,1 +access_file_root,File->Root,model_cx_tower_file,group_root,1,1,1,1 +access_file_template_manager,File Template->Manager,model_cx_tower_file_template,group_manager,1,1,1,1 +access_file_template_root,File Template->Root,model_cx_tower_file_template,group_root,1,1,1,1 +access_server_log_user,Server Log->User,model_cx_tower_server_log,group_user,1,0,0,0 +access_server_log_manager,Server Log->Manager,model_cx_tower_server_log,group_manager,1,1,1,1 +access_server_log_root,Server Log->Root,model_cx_tower_server_log,group_root,1,1,1,1 +access_server_template_manager,Server Template->Manager,model_cx_tower_server_template,group_manager,1,1,1,1 +access_server_template_root,Server Template->Root,model_cx_tower_server_template,group_root,1,1,1,1 +access_create_server_from_template_manager,Create Server From Template->Manager,model_cx_tower_server_template_create_wizard,group_manager,1,1,1,1 +access_create_server_from_template_line_manager,Create Server From Template Line->Manager,model_cx_tower_server_template_create_wizard_line,group_manager,1,1,1,1 +access_cx_tower_variable_option_user,Variable Option->User,model_cx_tower_variable_option,group_user,1,0,0,0 +access_cx_tower_variable_option_manager,Variable Option->Manager,model_cx_tower_variable_option,group_manager,1,1,1,1 +access_cx_tower_variable_option_root,Variable Option->Root,model_cx_tower_variable_option,group_root,1,1,1,1 +access_cx_tower_vault_no_access,cx.tower.vault no access,model_cx_tower_vault,group_user,0,0,0,0 +access_cx_tower_server_host_key_wizard_manager,Show Host Key->Manager,model_cx_tower_server_host_key_wizard,group_manager,1,1,1,1 +access_cx_tower_server_host_key_wizard_root,Show Host Key->Root,model_cx_tower_server_host_key_wizard,group_root,1,1,1,1 +access_cetmix_tower_user,Cetmix Tower->User,model_cetmix_tower,group_user,1,1,0,0 +access_shortcut_user,Shortcut->User,model_cx_tower_shortcut,group_user,1,0,0,0 +access_shortcut_manager,Shortcut->Manager,model_cx_tower_shortcut,group_manager,1,0,0,0 +access_shortcut_root,Shortcut->Root,model_cx_tower_shortcut,group_root,1,1,1,1 +access_scheduled_task_user,Scheduled Task->User,model_cx_tower_scheduled_task,group_user,0,0,0,0 +access_scheduled_task_manager,Scheduled Task->Manager,model_cx_tower_scheduled_task,group_manager,1,1,1,1 +access_scheduled_task_root,Scheduled Task->Root,model_cx_tower_scheduled_task,group_root,1,1,1,1 +access_scheduled_task_cv_user,Scheduled Task Custom Variable Value->User,model_cx_tower_scheduled_task_cv,group_user,0,0,0,0 +access_scheduled_task_cv_manager,Scheduled Task Custom Variable Value->Manager,model_cx_tower_scheduled_task_cv,group_manager,1,1,1,1 +access_scheduled_task_cv_root,Scheduled Task Custom Variable Value->Root,model_cx_tower_scheduled_task_cv,group_root,1,1,1,1 +access_cx_tower_jet_state_user,cx.tower.jet.state user,model_cx_tower_jet_state,group_user,1,0,0,0 +access_cx_tower_jet_state_manager,cx.tower.jet.state manager,model_cx_tower_jet_state,group_manager,1,0,0,0 +access_cx_tower_jet_state_root,cx.tower.jet.state root,model_cx_tower_jet_state,group_root,1,1,1,1 +access_cx_tower_jet_action_user,cx.tower.jet.action user,model_cx_tower_jet_action,group_user,1,0,0,0 +access_cx_tower_jet_action_manager,cx.tower.jet.action manager,model_cx_tower_jet_action,group_manager,1,1,1,1 +access_cx_tower_jet_action_root,cx.tower.jet.action root,model_cx_tower_jet_action,group_root,1,1,1,1 +access_cx_tower_jet_template_user,cx.tower.jet.template user,model_cx_tower_jet_template,group_user,1,0,0,0 +access_cx_tower_jet_template_manager,cx.tower.jet.template manager,model_cx_tower_jet_template,group_manager,1,1,1,1 +access_cx_tower_jet_template_dependency_manager,cx.tower.jet.template.dependency manager,model_cx_tower_jet_template_dependency,group_manager,1,1,1,1 +access_cx_tower_jet_template_install_manager,Jet Template Install->Manager,model_cx_tower_jet_template_install,group_manager,1,0,0,0 +access_cx_tower_jet_template_install_root,Jet Template Install->Root,model_cx_tower_jet_template_install,group_root,1,1,1,1 +access_cx_tower_jet_template_install_line_manager,Jet Template Install Line->Manager,model_cx_tower_jet_template_install_line,group_manager,1,0,0,0 +access_cx_tower_jet_template_install_line_root,Jet Template Install Line->Root,model_cx_tower_jet_template_install_line,group_root,1,1,1,1 +access_cx_tower_jet_template_install_wiz_manager,Jet Template Install Wizard->Manager,model_cx_tower_jet_template_install_wiz,group_manager,1,1,1,1 +access_cx_tower_jet_user,cx.tower.jet user,model_cx_tower_jet,group_user,1,0,0,0 +access_cx_tower_jet_manager,cx.tower.jet manager,model_cx_tower_jet,group_manager,1,1,1,1 +access_cx_tower_jet_root,cx.tower.jet root,model_cx_tower_jet,group_root,1,1,1,1 +access_cx_tower_jet_request_root,Jet Request->Root,model_cx_tower_jet_request,group_root,1,1,1,1 +access_cx_tower_jet_dependency_manager,cx.tower.jet.dependency manager,model_cx_tower_jet_dependency,group_manager,1,1,1,1 +access_cx_tower_jet_dependency_root,cx.tower.jet.dependency root,model_cx_tower_jet_dependency,group_root,1,1,1,1 +access_cx_tower_jet_state_wizard_user,Jet State Wizard->User,model_cx_tower_jet_state_wizard,group_user,1,1,1,1 +access_cx_tower_jet_action_wizard_user,Jet Action Wizard->User,model_cx_tower_jet_action_wizard,group_user,1,1,1,1 +access_cx_tower_jet_create_wizard_user,Jet Create Wizard->User,model_cx_tower_jet_create_wizard,group_user,1,1,1,1 +access_cx_tower_jet_create_wizard_variable_line_user,Jet Create Wizard Variable Line->User,model_cx_tower_jet_create_wizard_variable_line,group_user,1,1,1,1 +access_cx_tower_jet_clone_wizard_user,Jet Clone Wizard->User,model_cx_tower_jet_clone_wizard,group_user,1,1,1,1 +access_cx_tower_jet_clone_wizard_variable_line_user,Jet Clone Wizard Variable Line->User,model_cx_tower_jet_clone_wizard_variable_line,group_user,1,1,1,1 +access_cx_tower_jet_waypoint_template_manager,Jet Waypoint Template->Manager,model_cx_tower_jet_waypoint_template,group_manager,1,1,1,1 +access_cx_tower_jet_waypoint_template_root,Jet Waypoint Template->Root,model_cx_tower_jet_waypoint_template,group_root,1,1,1,1 +access_cx_tower_jet_waypoint_manager,Jet Waypoint->Manager,model_cx_tower_jet_waypoint,group_manager,1,1,1,1 +access_cx_tower_jet_waypoint_root,Jet Waypoint->Root,model_cx_tower_jet_waypoint,group_root,1,1,1,1 diff --git a/addons/cetmix_tower_server/ssh/__init__.py b/addons/cetmix_tower_server/ssh/__init__.py new file mode 100644 index 0000000..c601950 --- /dev/null +++ b/addons/cetmix_tower_server/ssh/__init__.py @@ -0,0 +1 @@ +from . import ssh diff --git a/addons/cetmix_tower_server/ssh/ssh.py b/addons/cetmix_tower_server/ssh/ssh.py new file mode 100644 index 0000000..3478f87 --- /dev/null +++ b/addons/cetmix_tower_server/ssh/ssh.py @@ -0,0 +1,379 @@ +import io +import logging +import time + +_logger = logging.getLogger(__name__) + + +try: + from paramiko import ( + AutoAddPolicy, + DSSKey, + ECDSAKey, + Ed25519Key, + MissingHostKeyPolicy, + RSAKey, + SFTPClient, + SSHClient, + SSHException, + ) +except ImportError: + _logger.error( + "Looks like 'paramiko' is not installed, please try to " + "install it using 'pip install paramiko'" + ) + AutoAddPolicy = MissingHostKeyPolicy = RSAKey = SSHClient = None + + +class KeyLoader: + """ + Utility for loading private SSH key in supported formats. + """ + + @staticmethod + def load_private_key(ssh_key: str) -> RSAKey | DSSKey | ECDSAKey | Ed25519Key: + """ + Load a private SSH key from a string. + """ + key_file = io.StringIO(ssh_key) + for key_class in (RSAKey, DSSKey, ECDSAKey, Ed25519Key): + try: + key_file.seek(0) + return key_class.from_private_key(key_file) + except SSHException: + _logger.warning( + f"KeyLoader: failed to load key through {key_class.__name__}." + ) + _logger.error( + "KeyLoader: unable to load private key. " + "Unsupported format or invalid SSH key." + ) + raise ValueError("Unsupported format or invalid SSH key.") + + +class SSHConnection: + """ + Class for managing SSH connection. + """ + + def __init__( + self, + host: str, + port: int, + username: str, + password: str | None = None, + ssh_key: str | None = None, + host_key: str | None = None, + mode: str = "p", # "p" for password, "k" for key + allow_agent: bool = False, + timeout: int = 5000, + ): + """ + Initialize the SSHConnection instance. + """ + self.host = host + self.port = port + self.username = username + self.password = password + self.ssh_key = ssh_key + self.host_key = host_key + self.mode = mode + self.allow_agent = allow_agent + self.timeout = timeout + self._ssh_client: SSHClient | None = None + + def connect(self) -> SSHClient: + """ + Connect to the SSH server. + """ + if self._ssh_client is not None: + return self._ssh_client + + self._ssh_client = SSHClient() + self._ssh_client.load_system_host_keys() + + if self.host_key: + self._ssh_client.set_missing_host_key_policy( + CustomHostKeyPolicy(self.host_key) + ) + else: + self._ssh_client.set_missing_host_key_policy(AutoAddPolicy()) + + connect_params = { + "hostname": self.host, + "port": self.port, + "username": self.username, + "allow_agent": self.allow_agent, + "timeout": self.timeout, + } + + if self.mode == "p": + if not self.password: + raise ValueError("For password mode, you need to pass a password.") + connect_params["password"] = self.password + elif self.mode == "k": + if not self.ssh_key: + raise ValueError("For key mode, you need to pass an SSH key.") + connect_params["pkey"] = KeyLoader.load_private_key(self.ssh_key) + else: + raise ValueError(f"Unsupported connection mode: {self.mode}") + + self._ssh_client.connect(**connect_params) + return self._ssh_client + + def disconnect(self) -> None: + """ + Disconnect the SSH connection. + """ + if self._ssh_client: + _logger.info("SSHConnection: closing SSH connection.") + self._ssh_client.close() + self._ssh_client = None + + def get_transport(self): + """ + Get the SSH transport. + """ + if self._ssh_client is None: + self.connect() + return self._ssh_client.get_transport() + + +class CustomHostKeyPolicy(MissingHostKeyPolicy): + """ + Custom SSH host key policy for validating the server's host key. + + This policy compares the server's host key (in Base64 format) with the expected key. + If they do not match, an SSHException is raised to prevent connecting + to an untrusted server. If they match, the key is added to the client's host keys. + """ + + def __init__(self, expected_host_key: str): + """ + Initialize the policy with the expected host key. + + Args: + expected_host_key (str): The expected host key in Base64 format. + """ + self.expected_host_key = expected_host_key + + def missing_host_key(self, client, hostname, key): + """ + Called when the SSH client receives a host key from the server + that is not in its known hosts. + + Args: + client: The SSH client instance. + hostname: The hostname of the server. + key: The host key received from the server. + + Raises: + SSHException: If the received host key does not match the expected host key. + """ + received_key = key.get_base64() + if received_key != self.expected_host_key: + raise SSHException(f"Host key mismatch for {hostname}. ") + # If the key matches, add it to the client's known hosts + client._host_keys.add(hostname, key.get_name(), key) + + +class SftpService: + """ + Service for working with SFTP, using SSH connection. + """ + + def __init__(self, connection: SSHConnection): + """ + Initialize the SftpService instance. + """ + self.connection = connection + self._sftp_client: SFTPClient | None = None + + def get_client(self) -> SFTPClient: + """ + Get the SFTP client. + """ + if self._sftp_client is None: + transport = self.connection.get_transport() + self._sftp_client = SFTPClient.from_transport(transport) + return self._sftp_client + + def upload_file(self, file: str | io.BytesIO, remote_path: str) -> None: + """ + Upload a file to the remote server. + """ + client = self.get_client() + if isinstance(file, io.BytesIO): + client.putfo(file, remote_path) + elif isinstance(file, str): + client.put(file, remote_path) + else: + raise TypeError(f"File type {type(file).__name__} is not supported.") + + def download_file(self, remote_path: str) -> bytes: + """ + Download a file from the remote server. + """ + client = self.get_client() + with client.open(remote_path, "rb") as remote_file: + return remote_file.read() + + def delete_file(self, remote_path: str) -> None: + """ + Delete a file from the remote server. + """ + client = self.get_client() + client.remove(remote_path) + + def disconnect(self) -> None: + """ + Disconnect the SFTP client. + """ + if self._sftp_client: + _logger.info("SftpService: closing SFTP connection.") + self._sftp_client.close() + self._sftp_client = None + + +class CommandExecutor: + """ + Class for executing commands on a remote server. + """ + + def __init__(self, connection: SSHConnection): + """ + Initialize the CommandExecutor instance. + """ + self.connection = connection + + def exec_command( + self, command: str, sudo: str | None = None + ) -> tuple[int, list[str], list[str]]: + """ + Run a command on the remote server. + + Args: + command (str): The command to execute. + sudo (Optional[str]): Sudo mode. + + Returns: + tuple: + - exit_status (int) + - stdout (list[str]) + - stderr (list[str]) + """ + ssh_client = self.connection.connect() + use_sudo_with_password = sudo == "p" and self.connection.username != "root" + + if use_sudo_with_password and not self.connection.password: + return 255, [], ["Sudo password not provided!"] + + try: + stdin, stdout, stderr = ssh_client.exec_command(command) + if use_sudo_with_password: + stdin.write(self.connection.password + "\n") + stdin.flush() + exit_status = stdout.channel.recv_exit_status() + response = stdout.readlines() + error = stderr.readlines() + return exit_status, response, error + except Exception as e: + return 255, [], [str(e)] + + +class SSHManager: + """ + Facade for working with SSH connection, SFTP and command execution. + """ + + _connection_cache = {} + + def __new__(cls, connection: SSHConnection): + """ + Create a new SSHManager instance. + """ + key = ( + connection.host, + connection.port, + connection.username, + connection.mode, + connection.allow_agent, + connection.password or "", + connection.ssh_key or "", + connection.host_key or "", + ) + if key in cls._connection_cache: + instance, created_at, cached_timeout = cls._connection_cache[key] + # if timeout is changed, update the cached timeout + if connection.timeout != cached_timeout: + cls.delete_cache(key) + else: + _logger.info( + "Using cached SSH connection for " + "host=%s, port=%s, user=%s, mode=%s", + connection.host, + connection.port, + connection.username, + connection.mode, + ) + return instance + + _logger.info( + "Creating new SSH connection for host=%s, port=%s, user=%s, mode=%s", + connection.host, + connection.port, + connection.username, + connection.mode, + ) + instance = super().__new__(cls) + cls._connection_cache[key] = (instance, time.time(), connection.timeout) + return instance + + def __init__(self, connection: SSHConnection): + """ + Initialize the SSHManager instance. + """ + # initialize only once + if hasattr(self, "_initialized") and self._initialized: + return + self.connection = connection + self.command_executor = CommandExecutor(connection) + self.sftp_service = SftpService(connection) + self._initialized = True + + @classmethod + def delete_cache(cls, key): + """ + Delete the cache of SSH connections. + """ + if key in SSHManager._connection_cache: + del SSHManager._connection_cache[key] + + def disconnect(self) -> None: + """ + Disconnect the SSH connection and SFTP client. + """ + if self.sftp_service._sftp_client is not None: + self.sftp_service.disconnect() + + if self.connection._ssh_client is not None: + self.connection.disconnect() + + key = ( + self.connection.host, + self.connection.port, + self.connection.username, + self.connection.mode, + self.connection.allow_agent, + self.connection.password or "", + self.connection.ssh_key or "", + self.connection.host_key or "", + ) + self.delete_cache(key) + + @classmethod + def get_connection_cache(cls): + """ + Get the connection cache. + """ + return cls._connection_cache diff --git a/addons/cetmix_tower_server/static/demo/img/backup.png b/addons/cetmix_tower_server/static/demo/img/backup.png new file mode 100644 index 0000000000000000000000000000000000000000..c67452e3b83d7bac001af4a2b74f04bb9d3debe3 GIT binary patch literal 20360 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 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/mariadb.png b/addons/cetmix_tower_server/static/demo/img/mariadb.png new file mode 100644 index 0000000000000000000000000000000000000000..1d9db212309fa8730a37f75b936b7bf29528f6fd GIT binary patch literal 7903 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 JPDHLkV1oOF(@+2a literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/monitoring.png b/addons/cetmix_tower_server/static/demo/img/monitoring.png new file mode 100644 index 0000000000000000000000000000000000000000..27e846acba6c7ee0b436f8b0756eec30a231c271 GIT binary patch literal 4770 zcmV;T5?$?yP))oKEqobpvqobn@fxtB2XP^WS^^ZqBCD-dOX zM8qvai5az=pfH!WJUUh=D>}t5n>E?@Pe312ftChv8E8~&X0M}` z(!yM}&uwceDh9$#1zL6i4NMc&m7jNX8{6izX4{-LGHX;T*HKS!cpGz3sJTE(1F%Z^ z=4UA@I!#&8DbhDjc3Q6E8vH%2z>P+wv78h8SCVt+Qwq;yARrJHaR+T<#?Z0XWDI_8 zo02QS(JKfFYfW%?8)8PUa9ZvQlog$3*9Sex_-Yy#bM{kZyh^39oQt_>WPCM^ zwM|7!QX@K)XEKlNt}{()}M>x&r{ zRV)*@yalK$Wc|F{VdVo9p2;99ZKWGDQieVT(1??V*2&U+?j6F6x0Yfs2qey$LTYkS z&9#gzFOae21y%W*x2Wd-Y-d|sy#>&S;{4xa>FNJoLbDdFXwoc#S7$HwkXDY-4L~#G z|42VS-}q^LLrZT*6bWh6P9{p6HHCunc@&(_C-IHvJ*1dp^jSbHxmI4pFJDaLRObIE zekS_CrF823oC~%33rDj6eGpg+E@Y>Yw*Co93v(4e)2MZKI`o*}Lao%H8-NptVim`B z&E?4VZz5J16+hRgbu4|xZ*kiifD&{AP)T`l9%&oKa_)Gt#kIz5d(q?3wVXSiOzxre z6rcSaQLG}gMHKD2kE4BTf>%{O=mwyQbH|fO+c1{$;yn2UL7;Qr7r0~S0t|iuG;G$z zVIQA&bQ|fLC#zl>4hm~c_t7h8+4TXn>RUyh1t>tQGIHem*`%x-WKsEnAD z_f$E0s=c0S(b`L@w*c1>tBjmT`-q%$$%|+342&^!io|uWT$c=JJsTT!QfBSegC3s z|JM-&-)=#X^HvoCR zk9{v*asKb_RLw2a`7XeG>gKE$&)Ipsr>VOwfGBc2^?k0D7s%5r6woIcJG+duKQS>*Q%C!#e;vddtHeAT5(>kq)r%sBM)BBCqE;o zVFX!eE6F{yo}#==Kp?c`ZM2ISLx-M|F!98G?kqdVV16su&{0z;b7^j6l+IbH?YXFtTaO+auHRpt2V%AQ)>ZUBzX zv{~85ZWnuJ@IqTe(P#V?5Y!)k@YR()wfKBzXwQ3BdMU@L%CAfKTo+)DyUyT=snK?? z3-QXkC2zv#TL63ByReAcJ*1WIpk)gn06df^+C3EL=**y_>&N#kmYuD)=e=ta{e<1T zbbPP{=n(e|;jOwkye~X5nvOjuxj`e}#^(&c&%Zu>Cu}1u!gf^J@W^Q56SjF>L@sZ{ zCkKH+p{*D&?EqQ(my?tJDMfjQG5814uvypZ-n)r<2j)&L#><^r9xQp8wsg^RgqT+B`5#DUes zKfRrx&{kSyUuRbK)S`9_5Cm)hK1ajKH>W7?FsZACP-VQTS)O%eWlt??3?K;D0X*s= zU1SvH9VTnPZ$%3AMbvZwtAV>@4;uvTj|ySn%^`GZ8bG5EKc|BZT_~w!;jRMq951br z~l^5YY>*Ulhclc#nIo>Xe_a^wRJJ!fO8(BrO%wlnu0m19)|&o?!9lE^ z{c=s^>v$d|2C&yO@#W#miZZs=^e=e%Y1&0JSFD_l>rr9=vN-4&r^-2ey4>MItA8q# zr3dzmX3{8a!kg-7hY|z$8M9Vk;jW8RR@q-GFSt@EOMf)$MZM|wLX-~aU&G{u5PzPp zV6(!mpc=jD_fFLOB|ipgg5-n*7+9YWNs|yit2f6l%$B9a^zGIaFq;L~+7;~VPNTYl zir>xY_XbV}YC85S6;*tga>>r^I=+A!12_xJHWlnRQbNWl#{)tg??tUkZ!vJVIw2wr z2#pQ~ggV}f+D)OAz!V^b>wQVqg$nLnbW&@*ET}7QA#*p*IQ91epQwH4^Y8Xp$g0d+ z4t<*FcAVYS@tWEP#>`w`VFbS(KOsv81T}I}9UW^}35Hp0E^zi-K)%`sjFpv)nYn;7 z=L<*}K8TZfXGxg-AK5*P+r&Akj*dB{g}J0}vN#)QvB|PFZLX~Swl>fIVrMFk|8qXZ z%1VoC*0}4L9kl2&NS=p2|JRZ!z*19zOLuiXG%%iZbEf0(XMM5Al*rpgSyaA`rlbQO znG0Ro0#uWflE#>s3n(hNV(XT#kPe(!I|`%^|7%Y=@HgO$`31M^>mGJj97i@SW5NiF ze|SF9Il~9BUimuCa~ar&)a>`hO6Avv-bnvUEj|~4BS>!Nmx0b&DypL$0x%wFB&k*9 z+xqMVM!4r|=_sKKQgh3`{~%>KBQ*a-IZVcxdM!H`M0d2IGP3Y1HJ`h zd$a@mVPtO@(NiF%Gqh<6A@!Y8;NoRCaTb2agwJ-1r2lF4HW7Foc*nU?Ji>5b8Q`g~ z7zAM&L*M=tGgW`u2qNa;`G;xZ0NYhzwV4b5IY_$Nt z0ySr|NA-bCuX|=qJ}C`GzAaKIKU#PU=!JA@$=i4mlOMib{rbGV_aF_LG<9jA>037O z*-KMQMP*3mI?vgA&RPp_7bcY-;4k(0o?=)W%(pDREASaoi{MQNNP8!1h<)fWmsh?h zitJu)F*I6(gUSc%1|VCn9oZX~4fvGu%7ZYjzlHb#V4nN6vCI8HO$bwipC9*3eAeZK z{$I+EoH;7<^i~4zDtXqL0o-kx7}3)^+_B523iPsE5}FQ-albY;k^TE`8$5(2?b^G% z&>xpAF&AwHtWT)Tux0@5O%px5WVls70KS|l(Au&!U5ON_)YHTQvUcj8iO;#b&{LU* zIleE|T&(Ed57rGp_7g`mc0wx`(XtVI^@c#OJZ?mN;7cIVBTD_n4AaC-eea@ew-}cf z`or?Y=AvX^ztZQe8Gt+>gp(_LO31yS{+XCgQg>iR1n>otF?fPbz$jC}J(FDj?=Lu+ z&2QgY?88`~CJ&Frd3$dw+%!>SN4n#C%^A}VW>~IC^a4B;TJ%*+W+V|^qv&#XKbIG} zd*w2!+UmdBE)_;8uM`hPnZISgeT;rPk+2BYI8gpGWFTjb$ac#t zK^llUg~#K)2M5B|Spqkkjph?K!NM)-@(S>WjsLK^Tft@ zIrif&^UANJmK#qzZU6vHLt*=DftF^6gQu?&*UiVd3)Kgjpa@wRpS>Fy+vn5c8tE z^AGTc?QaSUu>GD$t870A&n*$jIbS=Snudqcw_6nbV(;YM*xR|ia|a9t`EQb%@jFxI zEhY1h6Bf@4fWe7T*VFz1ZmlPMF)-1fDpXcu5P>{W?Z zbODYiE`=dZtQNWxe?LE_JwAeCU#_s)Eu7E#lN}4^JC(N$r%$4Dn#%DB0|05YaPTaV z^IozG@Z+RpIt)vo#lR;R`0VSv_TFkXexJgXvU1k``xW-AO5~24ZsEjQ7( zTa;{EaMkwlc$?U8`Yb@BWAnZ+23UvLwy(k6Ve1ZO1zbaVapez4 z`|bc(|04wWi!7fi5a7aZQCfbLDeo+0&DQT-SBN@RX_=WXv;ZJEI)-Al=&@!e-1fZ4 zif$-M~NHzk~dX8NT;PW!xzQ?rV4aKic+fGd*xN)9}q&fk&9^` z`;<&>9?fjP7*_(B(8N*2%;_y!LIj%e7tE4{{SB&_A_?k?J8_(~UG zN;XoAqD4sK1GmsVHh}@t4%)1I{{Z7B-} zAsuBug@&VJfWQXLX?5#J?w*uP>?5l!D%q6K7PrdMM|c0TRz^@>lE<+<|1cM8?&$Wl zF+gpYfW{+e51@H~3}0;loMA1s^qhGZtC2oyOI5V2YV$&N8!Hm^m_MWy4HgDsHlJmZQuS z=H8>suM-@gXo#q2;y|RYz5n6++xMKi&bjw|KIeSSx%WP?LtBan$q4}f01=dxx&1+I z`0ZfeK{N{q2mk(?(pnjRqi93cJd*4~b3)vc|)ZA6t{ z>%zA`vrUDv5$%gZT;4v9#{;CD70WRbz#D?HjDK>rmbO-HYC@)1G{GzroicY=iR<3} z9*4UJNIS#kY;SV5Z?-<=#~Yj|x|}%pnlD3Nz5pYTX(ao~M(@4X5_=U&w21D@fxgAM z5@QeNS!{E62N@FwG_4C?i%6d*Rw>+l?)BzG;cTS!rx4ceW*xPAVvX{hNp6_yHLUdA z*kvocwp-X-E+jQDw^=FQOFX_L%>AU(HW@?9Bx%A0>c)@!@#^sI4EEl3Y+tUzYs}-X zujhX*In+lOSKhSv7*IXiAw#$znU5+a5!H*G-5TT7i(Q~^9n3!5{zU0SRNc}q_bH!l zu9|6O?flFc`}k_SM){3>_Gs0}%1q`+rDMarVdhj&M@kcIAbF^mINz7hpRZfyeyqTD zkuDJ%TA^BZWP`Q( z+DD^7?r0l34q`Nts(WJUmF*j3extKX(X9uq?e#x`hR=QQ$0tohJ%Z&Vb_~?2xlaF_ zbOr!GA}DiHY&eNImN2&Lcl06ELABI>zRsUq>;G-fgnWrxo}Bta$S1XN*(AXfB#=-* z4@3p$R@dlWbR*jKdqa2r9+jiut?^cFJaNX&xldz?7LP5L>N zFg57uM@)>oYS;nJm`l4$=ITPsbrMMu*X#)|_?1jQruUepybDIYCi%)g93cp%B!yiS z)kfd{_V&=npu}j!{t=TTL@nalWQXq#)=cW@IWe4#eRGbUnKISpS3*lu5H(KdRJXn& zKhRpcq+?%vY>k&^9J+3Ysa39UU>LkOSJIa|gQBN$z5uXz<~UcWXF3ld@qr#&RI2Ds zD>yte`fk#cB0-3Bu9t1LeK~@;^BmzFXEpOSnh{t%6j!ZQjAP8^AEF~KL!8!?f*xCa zi9Gev$WNZX(psDDNAqkeyTEXYplgt|3Juv9h>TWon8Z{2m?mieH2>M5U63hzUQp(# zMz0~5VNvWxOrYFxK{M(!JdzCC{iDZcMB7X!s?z8*J)boV$7Pq%zs zi1O15HQ#R7juWA9BT#7qO$+ftb20!=?4%R{IS`Uzk{Q=dvYs z1-4>+<-4HRr?N8nTPk>*jhj82%q{ZurVM*Jev$tV-45NUT)1X>%z5Zf5V|B=M6x$8 zw|nhm`w^6|xwvg9$^WWP7>=t~VPlZriilvwmrZBs=%=ks6(kvu22D2}VHkneh^jrK zqhL^P4tC|P$m0|OJ!$yvf(|TX^s_0g*wqmY-BlasgSuB7QezTa|4fQ%kdiT3D7&85 zgK1EZ*~J-YZ7f83M5Wz|OV#{6ry#TxcN4mS;HeaTQbj|1@s-9`wFS;MMPcT9>(R6S zk|c}--Vj7)9Y=X|?@^0k z@;~A5pr!cVc(F#hi!G1w5?j;5OYX3OaJcyFZ+1w@eZs$VBHPHKma1?#%i7z+Fa_8I z4-~R-X@Vrh!FkM*DEK;ASAV}dEedWo?&9_`5vkA3tvV!#x%3U~%K~8zL;6l^ARSkRP-;~&mWyE{{l~p%_M+7MGJD#h zH~*#iiC(Pjc#cVPI|VCBWlrXD{KvP>XkAX;+3zluh^aTL+mgQ)bP8k`j)MjNt%#Yv zH)i2sYknVAGI)#{X%ZtprV?^&v{FCfhqORlgy=?_@_hTcb#fNJog``7 z?4FyXjwN7Ewvv&#@>eXgz$p}>Dvk`{z0)~K0>43Id3zSVw37?EQfPQn+B*4@XT363 z3MSoN{$35$IDiT&?AGOkf>a_??oR+ZE&TbqZrlVot9VW7A#U?frLmK)ay}@hSDBqX zblBr<>*ao7UVI1j3?L$1M_66!ar#OAqUftg*?eVVHi)dfVIh@T`faSRtJ^9vMUw91 z4we`1aZ2*4RTTxt3tFhqTscvQ*lzeyhL=b#LX9>}X8YLoZ zbRueO^eEwb^E}@lp6~M?eDCWucjn$XW#-&7XU=PnzQ zQ&R$WR#nK~iX5q7q5%Tcq|lzbQUK2oH+@SJ5GeQt2ow7e2aiIp*j+I=QFzg%Q)qcsFSSwM78ZV=-5*Eyiksy&MLF$ObC=h$! z4uTl+cHb3?<5P^LsHW||J=re$IJLQ9|LvNZO4UNh97HYnw-PJelfgw*FEWm26&*1{ z=0e6zbk<$}&l=wtXF!u$B!(jD-WN(s`X*`vr3@_I66Q1&7AZ$(r%UsbbfV_uePmf` zDrEMoJ_*?t0a=&_5vW@nr{_%D7sIX?`+=>^;6UoNe8qUNc8*LB#7BHb=&XdMPQu49 zsZ-UqDt>#=*tJ8B^8>zVd#Bg@EIZiFm=+!3;_AkhP6zsc`dxW4ItG>wk6Co|l}1YH zz9+=)s3c{rOz4t~)fE=We>Vn*+FUqG8uQs^nw#V?sV`}lak6(Cr_F20lu&VRB%ag9 zY z(6fmxDOxp)6FruDG8he}CEY9IdTwM9Q}TkT`NPx9_@E*!1B0GJj+EyTHyYVqxvD9* z{J{|Sy1wJ}@fpr<5cmj3<>>xlnir#^d@y!(gfD&lx#g5Mb*X+qBgZ1ouL4gqvDrVW z)1eIO;*==AoWZQ`&+qiCb=kGSQWot%4_-d-Ajymk2y(J_-QT(s}NBPeRg!F9v$6^Hg3Sv1B!s3VhS@F(iP zetlSZnCE)VQN0GMb`L2Z`Fu{#m&B|0ji4E`B#BeWl_}f0XS-q3Gn^fs-*McWt?VB@ zQRgni9-U$N8o2Zpe~M+Dexm5{(Z2t%yi~t)h7P6lEp8-b`$@}!L$+(?$DYsk;)s=! zbYY^=v2|0>!>xV0>@1vf5jRGlKZWOF1YZ(Rs_%LThyKpiTK^CZ3eNVL&;`?wMitgI z{YnpD%Axc2ju57xj=aPuJh}Mg-E@{CR!4YCjI-2!`y$OgsqiOi;U)G*V&GxbtKQFc z>PMUzmZ(E}KNVO^WFKSK{I_cN@!qRnF&hNgte{o?m{8wzH_`}@Nr-*T8trk1?3ZAp z(_q=}h-=2JfjqeU5Z>&DFZj{69JTeZ^{U2w(hSSr41S9{KH;ntdcm)k8dV1#aWcra z)3!yw?AMuiO3{_sS>+M^^>z1R$xc^f)ys&A-a8W=%$j%aS9i>BK0jjw%7eP{er?Mc_`Y{JMx^+!sRb5-S>@a7yxYqa_J8dV)ukQO z1MeF!6XEi|*vS~qcmMod;OoztlUzzUX&7}4nyfM$qXoh*gyE)i!p#)9u@jiT&bj&M z?&`8KP1#|ukx1-FY2QT^SKrtggBEgmOOA`&riv_yOoH0QNfVwiE5GaQz=ccrYY)ut zQJ$y)blDkk%UD^r&5v%5kx>lCXsKR4g&?LMzd5c+lL)8WlHth!ynp$Z0fVOrxamyPdRX59%>^wG8DQg z9IT4J+M1GN^)gZ>jrFUCO?7Zk4Qnf;I*=R1^{aDyocg5!A$gOG6cxmM^!UWidOU%G zWMGx!O(giulvaPQZ1rg##;?iM$G>wCT6JdNqlTxAkG++5xodD|+}!*?xtZ#5{&Zty zikVJ=F)V&P@1EX$MFzL%VniIl%_V>zTx9 zoD$~t2DrRGJYu^o<)rS1WsCLWu>zBT@@&XjW8>}YtiM3 z_8^{Md zdiBDojzzh`AfM}}s`8`(?Y6rVzj${vh;9N|zX=;ADR*vd?yOi^Xs^XtY>A;(LXEj5=$HgQMz($fD;2&q7|@Q2JaTsA=o1 z%PAKuaTI<6Q`QR8alm~f3ncP7_iUH5^y^NjZ!Bf}gv6*1)9Q=AbG`UNS3Azid73lz zhX&Mm+60LpxOEp)se#gGL}%Kw23Gb)7y1aq_JqlJ3K4>aMGZwv1Py=fxSWk)&F#Vo z@|Fc5rL+4$jFg}h&wCYV2Nq3KOR|Z6?4p?bcF86dspq>gCz5yChl3uEus+;1)~R0I zea)|gB@c((_x^XJy&U ztRQuu>H)1tv3=dRT+~`+bmO)0u^DltFpm`aae}or(pKn9hh{XDH>53))jqa=qycvm z^CP5P=f03L(?4&xxiq95ItF8}3;&=&ao*27#2tF@^cwRRtwbgXqq@KNH)!fG(YJg6 zJkxzR0F)#dw zATWoaqw#gQb(pldfgo6O4C|A>olFn1%eL#74sVNfRBl=NRlT8K35C9HoFW3hv&)D?HAEy2X|-apC4Nr~380D(Vh!n4C63B}`? z{!>pUVMvX)_uKa7S7vYq;^6}{AW z!QDZSJjzgwxt<-8hu$?s&?HZ_nJ>bkGL2~MTJZM0&I~pb zHLRA3K&EP^5K>E$aKit?%*2SzsnI1L41>>t_^KOV1=jj)!|e)5p>SJ=U1mP&SF{jr z!+v=sI49c{FJ6-y=}1#JL^{t;w5|M9X_H?dA7L)cul!zc=I!JISFh@hZTuknh9BSsXRYg=d1#eW^cps6W^OXB)owOhn~s}!q}TiGi8{q3Qm3xln(&_BCZ}4Q#x=#lSrvy$H7O$F1D7hqbXFghfoM)Vl?f z0XmI$J>w5zqr8Q`Su$dco9v@2Rb{!0NHL~2eVWZ#*@o`LKM@{@$c+Vetjkp`7!)N) zWqo~}9(B}^fU`seRv-%K*hGXW5)^|JOpu?Al*~4NF*9khy7e5s4eJsjKhb9IeRrNq zLd79U=U>?iXr)Msmi{cr7jIZ5o465nT?QN%uvLZR2X!?~fWMV=gzI zYkEjfh$bXNNyCgW$3a^Vj)adSya((jJ(>rnqS_kTH-K^eQKY9LUL56!t(6LQ5mqBx zox%|M6+IUEYnwJ+w-uI{HB%FjO!+DO_?dIbGMRs#K;+nTJj16W%i@WUGxJ5aN_C_BmkbDmlL38vB;p}vdxL$G*;@3rEw61q z80^; zG7Z0@6VHJv-WKm9PDA~wg+cTwBnBeWP{c9g#p9`WR^#N5?=Ldu>!LQh^`U><@(Gmb z8NW=c_{``mO2>u%aL@`o-qb?Dee-+y0?mf9jg-U}e^2Cx2a(uFVaG@Z5PUXYq=#31`q3PyP?hV?%ItKCoV zj_A_?x21z;%YU$HGS8CFsN_}3%+vnL`)<_7dbM1PW&f$M)YM^WN3%^F6YvGy@9x4E z=Y?EQE8D1;kzoNpp{Izqr`}GYmZ%^Jz*NHVCI#Qbeti{u8+OaNM&PNF*IBP=>%^FJj1x>sH4JBu#uEAH2| zL@9m|+9HCJUl)whB5_n@nN9VmCY2*S3q`K($<@w&98v4?=CN2ab+$fpp;>G4_lF;3 zrV$p}z_Pep;vpPJl`H|Mv6Da$mUzoAQig3%7RdAh3Htiasy%IKyHKAzo!?hey`xxN z7}~xu3G+fr!j!JNU#b-jMg3^~2~H#hz$>dP#q6$=1Ob4!W5?6f3>WeB8@J;J)<&03 zb8SUeG@e>nyj^Ml@I@+H@kmizfQwRKIa7RZsnv%^XLOF7S1UBAF4MfZPCrHid!=o? zK%itqeEuuZNs3DbS{%6Se*DD!u@8+VM-l?Xv+pnU8S~~|8FH6=eH}fIh#2>bLZX% zOrN1a+6usVr;}RExO9L#6M9S7N6RoS9d;IN3(ifP^_8d*=5OGNBdV7yZ{|00E-H=1 z|4>0&I5Q8`2nEyyPnvN@FJ>^eC34BK1NY?SrYXdc|8Q)4++yr`g4ut zKBMnxGBlCPzTc&IbozEL{+a82^E6b(;g0`9dxqe0-#G~dcTyj-<<|Carr3<(wi|{8 zviq>TiX?)D4o!i%wzR-|2FvH_4!6p!hS{N~U$&Y~yR!C1T+-gGrMdpK_L$9#hK5MO zhVoun-T^Q5YlZ7Y+=JF06@J2z>EUOZy`wMc3Vl1`+`><;*s=D647IM(^xv#yTWC~% zZ^eQDlDYNv_T=(*uJSEo2agbZ2@5Hoi}t!9_{au_P;@E|F%S%vWo*rk8!25dzcTwd z5QMx~buas7@G5r8mAA^Q@@BMdI;;K6+JE zn!-Q(W;ZZhqKE%9oAg-d9W5*&o|#%u&vpYHB|HSZ00k-Z8mZd;bUU zl1X)BvuE6kDeH{u6y%Q$4znvvk?SUI=h=>53Lv5I*YYq%O1yk+;NS_~viAZjxH#Bk|x zMHnhnHEO)Xx$jF=-&Pw(%S*IcR6N-8_T1iyVoarEWFx2RLkES&U>#{&b&Pn#cz6i+ z?e_N&31xZdu^2GxBZ_yS(3G*v-sjxxy=26D1?mqK69(4$pr;30cDZ*5=i5%3JhU-@ zV%i!=ha+6*p{%(fQ#(^(X>`)hazz5mVTk4W8H;S>Wv}A4Z{11GuB+t^@t{a7tjkggVgrD-`Ij(J*ORB_ zAr55r#Esa1B5ojQJz#gnLkw88H%iCL+{KeemOr-K)|c%2=VMs;b$;vA>{<5Ti~)tK z<_eA~CeY^LS{7hXz?O%26tnj7YUQxHwPVko z*6T}RJ9^^E;`N%sJRXUBWHn3QhS6c2LKSUPuHF_7O(03jFnd-d-RCFp;NY(4M!p+GWnQ0V8U#q|Fr$ix%v?iYk{2mNbYQW$Tf zTCI&KKy>v#w3%1|R3PbF^0%a63i8sjCN%_RfQ02gNN#%uyMti2BySmMYTo^^vjueX t{ztd9r2)vp)5{$&g3mo49|m(^O!Y?&@E-~UGSD&AuGG96^*<#vj@tkL literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/owncloud.png b/addons/cetmix_tower_server/static/demo/img/owncloud.png new file mode 100644 index 0000000000000000000000000000000000000000..0e0aeb56cd24a24a07531c330b1826265a2369b6 GIT binary patch literal 3428 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 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/proxmox.png b/addons/cetmix_tower_server/static/demo/img/proxmox.png new file mode 100644 index 0000000000000000000000000000000000000000..ed181b62cb775085542053e0f33e27ad2b2ca615 GIT binary patch literal 11483 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 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/test.png b/addons/cetmix_tower_server/static/demo/img/test.png new file mode 100644 index 0000000000000000000000000000000000000000..a6fa74d2097ccd302b8663cbeec43dff90e1993d GIT binary patch literal 6070 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 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/tower.png b/addons/cetmix_tower_server/static/demo/img/tower.png new file mode 100644 index 0000000000000000000000000000000000000000..960bc4a4099a46766797167a3defe50ac91a81d9 GIT binary patch literal 13947 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�sqUYC2C zv=OJ@DCdB$j;(?7*x{c~k6B4>41g*)RhVnUfCZFIOlD|sJ6LM%;C$;9s@)zEa$vkn zf*-gESXc~B;q4jXQu^3V)}4!<8Dub$AWyj$bi0r>GM>nQckP0TOb6 z`A!>`=35vn0d!knF)#ZU&}~+c6=5vl9}}PggaA_-esr7^I%5U!)qqMWWSR<@ zW}b6^OY_e$XhcaF<+S^1*ydl%L0=t*%o!XulN3Y{ISimhz$#^mnNAn8+(Rt2b}+|j zCm~bBWd?%@X(VwX>07N`hvLOP1Uxbt=^HhG%Ny>JTie$CS_isUXomGZyr!p}69M3G zwy9hWQZ5IX;~eDT{3aaXqu(fFpRb;6fyFcjFJ!;(XmMOjWm%Ko#7b)$3++8ryL|+d z#Uuq8hWnAY<`C8T-~F0-_K$u(MnCoeKPmxe6>yaf1(gCgDUDA($+D?-ds$uB!utk) z%9Z(z$9!g<-NuIof5Pi?Pf_o55>$mL15ZoeCBThZ`V{a4@bqXB=3@p>)7xKZAd9;E zc8;b$iU4MrqEYFlj!}m-ZVwTepQ_6_9>fIj{*r)o5Ag4dvEX;CrS&`UDzS^RbUAQ& zi4;1FB6~mHV}|TKhUQnw6eccHJ@ov%4tPrmxW5G+9gFmx4uB5uA9#)8z-tEg?vhny z0fSQppxbwh#8t@jR7&3wRbX5GxRPQWUdA5H8@|g(ljP$Cpk6JTwJKG1AwYk3#hVZA zslM{3|NSYU)xoyXi=WI9mqV1P!6Rp&IKH6-xg zz%yf&zOlFw$M||miT~*BOkDfSz^)cYNaYFI{`p=TFnEI*pO)smMS4 z@FREi^!D^BrI1q9xQ={35cFO8MSNF&_Rx3A-url)Z#zQM+V=H6CS6r5 zelp_G&~*=d`xEasefck%@V)bA&b(z?@Z3pn7a3L_FNn!30cB0s2ec{T)|y?fKm|Q{!eXPMarhy%!hfhRTZgx!<|x*YphxktmNMrNS@- z4r0hqGQ=cz-3{n$R!~`(lkCk16qZzU^bVpj10*Zr^ri+G7|im?558?x?Tm_rc+J(P z9zaYNT&l0Wbh&ZQ&;P*tKJ#6gmY;(VaK~-exPyV@;i$|er%D2YSd$)w2`USf?N|SF z-y{6~(Wk}C>I9iwflsV^tE#U}?h)hIsp7N&3`87qY4cy>vC!++UbE)%JHBuit=snC zGYz_X2byE=`}EcuoVF#C4WOYx^C=fYP3|L_LaKnd{R#f(Hyc&1uU~W??&r$Y=W+FA z%L=Ax{1A0a=4NVbpqRG~;IJXYe_nRc;vMyM$+S`m+i}_O@Ww>V_W!C`ZoNF|EL7@S zb1^3*gMZTanNoB%|6b(!`wRfXFfv!JKJT6xiTD?RUxruQuBHuu0Ff29O9bCfmdD@R zKalyH4k2L}ES$aTw{MEe!AXD6q^nJ?Bg_%Ce;!`|y+pJryj?S{D}VR7Z{7cq=!*CJ zV`Ta5-Q)RN;}+tIEW6#pt0ul5c(>2zyYPZ@YJAHY;y&R$M|g4p6yDl&@EwuH$r`2s zAG|U_G+x2W=|sA-FZZ2|o42PX;;`12NMz$M~?}b7i!pR*4R!UH=E{9M@HfHKAwt#`slkE7s zlv}|F;|42G(g#w1SE&+}E{^PGDt`CKVfaviGE}G%6-uHaHK<4(GExnJa#SFO^2fkD zX|rOX%;1GYYLiSrmBP3KXqqO<2+Tx?(5Y6l^2fj*Lq%$jlVJzp=E2E>v=GwqcGyZA zlyejSB@s&Mi8nk>W_T0tb^$%$#_P#1{Q`bddgoA5f6w()t1vWx!uzi9f#F9P-e1fB z$_#-S)W0zn$u$!gz9e2%-}DAB6bvA&^b!?L`p0|{!-q0Fa2X^0kpum}5UwwVESrJ! zPsQVxjvyFBn6ZgC^SINqgI}2q%9(|9rgFxPNQ-G0qwY{Y&Q1j=$A_DrkZ9CI*gzxr z+?V3AY`&0*C8$WXXW&DXS`AGOu8tO4ODbG7iC{?=;bal{UPP`Nk?F+f+in;`ZPjNA zfDxl-Ypv0<)sq4vfC^P2qjkvgb5QX)9w7)PQQ;XVUqq9J@lys$DF*eETm~$ctm9Db z4~(-^c{Wv8X%DD;pJv&6Fw%Ry_pAYMZ9thoegC%>lDK2%^ajwGb4^TSyJi_f2N8vS zaPx>v2WF-Nv%A$J48rd*xNr?JHVYM-jS5$z0&$Pj_+uWhzA*SgDB&w18%O=FV<%J> zfGLG#7wFG>WraCc(w7~g&L7qbMH81J&q-EC&ruAc6bX`Rft&M~u$@KNgC1)x3}9qC z5km(NnNCEm2jS*Gy2n{;0j3`nnqkV=%yZ<73j^?1*0cuDTJiA;q1~9w(Jq1It=N1YM*sGsH>+8o@`b?{@&5WFs6e^rTbV(R9creCp)cPaLfC@{ zYY6N?QZmdz|1CfnSy$1y{XrV#W^ZBm;}D2@e@(wP=|y%c9gp$a8PBb;hK>bL7QD}& zFuy1~1{*9##_CbAdSulK+{9w=$CikEUjlEhXb=DD;mxelp-chfKl%!;^<}HjOc~b$str7#>)GGEaMi zDpBDX$npiq#1dq*4$N?g)=)E4pe#160W=H)41b)`=61sHBq+HInOx=prEG*XgvfSb z4DLe=ws{h1XAt%f*lC0{2u>a$EzjN*?amRE$+4=hL<&-71%;9-fQ%9KCQ`lnvq%4k zV;y_#)GIUSX@fx7QNJ}1M}@1ss7q`XDqMv!!=45?;j$A!1mW?PmvIG9xA3nzse2*> zh=7i6R3a;N4*>08BOQb@4=QzZYepuJBtL5@e&6KR=9KU(6X}k-$r@Kj-ZTUv-&clG22(iF z$5mS%vJA3vDxG-%WeDXM(^>*;+rqc|XZ!I-3o^OXC*yNH*TC?>BxS{wlJwih+Pah@ z$k<*}^~u9ad2!rAzn6jO+nyHENl$A4E!)Mv^S<9AjC++Y{2mpp4XAK6DpHF~EJDT_ zP{DEtlzDbVjK81`*)DC_!f_I**qgDK84XViSklpMOd9h280p;@1G_zzo$bQN9Zus~ zUqiXCOltu3(o3P-&kB3UFH*a1A+^iv%WwERM(2+pBXy|q1<11bsMt)DzYOKqj=LE| z8G)ld-Y7>~CP#bzSP8ThY)fTns*2$=#uCa?;M$%a=;S@W(8+qtdFX(btWNJk>T%9%M+s4`oFahpeifEpOM1&^O(#8hIe|k<6dNY!E#hEfeMsE zu)+(s1uH%JZuoVY$J7k6xblZQ*TWiQz=5ylma{1rewA}kVY>Wf{1uk_dXKmQ_+uWk zEV>v<9-Vx*fUr_W0uI6&KokbN9Hl+vC8&#^9p15*z%IO=;rAx)*f}8^UMI{j^?z$2 zAFTTF^4ZRz->%L4qI!YTHp)E;;wUpM=Ie@XNHGkiX^ z9!to9c};~aydn1o=Gg~`s_{)s4;nFk6TX6bV(%a;!Y85Jl9h#Rd|>cDsdw7PwPIT# z=r_uFF|df=hA($I%$h&@e$QY3AaTde3C)E}G=P=BgTQ45im1%8(B8wj_HGt9`&nr3 zB_Rj!snPASaxob)BDDEtvfaOs&B628?q5h=oVrD)fuzpuW>w*NmRP%4WbdI;rgZ0u zQCUR_B1o4xgO~mD+3RoMMgL+B`06N#V4H&Ln%Az`Hqm5vqIb>?;`MvJR3gNn5oJ?w z8JmL32r7$Ox10IS0g`SX6*5hwOp$O02w-C>7auMXgFz!o+K4kKyaqqrW|D)xS~|>H z294;bs?H<^?Pe|Qky`v=n;Ei?2B(8sx0|G!A})uBt0BteAR$#iB5*NrgpZsE(Q78? zH_AvEuyJw0DrTOMda(JEZ zyI;cqJ_p>>ympOz%>k%GQGWpLfoafnZv00I_%_~ypn2^92sE!nr)tw>xZq{7gg9FBF%=hSUeDdUwnUSf3K`aAf)I z*8d6svs>>D0&i6C*&X@Nsvq;+QM#0zN7*?hh}& zRlX(x%szj20>S6-9#ar4{eP|!yiy4A%TzhPABw58YfdL|bOr3brhK6NJ;}a{s{MBS z1Q)jhxHYo;_UB%6BqsO#j|~Lx!+V5lbhy=hZYa-3s=MW<>khqK>2vSV#6V6Va0R@! z(w|cBj_b<@ZmjX!e|eHNS&M)hGcVpXsl;$nS|KZ;l6a3YKT*Ad0y;v5`gT(^^PAPt z%VUt- zd(3uFtE#UGxEI$=hc8?=>hm^j*a%)9(xN1q`Vb}H6{Wm*NDb6!5#H?9ABRD`8F&|7 z@6R8C=!`7CO}*wMu;ivIK;5XN=+{Dt7sD%Vo79Hr<1rc=8v(?l|NasDJ~N2Gi7dN) zO73F{9Qhv`2t_z#NU`wN=?F{(-edJH)(g$V%QE~&Yinz7V`F0#@E)zke+K-%wYAkP zIY$|AA>IQ&Hn+C6+7o%rzHz+qq&Dgur8ji^h%CQNPGhN>4SZQM-)IRz8U$J!8yg?j zcKi$43|x-)K46sl?B>S-^`ge{J@_9f25^u6Y!Ll zq#llDP+}!SDN;IzhG78AEg@5t6MfO+1>gd_N0f*34V;Mp)@h0HX@>eS;OW-ZR&|`D z{zNn08sK4GU1cC9(?p#%fLd)-Y4E1Ru8z}~sZ}U=)#O``2M?gNwRKlxW8=s5+!coV z%o!Dl(E!$HwR0wyOGLC?Lq}3h=#aOzwmLcxFy&*s0IWS-09UVGjZ#Xqw6wfxMX$cU z*(a2mp>0YKukLaHL4Wg2Yn)*m{tA6fG3~(jAxJgfwB}^jR~pfAnBZiyCQY=qwobkq zQNq|R$BS>?47^F-`1dpc0O$oCX>D!I0IXcOQV1a~04~LgGqwO_z}0wh&k*pP*4EY? z0IN4N2L!4K_+$NBReGTmboAs|yr(}u4&)SKoc8hEs|wY5)wC#b(O7q6URe-Q-JG`Zk{3%(3|60f=Ib*-(f zj}J3&d(1jPoEO5?KgP) zEL^=%<^t>VV)&2-r%FrX-{A$QZM`sFrzdGX@I{4qQUBf^{r)`t|K6qNWCyT8|5o>L ztU9U}qN%@Qjsm<&;B$CsHNVy%51ft~9TR~12X_PC@cDeKSh1o;184*HwYIkYYy@h% z61W2RzrcF!YYheh0juPbn}K%=p>i85A=wV!7qfN9|V6MSD;eL6^B-@|3fcu ziMB)iK(^_=jizM>+|BP^$+ZtXM1Lk>_GLtaj!{PR?>^CT(`q>h;H}YYvr#YDZv$U# zZEYQxZlScX3|k+s_6hH67Oqf>wVJtJGdH2xt!_1l)ZaJsear#AieOhDV0AQY*l71< zFtp8k8(xOM)g1(Y6mASN)gq1aGPVJV|gs%?7@y{Vf-7oAz^>g$6X+jNxTK zF4v4Ms+njpxvNSf!PO+`5#S3-iLObw4GZ|8_K_EAO>uK$W8+<|t*!ajEP!h^JR_6` z+*SImM}QA2#QvgvyZHX6kr*b5dBAVAd-7Gy9v1_%G|)mz=^ib0Ez&V^Ew`+mj9Zbw zqd+s>rk*)?X{e#b#>RV3mU5d)08cxRtq^`4sqtyY{d>w3d!K!0(~-|SbJHaNZqtU1 zQ7wr%yv72*zV@Mq=*w1$Vk-%OLKTE;zIDx1tVgLMMG@ftY3KhRfx*Vc#-EP|h~oud zF?MJIp~l9>(~0q!C3YhV>@NkcI4-{bX{6$E?Nk>4WA**bJp7g>^tDWjBJbALR=2US z@c}(Y-_$O}H-TVdW8;R_*4EP^Q>R^Lsk|-J?b^=x^aUDY7h&Z9#fK}zfD3ehx-?*o zRK{E#;tFW{cQcCeru#R(>YPm*HX2PEHu{_H+bE_8LX> z-Sz?R1n$y-=ZEncEBq&3{pOWgqEBpokA1C1@HyuMc5k@PqC;~(1iqvf*f}~LTs379 zs7I#`3jZCjL?7pB+q6edqAP?L)RSJLK}ro9LVK|(*K~gKcQvcLRYx=C=^HL+mHh+Y zCkS3a(W=?%4S0JL%J5P)Qo9~oG_c!Gau03VRhg^*o`_&{;}t43;04&NrTU*uY)vJ+GRw$+FfJ0Ks z{>__DTt2hvfoFvwWk>^3rTx7@ecm(%PV2lEm*y%~WT%Btxs5knJd*1# z*I;IcBVzh3m@WUYe+VIP;-{+ac{*^aI`F5mq}p&W^L=>^M8ljs!15*#qW?PBE@GN4D)weB?nX`kAR$wG zV*sI)t&urE8DM1U={N!41p)%~uvFD_(UgfgeP{iH|K*&bVPG`cz!b!-% zMbE^>#mUS{OUTT~#LmFT#lXZ$$H>IZ$i>abMEJih;(yYdOwG8JMaBP@tbaW|Vha}+ z2W|!icXxMscNThkCvyfSE-o$xMrH|3&TWq73+d z)%ZVBJF9v+02q`3&i1ZOCja7LM)E)4f4=*_3;GY?KW(@boGkwZ#mGj~-o(`wVCNzw z%18X~4ZW$QDL1PTJ0k}x3mY99hY2GcD;FCl9TyujC!Gl=D;FcDsj(3=Bm4jG`9I=C zgqS%P*+p1bIfa>+#5jbQ*u=%SSXmjF#W+P7*+u^cD`n^GVq|9m_#fGp|B?OQSmFOG zmRrOLVB})&q-t+(^FLXjXkqVS?`&c3Kqw;epQv*XQpy>bSlaz(km^5y`fs~M0Zx`~ z08?=%dt1W)@-(;Q|3M2DPDUeRfH5Y^w|GOCeb^dob0qp+Okkh|nd0FbB`B!K9Qldht z9veQ&J<^~;NVyS^y}^hDYf<@7C}_mtYf$`xU@eMZRba5N=y$-RA@RbX398>j;W?hr zSHlu(Z`nE9Jv~hhf8FeQ-#*;jUY{&Phx2J=!P1H6%gzZ}O1V0ag^xSss0C5{FNHW| ztf`GEyqIAEEQnSFMdG<7X(1k@Tm1Z$o${>=^L0}^$Y|ogu*bo{xDFKX)$D>eWB+{K z8Tt$AOpy1&=BT|Z^_{T{M3hm?@VqsysX7&cY%W7zig>hhqxireWL2_KC-M)Utb zF_qRA_x82QwVgLEL%;m$J$oAhEXfb=w>0^9pF@|M2BS2ga;voy`U( zIJij4jxXtO?N)DZh7h6dws@=7qA3>Ll(=??1?AwEq*O`oF&OMJ{C4PoZM(Y29x4#> zX>H&~zejMWHlT;G`r;(_viY*|Zgqfh*pS`E+mnsAl{2C@Q>4eHU-ef*g|b(KCBF)ghG(7{SMtHc0E-E;{b;QhorLmRm~~+1sr+t z1nQVnsmhH;=YoWX_X)oLm$aQLf@3V{A>mm6RyZ?vihHYzw`Gz6U#%>ABKYzx>yfB$Ih=MbTIUpOAZ#9u&IJy!-Hfa zGZ$HzS?s?=6EqGerTD)+Jj;Y84zXIC>6!U~mK%<|$WFU9JD6LFma~s1{bsg|z57e` z`uvHNjBM58AJ;ZCe|Z{C>D%YgC{D+RbjKVrwk2Hn^ra-Z>g<07C&T4?!+$Ft5)01j zTjL^mJ9!z`M}YQC$jL+FWRl7b)>c)roz{ZzCz6}$AteLfwy?PLz+9o!mwxW=UsL7S=*s+l$;H1S>wyDYKr9k)W5S?s`5KIIag z(fuC3Gkg#G6Cyna^ZmP$c3(NTHbw82VQvUjg}COi^3*$(*`sy~S;O_(MH}pDk7u5z z(EuYaH&Arei{W|5x3O(}ho66)u$OlgKew7YI=UvoF9D`D11y$vq-n~Ha|Iq*Ux7F5 zh2do8`Rf6s7|ZDHf|%vtS_X2BpT{K%plmj3^YMHQ%lj)Q$=CQ3)YWC#GM;}7Z*u1M zj^X>7oPPbwJ zCA`RITM7664Y`ZIzw_2r+KkS|5*s|AtFyAmI8zFdJec1N*PjvK-{bGzXK?>6Z6ZE# z6inFYCmnyj=<#8R3P^E9JQ!+Eb2&|ePw0}j{PtP0OocY#%y@nt+i|~AET1M5gB(@~ zsQfe^AicOzeT2jkDHx;OZ0Hp{DcIM0lA$U1vIUOd)R1UIty?UYJp9WkVW4ei^_jQO zTx`}+sHbDp|M$@R$cj=4Ed_j#k+pZ0n!QQ}#YS}?PO)J;A?ThJN?=O++l`b3#L1;w z{dr}4>Olr*rJB6CmKf%@^CmfYg&aCZq-}l;noLy+iYQ%SA~k(=-Ev@H)OCq#3H`7n zWheS?b-PGYB8!bMfo3%l>QNg+~7VI?6XTUyJ-(N(V5IWF_rEsgC{c#fML&xh-2 zF6ZOyn-4qjO4atPCP`gvRluL^z2lJE5s&t3v1GJa(7o(B_2v;L+Cf0n#JpTC-&zRi zoeb<7b4u6TP~XQOcBDWpw5_Am?4A3<^^i6}$scKIb_@;xrwo@AM9ML; zrT*5O-tPS%wFlMZ0wwDS^^i56Br|n!jX#(z%Xb16)z=x5IM$YW(wPmDXA6l-gzAGa zn<4#;nc|-^Ez-(VT5yaqu(-@KPb0JqpdjPGiQs(&SSj1N?jOvX?95zrF zvn>UsKVQ)WC1Q1c6ZkAQV8&S**ES0cf|@hqEKL~CiI8Oz1DoU&tU@^dc?c7DCAWf#QgmPCKFbPw^-pq9| zGxWZ`5!`Wzs;|Ag1)AcVVPf}+kqVqu0Y;ZnSWE*u>S<1^3_|eVq`=4 z+g-0^&}iRJX>@$k8?*XhKkLQyOG4gBg-5yXl zzx#*dA29K(FOO&C_+Zl1Ms8@PiW2vK+{Z8^wk#T;!eCEdeZ4~eo3`WyT@+-+y})UT(F zAPvfls42`*J0 zBJL#lh(?j}HC#L{`o17PV++1knEiA!V1W1IQDZ)>a>UM3C35K_3I=AB1G!>f$G)_EGG|9e)(UqzJUUjC?+BEx> z8f^Q1%uY==MMucaVvFcJ#V_deb@`u>3uV6GB_OF!fy}aXhW<4Hz8Onl%&^T$ITI1P z<6u(K5UDQOF;_6KXt|F3vVcDWPd!@}w49L9(aB-mm=LELhPlzi7K|+et4v(6AZ)

    0nw7Npz-$w_3)&qSm(Y$Xfcvm&~0<%p^JzW|pRfD~EmHhC7ocO_YVNS$+hz1&iuJ z7WwJP9{EmSU|>4PiV69G^^#yHB$2^jN=4yFXsFP4aib(CCU72Pp~t+rFe% zsIIhO3$gNSTQ_>DDqTyak}XjP_1Qz=%4!XY+?MD!!C)%mgTXeI-z^wnbXy4RZ1Rz$><3e!55SA>TOkl zvi3(=q^V}aIQzabMBK~4WTzY#MlryT(_(j(*NMTs1FurKMHU>vfU!tWmOH(eHbLsh za$2Q@F~%Z!(F3bQgR&u3`UQoP*($p~KIdT1W5_BL5e)dp)jyHUcV;d_yR^oobYP2F z@t?jY`)RB2=kbeK%VU&4z@eFd=BG92+E!vVCeuklMa#krL5!UsPk4YYJ_e$KX3wFZ zpyO{16*FS&Ig=0(j?_Cd^DQHUkV~0HpI51M?WNN*Dp&5x59poOZNaxd`69UFJN&i1 z9fc+>qBEJB_7)WtpC~D8igpbp+zXY&F<9)j@2sEIj|+*}d6xKUVGJhk*| zYLDMQZnR6^F^tC_U_&O7WVsEB_~cUU7u<$GGWGZsbZ@BUQGWMPQ&*EGE4~)Y425aZ zKk-P_;K<|?FzwyI7(seGmc?S44|f)!NFcb^eo6mEZhpWyMeBybgxX zRpynhv1DNV`HNL?iNu>Ly5@x;{PE!_8Dg5e!Tbjwfo)i1+87xQlB@B0`ewmy5!1J4 z@pJqE+8BLK`3uE)+(55_8``H+I#>!OX(pdWa32Z?duqdY<3SAu7`v+D1z>)=w>=w)g?nI!zh^6iHbD-L-G^?nek@ z^nG`%ht^j6whvtz5dF&@@ZT#^Q;m+9JY zCOtAy7ySl9JT#X$qjwaxN_L&Hw={8!wYMzLbc$}4nI7-MZl>A?2G%@hP0(LxkVNal zzixvtW9pl!^pz(KO#>|S5=#>&uy%lqUcxr>udHx=x{9?|&sORnAM5Z6<4Eege$KaR z)4HCn3I=Ro;oYg|9p-F!Z_AuNZ!%f2X4!I?p0kP+cq-Yu5-zos4`mCQNE2tJ4TZuX z1B7}=2bro{vMkoOAY z1auLhL`VkOu;OQhJKnvGI|Wy}1F+pJ-^>$@N=glXl&>mu1S;CAd6MTIxa<$oG=z=n zjMD%jCQDK`a$ybjvVUH3ZF#FC8gEW{Ts-G^yc@hg%ix#i?76(uZzQIF)dc4ZY+Ga^ z8#?-BrAwA8!e@-7(=M3AgHWf(cD}XdD&Bn#ZEex+0wL06Y&A$}tAhsR;uTyoyeZh+ zy<_&l4WLRQdgX5&z6<|JG=cu?$mu8DX$>-K{yE?J)GaBN*0L<#%DjjR zT~kW0+K<&i$O{eHyr^rEX(qH);rF1q?`tmK+XP=s&N#aZ*ldRS2M4=&pu=W~ktccf%hm>;-E1*)=A3O&3dI!R?kt?KohcLqUHM;Kf*pg6B zE2jCCtAt)uU)jfT94Hz+J}3l%Szt;ZBYzga=pyG2iTF(e%n;YC zwdr?0VG2Udojn?=F-YAA7Zo-k38$!Ix{wDnb_7ezOWr3cS?wh&l_d?kRdO?LxMAGo z&q9|b=tu&V0Hdm)cgGsGkIa}%)OZiDvJMAEx@}Hm~Hl(D~PDqrU|9;?$^N%1T zQ1*v?{(d-jQUYm_4R*V8M!?=GSh!uT*sGh+AzWu-XBt>NDxi{61Ukke3<(^P#W^Q1 zai}9b>4o?Gqy7hYI7yg%B+Pd@*iua-M+%mjaJ~SDS6N7s0PFm$=~4(;K|^AP2>OU| zni)=I#<7%IFXq>&wZ#4K4GI_(MRjWUyqsQzdJ23=4$5)@#3##EvPAhfCKZ+>{kXfF zO=#9lSiHq=qX&R86x)H|lTulXWZwb`)%hqBV(J!T6eE@}E~p!jp=LH26tmz{v2S8W^govPYN-VgD+r79tAiM<(1tnSoN{SU4+u*AEGx<65@S%5#`HS8U)Zpdk~%Qs#V3ByCE>2Hrmy zI?W_8==16_+8^QMa?;He4Fa;CS~xM5xK9!(lFE`FpkkG_1>xYh_{cg~0FKqJRphC{fRv-aG zz{`zzY$`ZD@@(?tKldJ9a0%``=Jn0-iavE~+J|-@->)~H=_JU?!TeyX>-y{8%??FE zn$-1EAXAhH8=yop2zE-OVE&!_T*Nt^*eIdZ=ffX)4US^Ie}XK%WC(UK6Nwo`i7}VS zAdiYqebWkzD?%td04s;49I_zH#GX-0wm%q7#@Duu;s%-6k6XpF^oo4M|$s#Zq z;7r(Z6>{d79)FSsoZaq3PdOMrMA%d=wZ~9FYR|yIVb_S?7HJ+n@%&fv(;Ur!eSi4VC>3$N)h9m#w?Nl4vRE(t zgv=i1-jK8#vLv2t8dEB-{Gkn)ok}*DIYw9eSzHtp3SG4mU6GBLi~Z6_5|faILM0+hO76aogNqMMb)B1zrxh$ujRHG}Wm^gjenDR?b8LU;#PYEVk*|GM ze=Bo_M&<`YWlyC~IX6(EAj$mEKa{pn1oDdqGDngewNjiNkxq#8gBI4(;|fh-;ZcWb zJX&#~M8#UB`e>()EHPt3RdW{QXp-Kcloi?}in#`Uk^Qt1INEEwV8&AnfgB?)aZ>0Z zN-Xiyw>rU_Jft6Wf|j6hF5S@>EGnFk>1Jb-sAm>^Go!59#o}X?C`o?yT)aZuE~l7Q zru>ak=>_l-rc0}kp{Y5x&bE(Cdu)ydcjt5<%>z+H?%WZzW=l42*-i=Nd*yft;UofP;$}&sCW_!ml<7%dI<#`HA z^rizY*fHorz_(VyM>(4vjmw^bCviDlm*TcspQ}jf2)G0l2;S;npc^^Csed~eVnUe= zrfAaWw60ETMHE+?AfvnN8&M0zVbnsH9ze%>PbbGmHkn42Xiqz*31XInMH@rqUYi4D z4;MRanvhU7k6v?sLvjvV`SS5KT+M9n@!DRzaNjw_j(V7$2)%5_(Yp*h19S|BSge`9 zBYY-NV(GrqO}5qV{^%eKC&B=Gc5iUDQn{qeB9cDMD~Ir}x%JyV=%vv#i??yIgWKqI zO6~REea)_F58Zt~CLIyMilEb$i3hTT$#)cxb$cr(WcEjiWIJexL98y{idID?Uo{1~ z_M8u@G@uC$SowY)IoyNZG-JZ?=xmgA6K>jGIh8*NrgPeE=Lc$Qf<1ohZd^QbwZ1sq z3TBe)!O|&gUp$wfEN^w1lAH!Na@4CS&4`Gqaj5xlr|cr%$odx&STV2dUEKxRR_QmT1$`tl055pSs{#$1yPUWTTkS|65%2!oa0UY`?B){XQy2{b=Rc z&3pbF#jbMNrLWCRwp1m9{*c)YN+{lMlpBy8I|&)LOcLHguy6A!50_xh=|cte`e};X zF50+K|MsCdbEjP2lXtJKf_y=S=U;FW1ryp~`^BuTfBSFSXj@+6=`7=BQSCW*Wa8-K zJrZk+Sv8r>bWJKup+_GK)moPl9r~+c$_78P!O7~d~XAI&&bnb5mCI(sDLvKo&qRYm3FbS=vLioiZ(0gNX+h_h=Zn(75=)m4>W}V9f zRp@6Sl#%&Zb5?J+K(I}&!`7(|n2BmplO#^sj%%*jzFld__hyNoq(J!} zv9PcGjGy3rGUxQ=-lqhf!H%zycXFSj3Gc%iiAYajVS@JUo8Qzpfh*1H4}tFa_hdwJhdqFD$DY?RD}7o*Z5n zNHuUo9OMC&sp*CjRhz;HHX&rCJyx9qTj}`A!F8Gxu?(OR><|ac@3C`$ZP_WS-G3Fz zvr~fDHQaiJIMYch(0ydSE#ueO*$G0}-x90Lpv7D7?bq@*_{v=&D~t~lJw7@T+#h?_ zb02i+@8Ne5quuPVjY(zG+W_R0j-aMnvJbFZ!`&WTlaj3{n;yq(`P8dfJ6b|nL zT_AwpRk`tGhKA-2|Z_F+-SEc|DPPXawxwKlQSJn#~f>riwuF| zoEAnIpb*zMT)tydBK1 z`!wiY*!R$70Impg3zeXrLNQ80?^jlZcHyB2)T_x#5>Y~hDQc)o`bGqOSvg6HK|n!S z3*hx<65>D}f+GgZlQ?BitD?ziXzvLbnZ;bttou_3O^z`p z9sDL40{Tm4u_Zs3ftZv0myRhFT+Rb&8VF#-RKg*3nTGbK;(2afkd0e9-HLYFQ{qU9 zu4BDXF<5bfOl4UvX7H)PDs1tIi!$|stUWoXC^22lKMMCgQEPRDu0qTVEpiy5UKYf^ z`X*~x9&l?#ER9bbNikN~Y)5F(@f(68rh<~@`BlhvCaBe|!4-1)LN8wiDM0+tWIxs% z&KH48xMpEc-P4^=^&Mtpi)`c$Nt--5Z{a%;3gLv%@ECeZyNy(0mAc6l7AwDTIaS`m)8o<_4L#m|jX{B>t3wZXH+Bx1|#7#wC*6$)}YN znOGxRne&i_a4B7IcT^vdbHPczu3rDLe})u?DpnQx^Cswq^n+1N;@jMlIS zr%sjUH6ztmOMa+WW^)_OhQNG9TP!z|M%zOROYVTDIy%ZUHG-l)5) zes`dpIhxLRqF8@?I3JOlL(Ng{3fDC4_%0UeBrQocGBTrxc%WHHESUm3!u*R^(U%GS!^fGqbBdHj0i!+wvxnwX4 z=}WA=2zy9P(qKZnzqm<#; zV^}D*i5c>8W;P~r6if0+qZ#Z{zfz_-TWm*zIW1%UR^gz*p^J%$feBSm=3~Z}1rDNj zIH@{`2}>a(oZr$OIyDBp7^zmj!}82 zato0FNqKh)@Q1DQ4D)_%no^>6VSh&oA+IA^t*Fh$Mniw#I|pzH$nzaM_eh)q#%s6~ z`I}hHlm+STRFc&yElaUXwtrHx)wfspaL6?8(OI?_)unbj(M&T$z%z}kD0xfCPD?vf zl9hBKWefMIwgO{n9rfO{Uq;}lS?-sP1}peUE83z(6bauUawCG{;}NFfct(0UMuRjW zLzO*Sds<=oe|IX}xS!w~o?X7(IE;|&+y*OPVOzkI%(2k}Ww3;4<`BA`quZloucD1` zfSIX;tb~`S5KmEpRAa?;Xv2?Ae=|m_^HT4o={bVlQ+!GM1eRdBy)52#_X`aam>4V>76XJuqfdfGgoBG z!1GQm3>^mm6mNOd=hRz3h9U!3Oq6v7T(KkmP#Pq7uZ(1ysGxvp!-Wi4hw|JUHE!cM zf8>5X^I9U)1kx@DPz@vqNp)#%K~l+En!ycUHdT)0FOCI}V(GjUf3m!qbE)TkHg?Uz zz3Eyknwd})Xgi#;H7w&;=?;K7r4)CE<`{&EazYRyJJH47_dJr{&khuZ(YyaB2uZ3U z{GMaw#*iIFJAmGEj6pKQ!)*9B$k!lkwwTh}h?UNovCw3{eI0m=aZ>10!6 z!l~)GlA+Ixd!Q8H)5zLrUa#oq0u8jJkDvgInoRNV<^ZfOn{?B{ezTXi#6sFL?ma<3 zzhI?Oa`n}~;C!Mxz=3%3GVe+s|Hs2%mJB15N2`O`RM&8n2HTpX4x2tli@}w0Ji3L> z5|CJmqqwm7C*0~3HSi`^j;}aOtn$vSK$~M_3FS z)(`sV((dvUY2Su)DbbUHdM`Y$kc!^3=2dpI;1hIxgcji$kaNk$hw zx?Uj85K>`o{At2Q;L)YGDR9&PCQPzC`LS-5p%LgfDK8WeRpw~*S+3oQqkDGTZtew( zS^Hf3ln86U?5^UvyRqEaa{a?wp;}j^RM~IqqT*u@E;yZ_khuUY)gq>%7=2i(u3cI1 zV$z0JZZ4KRpLJK#cD(6MHWecjI)LgKO}H3(0DKaf!0P$V=6xfi-KE~g3VhHvA(4Wb2x$9+-dfqx z6D&*dVhHjI2V-9|$cE#$d=sgOhd=2bFzD!I1>U{T$1&h=K~p|kY;|i{l-D2GHIl?2 zLex|npWJ8;aaBip)dG9vdP-M*vSX@eB>@fkte&NE>sSQz9}KCo=&#@qBxH6P~&QHo2g z_Ylb$ZSIUT7fKLjlGspBN|QQ4n_294tqY-pw}1Fi7wMpn#dbN$W}Wfh^2c{1w87}< z!B|#G%AZ(LR?^HoLR5rcJSOyhg`E0bjZqgr649Nmg zBZ50Zfr@t^QP*8vc7Rr+JVe^OSROP zGP^4B{?GKODvz1go1|KfA87m1Dr|Dg^7Ha=iGHd+g$YD}Mrwm>v%GbcKwCu_ zc~mC%t%YjdYj%9D-KlM$s?QUc8U_aOlBfBCWUa%GCz}@_>I>ZW8!8t6+8@8iTiAZq z@1DX&Lg3mz4iRrMs?=R3FB`q0>8icA2i-Ib%NQTq8@2L@>5dkER4&V457N%QmSXn$ zhT0whg>2e6cV^BZSCSb<8Tpi$Y&BFrlS8y-(sOf3#y1mYr_0j(n};HopY-i3AttQ_RSna_IVn!~MsKHH9^$5y))nElI0 zgcV~-#?JRxPDzsBY76nE##ZGmP`{f4-`Q}?Pt)@w$tps`4(bXRweK5%#c37aH5+!j z$fqDOHuh`b+9B-wZY-{Udb5^#ICN@eDFuJyAt89t!*IES#OSf)pul_Xx+DK|bBcY` z=U1EUTOP-Qc#T#hWkQTBRa%K=dp$Q1b|xMC6*m8xL+EV9lna74_C8S##^_2e_8O^A zFMKYitTI_A>W;Uqj_pxz#~+PrFQC#N9e6$4Is4g|Rx4|7$0$c9kJN_9@r&_+q56A+ zTqND5?@p6GZfLX49WIY*mUrC{kD}Yeyjd!#r$!`H{tZXC)>*`1)z&z)4FX!iiHNVDy^S0#x}3SuX&T?Mc<9ldofst&b0#jdr8H zus#zvTzBQ?iaAHf!NZQuAVwv!J64uEM|**)gsb=*KdQJKL6VIw$*ce$dnz-+C7oX1 zmA7@;M~^KtbPaB2s3W>zQjBOeuP5gtXKj~Z>lvpH7{c}YIKqNxC~}Tw^tS0?q9M6z zLeuAb0zdwia5!-*706pAvAU2-2WDir%Efri?mTM#&v?L{nM}#GZB?6O5a3Sct$uVn zJDQ)}BW#D#(y9mC=QYSJLqTq&`-Vec&YLFlWeb1o$zO?Ol)X7v>TbcE!8YXA#YS}0 z!()c|ANmg;P=2RZC@6Yv_nF+W7giCMan!w9wdczXXG|)2$x_cT{N2UmBbXp=q&XIt zW=w%7L`kjFD-^A?oxtVnkqg^BkzM<|Aoo09no+(f%e}-r-6OAM3k);{zs|TxF7P?0 zYSQ0vpb7+FJ=*U;q#!B*FQGIaiKNlw8LY7R37kj07lM#k(s)gx^F=V#6d{Y8(M!R9 zlMub{lT{v$Bu+D~-4U+7k@)}3@mU(53^oQ|v5ut*U7i?tt6Q-0UAJ%;nv~Zj2PPMW z%YFQG-tAp(KM54#aY{0EKEAl)!dq3oHxl*s57&o$wGPfLZTTI8F(=uV0)_2ndB9^5 z$H9$+oBCVX-7C2q?#Hb5DgL@_aB)38%5vce3Mm#NXpkbTJF zht-j`$QQ`@?IoV3BuV`q^|Hf04?SkX%?sk%UT1Lxflr9F?2opXTgJ= zI8jUu%!~+#Q+5eVDTbvusT2N^Z&mgl7-N`Q9GDsae9j$B$!MAcM!MyF&4y>N5=zsy z1G_$xbDNXkU zJ|~@5w0aPRElr_cG8d=nn0TyySUzt&EW<56i2OevEPLxbX^kbtRfnxFdx*b3{GsQa zhs-m*ODhLTF$RgVfet*=1Q%w{1W~rgD=Jb`%R2n)ER<3H0_lp<2k9^$QbkMSyTuDLWS~ zJO$qj&}`XdU5J&%6IT+K?ukH$#~yl~NmHvf#N@Ccw^n;JVJ(*5Vr$@+!2W(U?-<#( z;CZV?p1&^wzWrkx2UhJhstFS7iYJqM0{a@dSvT|YV((4B@8QmXx8Y8S*vE!=4jUwn z=*ZXHF?GE(v8pg*uyfS0N|%Sw{Q`_({3k52IQXh@!^bcbhvJV(_l!*Bh<_M{OoIg<7B z0xZpJajD38!_ZlKctCSF$#oa@DoS9|CB1^*3Z_9~W&66>7$2!#+^ z{SY%h@0XF4rr=GwEehA$VfeyfWO&1bvNab znmk*c{M{~bw=tCw`aH{oA!|XWm-;s*sLFIAbP)}q7zK0a5dvFOXUT<`ML+0Vb!RI6=bP5cBZmZq8=w(Eh{BWMs+6Q;g zpZJ*Gcb=`j&vxSvJ(RXUlhcE!7z3S^DJuj5q0Cn57}9Zvw2@0T z`x#pNW+Zmcf9>7(MVnjR_j-Drq8N}mfDy6WUE#ZZyo%E~b_3b$Vq0qT#9ziq5t`It zAZF3_L`0>dWHUZI$>wn%_{1D;i~7FT7t+)GPR88wEw_tbsgZ4(Qj+5uh`{!n4EYk!^QUnZ|+x2ERSV*7V_1(qGc()o}!l()zIttn}vuA zn+NtLX<>ud^;NaK9>sa44iwn1MF^rw!=AGizG$jAQ{FBd`LHU`jYDe7gi)Sy{{mg)R^Krvt&#pTdYctq%7Ls_Z^(m!;Ueog2Lop!0;C@=Im(_==sdQ7a1b_i4l1kf z4nto@;jD#CqPz#77?tnf8?KI6J_9MaiHVemPcV>5V}2;msR@v2kEu);nC?Jj0Yiig zrBRPJF`hKut+uP9PcU+p;=h7KHs~e9s|X3_A*h(tO-A{=G@*`MpDU=c7o|Q-|}Q587qe2tr$j?PLtMjE@RJ59}H#q z?pPDwA->`XMS*Dc{vMT;TCaW5mTO z@l(tWerpOZKdl z5RCb%l-0%N1(dwN#M|^v!>JCW6yT@H6J93GXM#V$3XaLC_@|UbQO8dl5)#jsNXa{W z4UY3HZpG3TsLO@d8}Q~s#}}EEVEn;BBk9guylPCJNJP{md3^{=u_C#_q7}NbIe16z zd0&j0c&^y^Hz>6NXZN|Y*zE|z6g043guao5)Zb;ORv@=GcZWkS-M5xHi|fy%nP z$rA%ypcRQih84aMi&y;RfGBT(uI}E}bnILN6@}OvE+M;@Lu0mSEu1C(QD#N+_)uVD zKdES_SCx!&J=yXit!<)E1{#Cu0;)rUx4^6~^ZHjCs9=JJ`mKDEuFwRk6cYpX+RNZ% z-Czo74e~^pROC4nsC0-RnRXE=0!4yI#9gkm1b71!B6g?(3UK%M`iRCbvt_=Ix`A1e zOhBxdk|(8^&3*8F?TSN^(gl&&>OTYo(Lf4TKtsSSg1-%d;jL^>0T> zu*LP)Vm$ar{&nu8^pWg*0`+t8z@DDqk^DmG`H(2W)zMzYbh*8~iON|)jnKLf;ElWU!?`98z zgh2q&J<<_PIZ~H&@F)$M6iLk=;`nrFP$q2Zy%^!tzO*)DL_GdJApJ!L_d`%8#`;{6 zJy3|&_T}z*Xe#=3PFS2>K}DHVE4Y-dd8FRrOM9WX@We>YzbPa3bdx=SBXb^q{+cPf z%n;T2M>AAW?p9CwNGh>$oDLZ4ML-{lgA`IL{U&@P4On~>PJi*f@$D$3Mc?i3c5L0@ zlwn1d224;-*j^K9XfZ@lA&7U)c6IOq1z@$2fXU~g3 zbdJAbO0mKS9%TzM2k)G*OB5y|vyzhwm|&voPW;4Ltuf3!bWrOr`Zj-@s=mBcTrU~n zYiahrw;ydZa#{y)h&nopELbZV)CN3!iItW4`iSFy#N3J literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/demo/img/wordpress.png b/addons/cetmix_tower_server/static/demo/img/wordpress.png new file mode 100644 index 0000000000000000000000000000000000000000..4fe0d0776c5616ed4d9290f72949b951c8e88a1e GIT binary patch literal 19748 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 +*h5o1ox+7N_U8h2&Ucsv>UKMpT{B#v!oe*MK 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 DwcC$( literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/banner.png b/addons/cetmix_tower_server/static/description/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..98c44a73731b5143557e534b05a6b7feac652721 GIT binary patch literal 88030 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 AfdBvi literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/icon.png b/addons/cetmix_tower_server/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2507f553896c442455b02ed5fa06b72ab398a990 GIT binary patch literal 22128 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 F{{#JqH_ZS5 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/images/server_from_template_auto_action.png b/addons/cetmix_tower_server/static/description/images/server_from_template_auto_action.png new file mode 100644 index 0000000000000000000000000000000000000000..c1a70611de7df7c496985e95696e4ced51642076 GIT binary patch literal 77495 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%0DQ~#4~uCX-ryaW&J<}; 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 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/images/server_log_tab.png b/addons/cetmix_tower_server/static/description/images/server_log_tab.png new file mode 100644 index 0000000000000000000000000000000000000000..01001d1c98d5c8ecff8a97c5d5b865b08ded104e GIT binary patch literal 94350 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%0DR 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%0DdmdEW|2d0wLEkg_&e>*|F` zl$YpVHE(9w=oZRdyrY%zrh2N)&%Q5wsPjhNFLEr7*w8c3VrGKn@nemfI@Mh98t0AY z!fO%q>)!B@D1gy$4XMUwC>efwiK?9WL^RO!2XNJU_356yhgfGax_^xHT(>JH1z$Mz6Ff4ZE`1!Qo ztdBf_3F1en)&+YO#WK; zd9}C^@eT`Qu;Ai)G)HtIc4LVP&ChF5Yz#svIG7@JM|rYF9HvriVNA+zo>Gdqzf4c! z*e6S2ivmmsQF*N=(lu;uY>Yi)&SrRgfb)`v4XdQTFOmKmUGktk+u4TiUo}wV&x%*m14*g|+ZzhFOtLg(15|%9+yX2j0vUw>|A3$N^SF z{7p$mpu5!9*;*T74*dqmu$l+d6^Dq+klk*3tS}t>8K+dOF3o8zk~s>e!gs-(|B6C8 zHY8+r^A$dym|)(bMV1q?+upSDS{`QQa~jE4drQqh8yh`qF2&y|*eP^x@S04bXXrs+KxLHHH$?lK51w#uB%EEvQK@o{9qv<>trP zhy<|>dViPSB&wOs#Cv5vuhW-ge3l7DiBqDPegx)U)nxg&w8U2mYK_~izja&hrTNt) zbI@dqFjsG|<*W6FZwi?AbBmR^AjT_TQ(111@Np05{$uW?Y0+ciDb@pD2&*!q9Fa5SU-@ohe z`St#{Xsi3rlBt#{XVwg0Ln1cnO zQd_d>Znvc3X`t8UYm0TVaxM7Sy&oJL6pjM&O26Jtey5oJyalrB8cdfG?OORZf7zs2 zTEb+X{Y9OUex0$DlqmPi;N2D zq>(&WmiKOPLB{iL>3hOFguE8$nVPn?ltarkCYw}MX_xs}U0LH~z6{-v@SdZ#w0h%x zZF$sCaZ;w5%c)g~)i%6h3U^TAsRWOV_Lyt)N4w6kG=x7#3R=p~(BnY=o#&*D0 zpJ>lzA|8b3f1%h7Z-Z)OT#{5?v`k-5 zj8IT!=mNN%n1~_Zd=>d}OVt?odG+f&iD5{KhXbnDH)~!?$9TEaIN|S%V-OHco(+JU zg~U=I1;E{WIcM{ZT1EW&rJ4#K=MB+)Sc`P4WHBtXIN;$Y9+KB1a+W6*&6jV^hdAmN z=xpR6;ynb@cqXYk&yHiCqvIQdS4>%v5K$sG-(9jpYaNDoE zskNSB{Yvd7;}3>VnLG+Ch(4({?M74c&&uk!#iZJG*Xq8%S$vZ%sxnaQGV}5$rP4YN z71x5+js(PR!AyvovtRvZ;0WD@9#MXJiXZ}qI+@)8-(SA=C?2A#L1-zk(UuN9r8Y`h zX2UFvKap6-423=9CWH`NfD=*4Li%{6lKY3>r4VIKV<;5t(SWk5fFuVl!r{YpEVq$% zdMcU%OeYyacWb|Z?m|S-o^h)- zp|w0;@D{joohATruvBL3X29sDboHwtyn5aZdfYen8%nR^$ri)MA15_%-qzDrg5ScD z#+&8Cj?~&}d?Z0Nn0|;e{7@>KQRK=|Ku-h;5-qDWls zCN|plIFN4Hrd|)`32i}Ag_`f4^9UgMMLGrXE&ykoPHN3`dIK*B=OSS4s|K!M5-x}^ zM5p1PofQRr?hU^U+yYlDS71DwT<3z?SmU!zyi*jY`jn>f_*G2djM44$rDdnfZ}<%= zgoAUPF*b|+)*2`h=r8d|(EG)iKsCz{6hx2D##f*-tCAT*51Dyu`qHJ}KD{0zFjqe; z+aU~dkMNXrci9oV4Bx}!8&s|^Z`fp`>yc|MAqyob-oO<>f^@M4Ti*dk# z+M!epw?*7&#FT`aSNq@Suc|gdBTlh*#;8&h`t*7BvsI}5n586Kt#_YFYSD1S6IEON@1N3y0x(ud-8wPEhpUV!zxbJOfaZb$K+xN;cjobqTrzNuD}qMg#o*eeK~P=Ag4NsX8{XXtIqsJ!Dwr;5Jg zJeh!d7JE#uFagwN$@ffI_@U`c)+wyuY$V{P2BH&7nzd7)G>Fmm+o}0ssf=?RQpQFPji zbN~z%AlMzrdsKJ-If zJ!HMGCo93cG^MK)LEHp54WTBqyoV-4RMp+Hb8ZU_PCrFy7|h1RLUh1iA?{2n87x+O z0TheCv?AwnRrP}EhRkXS$2KyXIIxHC`9|Ok?tL^IOOdaZrNzp`>Fd}Eiw=?w#h!~g z#Tk`YiT7DSMBYYml$DVD3Ufq9!gLyw*k*GK8^AABk3fHiDJ^y*g=D~D8xicIM>XCa zihiAMG?qT`K{eeOhuVBih@G(yduQrnsA!(&3RSHP-=hme@I!{%=( zi8;@!*drwCtQByi@>yf{=hIaKe14b_RZ^}>kAJp)=lm!RCydqeL}&@Y`5-{qyq$MA zH=gZTwg$(CVdWMWO+`??xXZH3hfWazS7O%K`N=|&<(pGWr7UAB9`yKHAdc_JaI_*< z^JHrsE%eU#>bc@T5iGTKnDQ~%RD7Sz-Ysb>vFY&s)MS2PHS$|=T>`&IV%%32siRKJ z7;6`-h4)^FB^oPY99+jOhFnH>i=_6HGI-`zayxa1yYwxWr5RY_KhhK@E!E z3Q)=vuuJ4rEca<&aVbN1X>>u*58o+foj2U zE!hy))=VwCBFfbbzyd9it9ets%j?hGzqS?3koYeHKqHOFu_XeD$kK1nti8LI-LuR# z4UG7f$GO{MgBlHZwQ{A zFhD*uc|v-%GVT05Rv-%Y{@TXJ5o2&U+rwp8#O-;0V&UihFc1!e91X(s#e?fZxq&)) zl=c>>AmX&qwT(!-Nn0C`U_e@+lv-fXHJOF3|M~pk@POYTC*;f>o2U81hLvwwx_GbO zl~6hEhkVx?3OkrA;*mgLUMw;7t5>h+&>x@)P9vSq=jpLCuR}l6wkCgwf3GTW9k=vg zEEKQ9iP(zkzlqhb+u`SNJ|?qK_uj1+Rdi;Vbq+atK|@uH5p9?;U7zmIwgCkR(O#$0 zvOP-B;*#R?*|3PY3{nH_a0ZXe63W?^<$IM3%jSl;118Ub{^z8=HIHZx-Yb^T{%Mm& z^}t0dDIG`Hf?EspSNM)(A3wZL6^z9Z#W9-L#|SfH`R-iM&6+hC#iIH4{rz~=Q?LL>1HcNB4;h#$6vv)YJEQEk@e0Eb%OJ*`MjL%2 zFXM*RBXvY8CZ3SI>Ktm&!N-Eq+@m&ulruvte`F)fp?b8Zmiv9quY3eM1#1M?F~o*c zoNP4C*lYJ}lS+-LALjG@T%!kV;Qq{&0Jn@e2j%;h0O4(((v{-?2)FUKK;A%?m=}oW znaJz8$=USq4nZ_2!z8e6FfkFav6aCj=Jcz2``!jy-r@Q4kArk=c&7gXT&RKdlK3s^ zb56tT_0l+9C_qtcqzM_d_g{1xzm)|Ae^F5u3rCVgAec@wj^0O@LNE;B974dLPgvwO@=@3cX zc4Pp3SbSdk!`Y_>VwEhxF$Y>}YXhuLyBv68QCD$98}MUTkOxhlZK8A;&TlN&F0rkh zQ`#ZgE+#}+z#P|`!O<$(&UTmX+_9c8M;7`ro^pq8g-RSEUfImU^xziGR*DoT9Yi_2 zQXXnoUB&pAfIwdc>Y$1iLEK^q?L%^j|6i0ixh(mcV{p~zB`Q>+?q0mqPHOL3%@kKy z{BWfOg%MGT0R5*9(u=dt2zA{(DEZBgs4bI;x#*%yb44soOwrm6{=w)aDM7rqebot5 zEf*+)byQ9tRJ0BXfS_&pK9OB7IC2dr)xHDYZv|dPw4C2FUCiozjI~B$ZSYo&FJhCU zue*_ju!XmBWC4F>pBO2Y);)ChL-{g;DfYsjd_E8u(EE)R)0`uAnRi@uJjV5xHVWC8 z1tGpkYbqb@sFjej1fv+*G+jEMcOYs)p-o5e>)jPU`d)jHWt(L4eQJKo@lTemY60ZRGdQ!a1ZTixnz8|u?7tC zNc{!H=ZS7^zz^!t&;7~0F^=$%l4a&?72i(HW#$^uh+FgQe_(pZ(Cd|*BdNUn3(Thn zuQq?(o2pGKw*+7CxxTEK~mN-~N^EhI4F+ zZg^E^eBd{xxYuT1`yUO7&)Sl+ve={D_r+)ug#e{f|B+`z^_;x;$cSG1>9-mF4Z}q! zfpU_P@N{A2Hd1u>!L`4=wck8JU>eOYVjvM|J2f?xLoMg;<@K9qkVM2kPDxGuYO^fX zA`m3Ujc)V#-xR|yJtj;m#YIIcbOp1R1NfT->^{F2#qV0uWy2bbrzSO%8;xaeAeEQo zf7z`+TN!wae@s9~ScRSzZj0;v!{i0(Z@E7kLo<%*x!)a}D2`Zq|Gz1D8(>d7e0)?+ z=p|i8TChFiZ-e-kVg<&PM=UG}NZW=L0_vpDFMpoAf42;7H)%mRa0ImuHre+@Qm;sf zt>m4yNf6$2?vS1DiS zrvXIYSK5newr6H!JY{5jR|gi;e9A%n?>-8AA`jr&8Xg(>@b)c3RAi);%LTShXnqVK z5mCPgf`*2MjC#yW-6|%k{J{V4HhGx%u!QWSBg#a5A-!Z$?2ndH)8 zQ(om4M*3eI{-z*E0%e{7PL%~W2-mqzKU5^Lpg+`V9;c0(Bb()m=_jleEFa$f zVAx&EukTZu8&0U>P+2`H~sa%HL$-CJmU zd~vj#+Y>`dFYT!)Wod|jEnZt^GgEGGK4()gmc*#|i`J;|xw7A#ujjhh9t-xq*p-#I z8H)#h!Lqyy<|_7k3o&;cNaHw%;|EFpr^&ga>tB!t zHPer|f^Bg7b!)8&#g5w$T<=bLe*x^5Y^KL6?Wd>dk2Kzc&#TOOr*{}UKnL6l2>Zru zwPt)+ahUHWEG{;}&+!^syIqE-PTKotwhs%@Cg$rMKCqQHNF(b?`+~d*#CRiU*<~I{ zHLNEo2C>rUsdiZvmCyMf_ddKCE@oS9>%cbx>jfBz;4&UJCKKlfibn;GoFGc=!CYbs zFCgeRIs8ne`i)73-aCvpQx*P0JXb@V%gpmBVk*L^yd#$UZ|%I?2YGr& zEpq)fD`d?2s|EkHNMXLfZ>B?U_+2l*Sxmsi<%5)XT{e|ouTTg&uK1sZ_KQB}it@;s zf?2#_9_BAs88l-qU+oOPt^b0VlD@FRKhh@XZ4|F(sI5k{c+(zRQ}Xh$ITOa^fE9Md z1F3oXs|iZxMOmIP9=fflyCTw%AKRfD!171C{djA4doRN&>wn>(UX25A6j-HDK0fh2 zq>Au3ijd86I7^7maSP4|G&p^?+!=poim8@Q`Ff}EOQ+*9hwmDhl*nY`$4u=bE8*B9 zmBQ?IPEP0PBY4ds<+raz{8t*W5_Al`LZ3J+4PwaQ6I9rgw}c719X{37)pdS= zqC=fVbvBi2uj^o%pj91RY0tv)H!g4Ny1` zt`@EqPZqZ#Dy$r-B`)DrPV~My57 z!?O4d9PnWj>+d3;=NXGa{TYeR93?RHl{uGviO>FZpH&e3tY6I~{Y|+et+IkxA{y%% z?NI-BvvJOXbpM8UmE|YNOfOZ6=rtN~LiRP65b(NNjK{{763q3CBW}96g(``WiOb6QN31jHb2O(2R2>8Av8U%)1YzKmYyny z#Bq_Aahqf`74J*1)K~+&SFGTNyOgvvCQeJp_=5$e_kgK!*i+DmRdkX@!NTydQ2tYZ?;#+IP9Z7j4ht)75f>L#alj z0E*g}Y!MH%t+30a=}!=cw=#aqUR#t-xgh3{P;}z{cydk~u&@j4ta?~jara^(_h(9+ z=&6F!eJA!e1BtHc-~K4v=m+-Tz%YQM`|#s9ZaZ~9GE*Y1`2D$t8l5B7_4fNrdps}P z)A3k=B^FMt1QCbk3++S;9P9zSVUZ(Iy-MT2zFy`m$DqFAgv^yN>_!4!=5;fc#|g{g z*X#HvKPA1DUUi})?$p^Y?y3e`1fF8O^a~l%B`;P~V)|l8I8f_(UfEn&@Hq>wKzQtO zpL*TDp|#1-flr`3ify+E#<+k40vohQ{f=ru!`<~r zKw2#Vj-`AIi4+{wAimg{2+zx=6DL3i?{DP>h#LglU!)`?tPnS;t#(HfN0Z~!DEsEI zOQDP19e0}JtWel#nuw67hH<(D0pj?na|3<~VNwdGI2v)NS(a?(U*8{hVvk?K@l*90 z8x|HQoAY7gRzE;aB$3K$Yb00o;VR&Px%**_xHW+qTnc=++mhHh>7`D0g<_ADy%4B+ za(^?^ns(~eRijJTUkK??}2Yx;pC+Mc@g%k_52<7{=Z$RSO&m1@F_i0-zpxK>|4cj&LlGVzIt#1 zB`yy2DGI2RDIqnD;{_8h<{>Q=JJG|L!~^|Rz}1)KNQ_`q4a7c|jEL?owiJ@So`j3M zO3At#c-P3bh+WQ_S+&7{=gSZG%clH^T^GKxJ`idp;WGx2g>651{c5400xye&G;ofm zVE~{VL;8gx5-zu2iz6U99xWWI@7qIa(KqO`EbeFCLvBptUr_ff94*F;IpFf;jLytYyuS%wN44-~U##bFSAp$bpZWvJ5 zc9xs8fd%~yM@lH1Co`Ojth4r}NW_}sOQEYl(2jo%-z485p_g$rpR23o)lg96&q}Ga z$EiZGf#~=I*oZ8++#6D3(w_TOyZSu}>c=9{9N}SeB5Y&j`#a z^EGr2J@berW?wu?JDsE8sRN!v_dxREU9JFYn1mr~`PcV>1}Kf{p+}Plm}qFQCp5mY z{8`nYUfZiYsoJUXvcGpj(H4+ZVXoRn@r2AU+L3*cc}i zoWOi{o(D6gY>)rBCr{VVF@*kxWl-+{zG$A(l1TVB4epwa@s@Ic`>@dfYqZ!Hc$Ov5 zb&NfL?=?3GH%q`-=5n8H(~IIHC0`?9?iD)3tHS5^yfiG!V9dvPYXYDmez&;%fQP zp(C>es|((OUwxu_P7NCLA6-%NaO?;JReqGD4`)QMajzVGfp9}0!OJ4GP0XU&`fz_F zyDHThs9OnpLazkBQ85_XhyP0DZZuC4PD)010A+C=z64D3-H+C-7U(ZY5c_IB zvbXt|ZG231rv5|)dMS}D>2^33(Tl0yh~pTtf%J)xo%)y}2&2fkp#L>|T5TBZ@oYms zf1DZFIoP)-{`fiQ2NJ2Kp4L;q_! zJYgsLu@VlFMZI|H3$@01uaf;7PX1F~?9Hf_Ngs|_ztp)g=|A8D8us<{GNt}BS)q^h%WSksz`ucj#Bt# zggHh(OA0*;B!d)UnDi*1y~gq%xR4t(M4RV~MikwNkNAm&X<*rVk0ViJ)!84;>P8MC z#$$Vi7$=FoNf(k-3AuZ8MDzW*BvvN_C+G>?=iUC2bqd}kgC=&sp-x6#s3^D*?-OPk zu4IpN8^h_8juFSFiH&q46-Nts-#$~Yo=&5q->Ue_k{=nKH@W!!%2%;IZ`8l@zWh|@ zqfY(ivH24N|8>G7&`Aen6V%sGLxVFF&BG-2D|M_~uk>uMn#I@EoDh|EKg+lb)Nlv} zkmOJlN0L^l@Vgrm#YX!g>Y=owleD~aNtbb(9*|uyJQN^Zi;Mxlz#}ZE?Mx6D&!fsX zk#CF$-ohC*>4w4I+7yDIjyGKtJqHtj_s=kBsnI~wnr*DXmy)wRK}_J;A_-&xWa>Et zO!BNeyH0hW4hsu4gk}M#R5?p!qnlS`f^ALMuZE(vlo#RmvwO=lR;m%wwz3;SjqI)^ zDY*na3p%aJ`92V8G``7^8OqG(nStZQq{wg2s&xJ|XvMLo{({K6Ge2MMy)Up^1I*DpXNqU5!O1Av%$Inuy*qd!yqj z)TfW0ku#Jt+mm#N%qOjH+^Nbb1?PL|i}o{PtHtY(wFUHiTxl_)!ul}4I|OO?`-D(F z0)|7GRhTrWp0Q!cvQ3PAKQIZPL+t@e%4>0Nh>r`>0<<`KBBMvLl6ztu|JW9a!^xn9 z(dYW<-oD&4qB~A^S2vX|=n75>xx+nwt&iwy0<&H;P84!pOFX|r9~tl_uvw@MJHt(lLb}Haaemo5_jTZm1j6 zwhv2QrMF2edI~PPQm;L3Wn9f9w*0)Q{NET54KKLJw}?09mTW7*=$tB|;d|rA#p|9_ z3w8}D6_;L@+%FTBx*6wT`XRDA8NT5QAwHhvR*A!ujz$B@af+L-y|;>&O1QE`;vrc+ zJQ@c2`k!(~Numb#209l#34c0;&>^BDu%kk$f%!&dq}!S-WR-{%Zj)-~1h)%Pv|~*G ztf0^KUw1iQ_4OmdXiCHErW4~>Jo*yO*GFzuSxG5BiF)8DksJ-K%pd+SWg#US0Zty@YGQD6P7YLx;V3`^r5>2UmjrT33%NyNjTOk=bGS(5g@V< zLM!8&s$L-i(?}O1ff$;JfwddiLPrs#IMbM-G9t(AjXqZ>Un5#;vc3KC=u4?Q1uz69 zX+zD?F1iX_SQu6Uf65uGm~NRXQU%owWJ?^8y}9@>b_6pBz^ zHQx;DvWLFhRn;~UUFb#mgvn%h%NeL{TH3u7ZB68TO6Bmxflb}oXe?{`@VOE+1;C_d8}K6A8l?M38+ zFJ`@;z;6;7G6a`7I8g)BfDJC~gf{bac0aE?_%w-5r-3lfj}2`#;Rm+27kkWmdTnUs zMpipkrb(T`BpI8>qEJ`@7Mp4|&Pye$#VYtKjWiH(NB}g=7|!7N+eWhoZg^Nu9*cC{ zV80EeD-scyd^F&;YM;C39{fR%t28$7H7=2GB{H$CDD2V%+ioFzo{WI!DqeY_5Ql?W zzRRh1!U9vfhw4w1`_J1)gm{o|$Xe*4@V6we-?6W^mwpbf?^;x~w6i_HrlTT5+W&9FDS2_n6Vv zQd*~at@*%D!AOq;df0+mSf4;!gM3#eBK)OcU&u~7ZU|}!FM^Ka>)ksJ6mJ3dKw0Dq zDM%nSd#&oa6nzVyxO+p#4Yolgk_TG`NtHqMXE!A0>HRTr#7TsUaDMq)$#}AvxW6$O z$#pz%3L_13&1L*$+Kcz7I@xMzUnuIyTc*Do#~BXV%6Nw3kRHOKWQ>PivVq)j+is8- zf>&Y}iz=589?zdNJtlTf?!agY3ZeORTji@>Jp49qR4X~6=U~6gXcs4RHV6aMR8B7C zb6kqWZA8z%Y&?=6H?aew5O$4V_wKosHs*oh!T7N5kjwQo#G3NK&LW+?9KQXltIhG$~Y~@&T*nk8%2OiD#fc5Zm=R zJUlpTGVr%BV6jaKrIpV2mj{POO{KHD&lLnr*TH1HUhPRRUtF5DE3 z+(6p>c$Z)8uA?p~>AB(7=oL*8mA)p}Yp(bLbqfkDwdZdSk#0um2B0VA>jTh;?9G5r zUaeIX8J>j)0g>Q002aN>no-B;I7^8kXgDj=h|w4zn(w4RX|XQQ7u@*R0|?_XfgQq_ z9zXR^y0D2L;_fvaxL)6bVeZCiiD-jwvMD^R7*eX)$+WMYV)nKa4drpvcYF*%vnXZY|Poe;LmHax9$W`UuxB`rmr;0tlV(jm;X0fkMO#==ANU$g*SG+-S; zDQB0#^<}NNrwy%uR1aoW4K~#mCyzoXpgb_o5DFcByn{fN&KgrSdh%nExk%co=t!5O zgi*K95PX5!l3)6wvMFdW8IAu8{a~&_+0q*4Y%Y+5ubMS&;a|r~xx8%rW8vy0s#YYL ztOKpp+I(Ib@DVe{7d*-91YfA+E3NK}E;Wn=tHlkcayP_X;gJR+g*;Z+ zb^gkc%NcyJk>5A*JgG9okA=Bg3=f9GxLKnmj4HnJ(Ecj>r6CTVhjSH^ z_M;gNa-QH`ms88(dKB?v{-z;Tq6O%GSWftM84$I_{a5%7QS{KR=g#Bc--ywlwgCqjl+)26?cN?`ok)xu_W#&=$H2PQ zZGX728Z~NTn{8~{w$U`U?WFOFZ8WxR+fG(&JO6d|KKtBr@B4mSU*>w|GsiQ=nB#|; z^PX7nUr*w%f3gbor63~{aqcQDE3*`uZz%n*GW^%-rZC}Lb6mR+5iY>tEVjzM)6ko& zG5_x(e-c2pnV1xmc?yTffKk!W6t%qyhD(TVJv0lY$cqSLAZVX>J@6ddU0@UxpcCK- zl~75X1vJ(7{C9|U!3r~if!G|>+g67;&fWgq5YM7eTIxIMexc(l5*TD;=ulD-$AmDS zCZ&`pLCg>8Kc#WWrKPxpDLkzI0owjvSa!mf%S3)EeHkK7PR@K4gHG=`ciujFtn zUeW1qc_oFVYi5WgMMIRTijU)`3MwLVK)(kn&hh|Z|1%l9_v-OG}(-vF{RL&w^wq73MJ%8&eA_2ud11dHmOG_pjjS$|2m6 zk(B&`RYskarnE%e9gajxz@k#7915m>|X^qn^Nt-d}j{!1M`r%kV!!vwv@a722h1yZTGFtRjnTf6f10j4vuI z0hpYpCpZlO%k}r3OAI59{{adAX)HpyLHeI-B$UcVDD5ij){lz2-~V@C&fW{W~|Q*c%ztYX}`G%f9wR zlHw3ucg^_Y_A{SoJ&gbNnOZ>tW7zOVdZ005a(AVUNfr&)L@ynk1f6od2b#cE2iLM9o+bK0&jG(5a zuhgJmui0bH-3yq*XLrXak_nASfd7@5Q&|rD`M>YvH@_6hWg-AjrKVix*`uIOO9RqZ z6teN0#fOt*Ir_mXpK4*%ss_DU?@%Hz% z{}pAR^rhOi?~ei9@>ja3hg3fDt)CAB+2K$d6o$@4p>h2!eD!md>(WwICdEXd6>j`1 zz+e%;U{~l1@~9coN)fJ03R54JqPkEpu*5cQk8|8??FQXt!| zD-bh+BBY@geu45@j|6`LGJT%q89a3+Tgb@4dwl8Ul5km~uy&W}o`O(V+blzV6WU~9 zAJw3ON)sx>it9 zkcFz`lM*@5BO&X5-H{Cc#^IM6r`W4ve=>%(BBp|&a<(Q3B;b)yskF&Ywvf%8TRqQf z>L!taGtX!@t^fX4it$513tee(vOvVl#==sY$rkVuN^dtY1O0j57x_^!Ck!C(of>t= zvuXHSzY4$}D1*N011r+c&hRx-nb%f`=KWNNy5-3;cS87^T!k$F#bGdfj>zEbud^=v z*{@iq)p=f@qAWKT9{YQ~ULW%s_Ak)2li?oGV{+v-^4g5#avnWw@@{dwMKbr8i}bmp z=eF$Ysh{o_=gg81=0DRh>c!|00oWAl8cNihdP1YbXRIcobQ1 zUSM7+VNy7LXHu@2sUo!&f41jb6~dQ>t)OSrYJ$2fIy{zn767C7ce1y4veL)J7vW4NI4@MbXRnL$(-;Nd_1R(XY%RmJ zFd)~H>)e1tSQsV=gVb&WPTA9)jXLdtoQ!}iAlGo$ibq@+8OfsUU)d$w3+BoIAqV_qbFCA%5YSVE~2A@&S#+>}S=&ay^EeAJ_&#zEhg0?t~ zuuJPyf+z1O0}FQPzO^kk&i)3Tl6OKQ5Xlc$AbLXOXJ=ZRi_#2p=|Z|4VjUx|znFjk zs>(G}p_v}F)>z}{C{9A8$lpN%Mlz)e=Zb)U(Em1~E4-xII(2LXsmTB~c8TWUA}VsV zI*F=2MNdtgvzHGkDXq^&f&fVWDyJb*um`*&gzXt?`p@wmglwatqM}8){H$$a!ew2^ zG7ic=LPDQr-(He_mv^E{FF7x{ax2tO^6V`3v$LQ6``N5;fH9yWH-Q!NPfe^^jDt>V zHU?@}Cr>PXc3`*so&lzSW4-vG(b?bp6hCM2E+tjfU-05wr|K4Q|2xC~jD+N-qAIyY zidi`JH=)Pg#$g>`Wwt!oRo{G@0$>X?Cu!t`HlPAq?ylA^fc5 zk(-ChG(T5;`DJHU#IPncq|r%TC4KlmUH}!*ow_g@L@xL+Da@AB5Uci#PL4YZdiDCa zsI46@cL(UH6z2_44P4A*b<*_j&Q(dOES-G$1^%6DiPTdgO#d$WCE;Y z>#~QK#h2!S7!=mmb^fPwBvgcRjf;y@cP-`3XCR*Ad**U6o~@nq7M$d*^;MWdC`}Modk%x(~D(oN2%ozM8FFOpJXW38-qZ(c^7@ z>@mB&Rk|@BZp0xo&wG4^7r>9evdNc8bC&QPQX8Ff&*nJiT-h`92bkGgs_?z zr^}z@)7#Aw5yNY=S{3`FYL##R;zU3%YMS-wG}+RfEyvTGO>^J5dF!Hrq}|{kaeIs< zfMVt2_)o-M=77_|Nhelr!>^5E8)s**x0lVV*?2kcF|TTY<1GTY3g=v;N8ebk$CClJ zfV+0Cjsd4lhl6>5Ly@ckAU<9Qggn8uJVZat4&Ly^mep}np1eEEp#_X!tsS^eS_#=lly#gL z&KpKMi?#2@0w(=nlR*X=ZzoyA(rJB?JS=bQ*guFu!6zW-wQ+GCK)V*ft`maL8{xrN zvA7`7RmpHas@=!k-8C}5U#0o^f~@gw2~unNOD4kzazwY=?-yD1@}%cwO`Z4kv47Cp zHC^nh_a~Cp^`6{?SK*J3p-j1$6d)mwOFfx?NNEW%SIEm1;TrZ|I~;G&MVh96Pi*UW z&L%(sKk;S$3Nz0uf^KJve3!&DaMUGwBbEV@r(3MFb73iv(GRGXRF>P(N6RaNCmM8$FvE#6AP<-|?j{B%x)M@tEO_2(Oh5QFUNe`eD@ zX$NsGre`NVv@&<4W(lp$ON?0hY`{lguKRC5ZVo4vs zOcUP<5b^-J?ck`Ug80MtK z*J!orz%Z}FWFs+2RXoo8$J(_T6J)RFBNyVYvRu}Im&qSY2;;O>=j&ShWCaP1+d=VE zEG!D9%}ETaq7uV)AjEc8L?1N6>!;OEif1EFEtJI@w$hY@dLqN+^PAu`i-VP=2K>rF zVV{OlZbIGSB=Te?`vwC4;#l8>|l9-ozlqFs*^`;n(Pe8=MR&^MT)X z#4$osK~>L~2!G7Y*YrjkXR8d>4*^O85nK1?t(!9&JW!x+b+D%(X(E}!4@5K>^{Pw7 z=%eN|8TfiUoo7_5E(9x_mhB5dS3^7AO-b&~XEkUcmzPDplP`b-b$ayLlOuv;GO64# z1J~-0`zLpGULHt|tucjJ--ENNw?=x%mpY#Y)Lu8z?JHbpGdwnoF)#6eUscP-(`tWG zHeT(;4>IYA=OAfpv)Mpl*!f7OGKX^9k{@l#b)W_5uhfAIL46iY>l6Bo>3=7XTiX~A zJrI3Vv^tN*ApZjUGt`ExCQcxe%;+l`bv$}TS%A!|KJq3np21l(WzOx)`{Y=E>tJmS zm*LZ`P-oEV{QUgqq9AJ`oyE`je5~+c<1NaWF-yY?kI0MrqBo)EY40d6gLjvcAJ)ct zXkr4R1GLu%)k``xqZ1Q3YX2gCoQ>y}49eyV$4BL3(5TrHc#pr9mNex9M5A#91_{_y z0RCo~9)1%X7MNBWH9`ArFTBwo=q-Ca2(HqFGdB$HX)kqv>l=i_76Kg^I5;>(QEBPV zP8TcvdR{7ux?XQGD|K!No!vZ96C56xcgM>C4c4oole`~aoX+RMZib7#&C2D1IC;}r zwVOFW8db^k2nOz~51j~e(~svrMil?Ni>z?}>mAYm)xfd!OP%qb%R#&!p>S|^jzAT$ z>Aa6ezMg{4P5AUS9m%GJ8$uvVl?mGQ<>J%@X zI@@?k0*Z7-QOMVLS!@|BZC7+Uofe%kuIg%COni$3S=eKFZu+*LU)o=<8*dNu95lLZ zmtGHmLCpI6SRm_=ukQOPAc1M5DOdwV(6;onGi#xW{ zF#wZzK~^pjKYsXbg$N6W^%R{3`8mg;(Xr)r#z-CYbh5&B6!@guAno;X#;V{C;@FCB zde(+rUq`t89+(LmyUL9ys>*E*3A&^;#p$)8+pk{mqi=SE_hPomxa{|06mh*v5E-ka z$wAKaaL!qK`8Dkx9(H%x#2Mv&drJ7=s6Es#;7vs5EtYvmSutpw+KZXh`F7?7vaX3q z*WKp4Jx%CNY_PKgYLbTch7iyft+&D-hSL{j^guNVD} z3(IJ3v~D0~qDdL7OU~GsqklpT4h{Q7M=fAx28}&>>NceTBH{h1oUec)(=FDz`OHWC zYMJcpaS=_f+I}jbqWReV`RVK-G)_TVThQhGiNNwIMYh&7Q`E=f7S5pBVE3UeU^W4e zICbXoky+7x3@<4sQ;?+RCgV*~*?C9*u}^(eN<$Cy{*BiEHOM#b96r<`5P5$hk4!3Q zmcgbqXy7-#$OF%#+Ee@c(R=4iei&LlZyKvj!CD)4b=(;D8+OZWF2bRqsi8tE;Ee=@ri9lG(+hqIvF7F`h83{>T#}r(OkVT*Ehf0b%Q^H_|qC$@c-7ERyY^$y8a`b`(Sc1u-=){uL@Atg{k2OpfoL&X|xCM zzW>CykuuJylthQ>6Y$l?A1%xAB;4X{68vLRC$7e?Tt1#0af?-K zx(jTbD&qy#e#6oEn`W&k*4qJ}Ua0Tyw$xG0Rak>&i$!C9;ugDJYiyntd%6!i3be?D)`C`&IhOv*J@$S#t_89w)WIV?!xMKIUuu2!p*bsW^G zbxkLoiI*G0BFhgwsd7G}Pkrfzf5$7RiqV$ka@nidVbXqvPvosb^>SOgBB6?mDMit(hb>5WX?(jO+;dNA1o!9q#dB+xU zbgLw1P|ygFTsAq0Y&4yX1O91he?(MP$kOFmI(C4MHWyniSK9RD2rd{^t#aBG4*PC> z`jyIM{rS$`Yspo;joY#3YEV++>gwwD*dlrgvGLU()8~cG!KuvH>ADf&pFuOKbXR-~ z3WLun(~CDHM4(F3X;;BvPefrAV~3iyFyZMIFF2$gte3gD#A(3!*y+xD;y4HG?LdTn zxt$%s0$LL~k)tsbv|u(D|07m+nvJ!3rxXB_o557=TSj}ZzVmKmk-U#C*45aLD8+oi za<0k%j>R^y->1Wv5%ur)26Wx54fb6ou<(QV!vROB7ST!He!;c@PgC=GfwoHi5y0nkABO~_d=pXk9X!}V+H0F)$&U&J7Yduf%8vB6YrDucqCMxs zWKzjbI~Cf^^P4s(k3L>6w03qqsb3&v7f!R=DCF?_BHpkhKJITcYVqF&2bqn3BHLBc zP}wb5X7ile1-kdA(rMoEXk{Z|VbKm1|3qHkJAB4cuje$bqQO(c?WCeK{n>lS zPN9ND=10(CmQScqd(L+}(rTvSk~b3wjd}tNo%t?IdOT(1uFlUgNa)_4&$) zLebvdU!A+b+G&9^$MZJ!Hoj7Cw|RV;RvqLX6y@zcOSQubL;th#9eQhq+-m@Sg3F=9 zDHYY9S4b4tRsn9o_kNQ4hcanBKRi5KefWf543{<2x^0`&{jy{8MqLIa0z!L_nP1`Y z?BXJC-eO^z(s{7wf;97n<08HGu!HY4k-l}0_*?B`1|Si7wd6Cs@u3do{zSFILDVU0 zqs%#Qf4_!C3)SgtCg{FOvCiwo8GjYy_Wb3BlAc#~C!NKD{O!gDc_v?}BHQW2iNtq> zQ9 zUW3{7UXJ!b`)yLl#_sc-(PF+8pc@k!w)F?7={`Zkycu)18DXG|cY=xuQcez*59*&n zSqVWpldSCQ2rd6?9wqYg^^<}s3JehmN$je8-wA7CgkpnXKc=d6@M927Sm3McsyeOy z#Ob2ILdx%Nvl;8ooHdF+eq-4&0Nv4*WSyB97w;BL5;AYr3Rr==Z}1H;Gw2k+65 zgry86CXI@n9aQU{<-QXviSc;_lPfyZVTQTriTu0vmI+Tb%SDDPZX!^G$gT*});gd@EbFCJ}~SwX)+G zF-dJ=#1O7EF0x=2gyD96cx5&vbw7uA$V)fZl1{!`W?=KS(STfB1mC@!w(dn{r^S$q z{2;ilEp z(NW-TVYok5%4OeBaZ#{Sq@*W|q)zv8`cut!ylzx+pK`_4m;lwd)BR{ax=ItiVSDQI#$mL zJ^iLr+4zdk$KWowx5(PtE!Tox07NzRv1_wy&2LqkH6{!{j;S9AhkD`y+`9~e9yKY_ z53cU(Z67pR9MQZQO}P+iXVs@@F)@qzo@sEwR`(6LyAff)5Js7zs*b=`e0A~|@fkbn z2FhT^?8DIeVGJdOfHeU%p_ zXdn)xw#BO9GDLci!92hltO)0MGx~J)=(osYke-9|6$h$q0e%(jzy0CgDX;TYues0TR*BMEH(GFgqQTJeSu5tTLG)inEpWjW%rkAHUP7LO9{G)y7Kb2=;BoAC5miROi1w5S%?v)c2f&MsHr+ zQx1`$ch1DCsCoPugNA~QG zFFP)1gw*mMlr_SFAQpO@;qNhGTTW7J@IaE{+!7!`u`Vy3VBY8zzxivdP`2;m&2`4y zB2r|L(`??_L7|b1r2Z<4j8U}H^`3B5ra){a&w`qg?5+x^rgPi4oQCy+=i7ly2!=B8(kE-Vw2PZ=|85fPwjzZH^5plE@vKd@)?R zldJwgH5no>>xwKdwR?n&I37z5cZm$Wnb{DDHT_avhT5uq9C9{ z^Q;CqW(;rzx)Of9xDsc~A{F7bJ2xU8oLl(h5V22l64Lo^lg}fBi|wghlHS$OgV`u%k{G| z5ltLEb?)|%yxt1y*A}-0_!ivNtU0_vFQlZFi443XY*zjx%x7M*wdXr2saM? z=Vgx+4B0cA39a~Lqj6YOy97jKam(Y*0ta59-zf>wh#|}?m-6>BdtF%9F zI2?wCiACK;vCAguq96Wq<)8of;%4)pT^e?iz1uFZ;e0I}cp-=JS{C?Jt~WI@s8bx`|D#N`5tfxdL9A;s|Jb8%&@mPY_~Syt38 zG|BwK)L=06NesqHHt3y_Ul6-l^YwG3?(dHslAZGLqob4 zdvnN-i-xhaV_m7Ot$|QE$3HR~eLooPD??fpnlNHoFj#?kVQ&}%#-IfS-&An1)`cM! zO{+UwMnX1ScxI$BT9RT!lTp82B1&>!f6ZJsYvjUgnSY+gw%E*d>cQrA_A6g*%Pf7r z$1dQ$LUEh*T)kgGQm0$O_@Bkt{{Jp!>NhHbb`?B4e9y7JN)N*eH4Tjmrijq=zqk$j zFBG51NT~u!>02X8ZJHzODWvm~(IyDJSw55PrpTdQeawc$cli+buS0OOR}KzL32T$? z*&}PfmNC!}sJJbo-(B;CE@_opo|2Ys)1_OCNH&-5hdT{D55xb>!|-c?ygZ0!!<3Yk z&Q|wc=XVIe@>uN!|1u$RSXHs5LlX6;hv;>8Q(H4}0|e2Deu?oZ2{qiIyz~E4;=0UD zooUA^@^SEOB-svg(|M!4I_u>N3)*a^{AMjUd4Y%1=+)puI;%YF06>toAL~hzP7DGeOCx;m;Z_; ze#kFdPJ~P@JhE<%EGk(Z-!R@b*_1iXn8T0elXAa&d1!PG#6^{JzwC6F?-x<7wnpra zG|po5_Hf6Of(3x9i_UQpe{{7l2g%RFX{f3ws93WmRBB3+MeB&nJA{47@(7$U)g3xl z=l)~-p&A_C0Q|R)Z#jTp8D!#%2zp8gqvlJDx}+=MU_QG-`TX_#RwQ|$w)b$Bx-c{& z^$pL$1C+3GG*`Lplnwr>re}pcY`WPqA=Wv^7%+U~sYzq^PajsZSvgRHsD12gGEX+CTvPO`6Zq~!-Q>}ecF{>g=9kNAMNHno2^1BS+rNEWxkQ*{Ir<^X-F-$h*h+;B?6%D5JfT*L;eDw z>A^6|oMeRhC#;k^3=l!I09h6w&}ld58vdh{xQa{Z>9w&qoNvRSA}={M zepA!@(bIohPOq?m+0b6hMrTyr(siG;HZQbPq4_exXvnx+`k@pb*Tbn93~v5H@U6N~ z5;SaiAotXy22^N>l67_l=gM)1eD}WM3YWFh#r>-84eZDO)V|}CYF^`u=-Kj+5%{?4 z11`0VC}>|Y??!e@#zMmc=6U zJ0?S6a;DcZ>@GJ#AVaHNwIIrDIhM)mxx|Rf3kQadE#f=?U2#Q(Z|9EWs_b{w%${lm z#1D=oix)gxTBeA9dq127`m!-Gg-J?Ex*1`b6;@`{jiye=L3)me9i;*R4-w|dZ_W)L zPDn97q@GdwH}41wbnNj&sw>;aaMLd5<9N_HOseBP0U>NKC*O7%O0I(#sLp1a(fRD# zC{Yw@GV}0p%d|tu@`bQQ9_JwdX{iGJY8%4y9xe(iKI@{>=6HgYfpbb2t^Yv6pfV6+ z<=VT>pXG^tZm6mAjyi3vNAd^$rg-Z;OJO|9GMv|kxIfb|$Ff@U{`^KcK3;}QZM`qT z8b)bmPDvWf+=-+!>PAy8{ z+)bdQ-A6azqqZc_N`4g6M^s`k^R%g2IM5lBo$-{@67uz2fd%le*Q@Nl$^&?u)1$(; zScvK38yR>f^h!jL^8_!@b3kz8bS}uPWynjXqoKTu8pN}JzrIa60_gwGa`|^*T|qPi zSR0|rvc1NO;O~!tm!+k@R_e@=TisNua?!>&*j%SNAokM3!>LDx#B(>=xfextKtLG2g2rt+d#O9V5%? zri3r}ZD~61-g|p1kRk$fL_=(MWl~hn@z%<=ZxR5mT}DaoYq|}h6Xv2Z^1b!+LQTzo zMr&t&zF6TfnEE?9ghYrPB84~$7r8th2*q`E>Gx+eKW|Bm#ZPA9QVeqE(L{7w3zc+r z!>Z2a{{&F#)=brto!D2Il-L)G<%jrhD}DDjJ@82EV^EYzx2@}o>{(6s%5l6(Qda76 zA0+mk@2Olu-t3*kR{Y7FgBQtf1lu&A*qz3pI4$xaV%OQ6GUM$?Qwg=*n879{(A62% z@fsh+8{1Y{p5L~qD+kpkqzXhsRy=l7yXN9{^`HaTWp7Fttr*(<1FZ_fg4ukV)b?;S zFr~ymQRg7E#M(=ecb2WDB*^lvNh~J>+7tN9u3GrwD(a{R>9iJ1^^w3#pRc=>{m#E? zozj0ZS5LH2ATR#iO0ouyKA{7~d#9kQ%e^7L#gvcW`9~_e#CC#4x!D^-#q$|GEx;A^ z8Nx+=M#7945Ww>T97e3`E`ZwgK!uw9hB&F8-!P`Wr03oI6wC{WJ{R@%G^+sHsBF|8gtv3?kINWXCIZ-!*NPo;Wi!Vr9On=yd`W5@Y{m&9cOj>g;oduinu_gu^iiTEoIwd4)mK@1j=2X3>Pov z(Q8z7hD{q0=b@cDLOko%B>3P+pc7G&(nQjv;&uB48ZD{qqWy?SJJOB|zIvw4KMcX2 z2as)KWKn7I?3g*b7p!}bm@-c`4ggAm*lb^Zq$^=DyH!w2GaHy)Tf$3p;>!X1Q%!?6 zXP5yNY7c@e=Q~?SX-#eMb;!7K^Da!vbh#!+Qt9UMab2MIfl6az3b(>hfes+B_kq34Ed4ZPDZO&y(`CWH<8o1qlv{O4;o;4uH>$ zbokSi^hnbiQMl~IP&i}fNnXyGGenKWX+Z@-Sa{?v=mr(W;)s+l(?%&TlH*HW34Dx` z%4jG#16&OFr#34cO#UYy_(u3Hx@F=zRMO{vXHzst3<73pdyoDCN>OB=(^tjRr^TmB>I$!4N4CSU6Pi7@6k>B}0V9LwuHq3H?fTQ*H`KZps5QR+k3LOID!|jcHnDec zpxj5;4wi`mGdfC!N_< zH?b_~>LR_-@BzZ`^QDwFot7*A6;f4J*U?h)z@7)aqHq-{O6ok4f+`D1$6uWDm(Va9 z6YIlV_TLj2GA1XR;DVlHhSuZ0f{|zMX2~&aTo+}DFfEQ70(UR=&gSSW6QkdjMlYg$ zinR%BlJEiD3+=gf@$Xsf7oUFrXNMvCMGZt>x*k!px76}oFGH@pRDWXyHPasO+z*Fqv!9NY%bBvEgK1BT5%>vzsob6 z_s7N>Jub#l985(=Iz3tdf|Mog7qV2(Ys=^KsA>xq56*40-6)KZpTUH>P!5A(n>_f5 zV=2k+A}O$(n32+5lnA#r!qjOQ=dUT=ygh#Q)Y}UE$&zP{oG4ZEBy{Gy^9 zayC^&r1oWo>b26(0GiAzDyIiT-rXtcCB^jQrUOb;u%XK(W+dQA1CNn66Rg;DNW*|P zBJP4i%PmlaWKBWK%gGK|q8KxTKP?~8O=T_ey^lOCAA_r|Mm9;Wbd3t8^Mnmyv}v0+ zTUBZXr2%CE0i>+9p~e{eQ(bn=-|)C9GF;;6d{#>T9!3C5Ku3eGL7I(Kf4e?eq#;Ce75{EJRIr;J)>%hqd!10Zc=U|;y z=p9`X#EKyYIp&LsMxE02^IRKmgq)DFv9WJc<3(c@6p*XU83K5&T}s;aiNmC6;|?Et z$*$fl6yNSo6gy25=Dcq^`qb!SpO;<4cSII>26^2HZ8>cV_q?~ozTof()IcN0o(c$& zg+o9Why7G_l@|y|U()C~@PyXfQUn)22Mdw9BSp_}3y1Sm4`kZRk$MP+P&OH?YtP_9=O!J-fscZHhgaC`yHBIIYSw$Jz~d!$Ik|=_ zlD+6TnO7BZ!E4o|c>lv>;>=qXAhp#do)}gNJuclyPxHppO+{nsKcPwu+jm_>Jg!vb z-%g0PNtgP=YrBS|K&_IPLcC0;RRh22gN{O4m`UD-swD9=o$~s|xc|D`etuqTxamsB zcsQ%pnuyRcz&W4?TyAa07~2pwFcq7k&)95Nu^Ks<>!|y$+HI^y6M>>Lz8;Uq=$0A(4$xW^(vO6AykXf zXMx??5b^u4MXA%$urD~Af`$Q9KW_oF()T3Qr?+~90`g!=6z`mHGp^5ZT(U9=ldCSN z$~sp(C4L-|_MM9bw~amypohQJL@$d@)$y3RWgQHdsxjIZojyao47@4#X@(fw)TL== z^OAA*+J*5;(c`jWx4}4nrp+mjUGt>k1=0>@v?-6AzqKptz9W`+9ph8A?PF50-~J3U z-fizYm_{qrR7pa`0T1>i(ue&DuJg%nsljTM(QUGa`s19bdj~(9Na%SegrE!n^4wMC z_1RXa`87}Ob{gw8gGH_yg3k&Mu2>HOHU)waFNpF6KSQo_!a_M1_YGw>S+(&$KQI7N zSKKw~^wA&#a^m9A;*mG>XqaGe(Ldj3u}Z^VJM85}FK})LVrRK}DI#%M=37Es+?_NC zBo8}3xX7<6=&C{x6=k70KH^2=vgl#btsQn)W6V+R^>J*A!*OuEX?+S9S=37`Pfni_ zndF>6g`i2c=$CL1A`dIYEG!c(IpIhC=jl9Z%>I_;zBnFg?%9R?e2Om z{$eO!LP(#OWySmlxhyty=gZls{n=u4A}YzV6o$27WkF2Mp^{nL9l;M|dsFQ>&o!q- zgc>^`4(Sp@Yx=74GTA6@d;c=cX4Ds+>%Gw4!QqoywN`uWHsn^FRM`N7IyDOufJjPj}i3Z<2K(_;k~^=&@)|wxw5;fjr7!IN6(9q zM%n%@fsBy%wvf4No)d04HvV(5UOV`oGrOF2!d`|Mta;Qh>9y&i`B?RMZ&I$ld%w!N zq52Od(wTfW8bEiu4kN2E=v8&IIUlrNEqBmQRM}$2?d-6^tRqg_OEw}O?ScAJEoKyi zYuly%jk`%c80iG&Og!WD{Qdw~-l!^&9Ja04^|g)`XYYW|0~ZTwf9O!bD`Oa=o&eqS zry`5#7@^z#GV)&gL*i&)SZ}`ErYA-I5w4$-x6MlDO^hE=P{hvcBq)qNw4Yz!(2lMy z%*YOC_yFQ^OO2f>R8E~A1SP%0s*Y`+$2nv>ZsjN#R+@ZsjrcTNE`{tV%&)|ii>l30 zcopNzqO20;8;Xcxo)Hyl6ET0yhWFIM?2eS$mfs4-8+r5LSljJY{2MI5H?mF&~ zw;Gond!A45B~6Fa%bvroRB3l8vO%lZ+=HIpkCwH+IY6DoUkyff1PKc89T^^MZLOUP zcTj7g({`I;>5qb+t+HxmMWDG#4jO&&cDCEnj);XpTz}deM{NJVrKP9-ly0*<1T)2n z2?%7fJ}qo8Lcceq)ov1PqS$-y?1UZ{ZCHDJ`(cbNUHz-NIz2UJxsoncCXG5DIyF}K z*OVDogVmp);RRbu_Q`~J*;MLyifG4ekWH6>I2`@%F1lY5&9SyYQqkvAw0GB3B(`WQ zd=8;!?H8V-mrXhG%4Hj=_eyK0B8jUPfl98*UD9wLNb-4##)m+=)O64xPZjFoS?#td zK7MO79u+Yvc+Wh@hoQ$gwcGEf4C9`$weO^0=S8rjS^X_W=(2-;fFB-~Y)*VnI2Lu!h33XhHPv{qkcKAkNd3QPo zd*n%s5c$yS(;r}p8E?!Wfe*~!yLAFdbn2mPc#ij4*Y--+^ZJ^*n%^#|2!)D(5Uv=0 zdm10`)6rdljRgJqiLLCBbN$3UZ6z8*LxMldE~x7=MX^n7pR;LweHygO8W2dYm8yK$ zh{@fRg_FEqUPAN!*!FHY_!Q=9ZinW+y*((kxk`|@BW7oLAA&4(lruQ8pH>lD+FZGN zj(eJBe@kT$;l*kOszHC1B1a}jMYf!a4qZEIO@;1Omg~Jc4h!Ho8|LU0W8?FY+&h^~ zTebKE&jsVtTFLyBs%39xl+W(cx;`jB;*S-eR8&T^wBr;MsC>6rXl0yFJ(g}fK0XG1 zoM+HcWN>3V1fd&APsTNFXm80*fMlk2V(&GJDk?12lW=lFpx?b9-%nYi>#!TEO*xcY7-gh9b%Qmp#B4-`!dM&;<3`|Nc7ld%Y%K|#xm5#e0%G#l;Hoj!ib zs`Ca$t_8pGBVSm<9e}fPqv^i<_;Zez#9f~0+WmGZ{tyo~maD2HUN4wKTSPqh#tuP% zL{+YBW7wTGPuuZStF46H5fwkaCv*X{9eNDM!9#L9FHBdvUas+(pTGLiYSk;Ct;S68 zk4GoEhYezQsc9^9^xk-<0TIk0c)Y5?2FN44FQ1)EwZkLiEDVIQSBbkqvXGGN-Ugg% z?i%?3sRVBV*WT_Kx342Ewoj&;JfnNmhcctQ?H#rJXB49a*CiqO$5NZlKnI^|JSz4( z?coe&GWM3#67o=X%hTS!vy}d%7@?w~VjL~#v$8KZW zS)?M3%?chSVHE2vFl_P=K7f@j^3BKkQYxnz+&cjo!$^$D1}O+@`#s}(QE4WdTEm8G zM<0PMsR;5KN&kl(m~70*QAC{wgHq?WTa8?&OX1&5}{%t=rZ_FIifOuDUgwGGBDG1vnO;&3~ z1;IU=+;Z;w@tHl)csCyTJLq4FnYPD)=)8wtO?8f!q8!=M;t6hsvL=u%G;R>Vg!IK= z`HMnFAu=w(I!~`)j1lOor7Ls-A+U4-_-1-5UV+Ghz>5cdX0uz@Bd>Xv0tU!fe=eKY z6)kTvtwPzPxU^291e=a8xS}~Gk3IQ}JC%S_SnvO-!kl2>)111PNh`|h`)B6qi)^E* z{sO&Uj|Y^)iTsozA?v9fdFf`Ed9snNsX}9-qMp3d%~OlEgIn;tW<6)7%-b~+O_*`z zmx@5$5_7~S0_@iV)YdyPoPxb1Hpy>ezYX>H^q$u`A=!cm(8~B@DtIu3Q?1slC#3Jx zqL(MWu^@%Wd1^g(AZwq$*U$01ZYsK8D%5~XA``-W_{grx$tE1j!#?eH4mmrxl)3+> zz;Qqt_V@QMsp~-3=0EQrI#bIH3c-WTeV0FpQRF^F@8l_nxmS9*nB(-2+%3-t=Jz1n zO3_wn|0uM4**5v&b4bG0Owar9eVCfo*=3D=+}Ooc!nliFPymtvspAI;y8yk}o^G7O za=oI(eR>WF4iqcPB=Lv!ZzE_|y5)b@-grWk zKgGqi5O{34jrF@%4Y&|DHx71&D!BVnaMItaT9y;=o=5Y1O7OPdNE{WCt*1+tlOmQv zh^BNeG;4DOzK0eUza&Sz4d&Nd)SaJi4Mct zSq=!1VjY`>H}h02-Sm_xeSd-}0Sz=uQlWp$FuY4&M{#J_56Z@f%l^uOZ7v+K*7Ga) zcLx6o-iqZn1p3%f_MKddQrbW?!8~hu&Tqe~c(M78k9TU9tIgMF-q`vR$qe#*E9#>@ zZN|&!b&KrZ_&oCA9(%(Z@xh$i5GSdD{pmzgbST*zs0@pH(H-ZzL5j|U(fRjk4-)r? zY7$o%O{=DTUmFQy^DnpQOHX$q(75i(ZzBEJ#3XA`?H*9V`bHb!*e=v~u6*Y{4v`*o zhdbV-;vP~`;<(&=!tU`<=0wG-7YEwonz4t19LrPwmwgq*TvQQ9RtM;PI>6?b)X|$}KO}G{9a1*AFUgc*x|w9LA1+Y&D8}pdMWoRjBDq04q#BW{og7roXhwMc zE_ZC@R^}D$<3Mw_MF`J26%T-o)QPJ5of7a7zxzX+HHvUhFE>{@?hkL`;c8{*nom2a zUjLTuF|0)THH*ZlOQ>*gbfUOGj&RiwAU~xX&A4mUKsM8{{LH9@H(v^&sqxjp%USJhjlpIHE;OC&kv<%AIo#5b}KV^S-WbeL87j z0WN~P3w!YEOpyz6x^!COD502Yxn{y1X*1mg7+mA8-GNB!w$br%r$PxGFQSQY)HPDt zNB{R%ViE zBzY^<49Gdh>u)@ck01I$5G;PS`k~~tLG}G0=wy-o$J_PqBG-H%Ug6zgH-);me(L0A zw8fL-+1z)@ytaVb!cHq-3A1;)!De$*6XXu9XJsE_i9aEDtDJ9W_+7Fh7j&Q3oaeg31g!fdxlE>m>z5P9|gn0l*#w!&^{xX|D( z#hu_@+`V|v;_g-mR@|*faVy2W#ogWAX>oUVg8u1y&U^mvDmTea_Rb^gnOQRf)+w5{ z@%rmHX7|Wyr-(7~vBC`^^z0fo&w#e=r+q#DN9fV@@Mwx_@99SIPg8cW$)h|D`jb z{MuttXP-YA>UKstIoz#X6UAEN_woo-?)hB;)r04|#~aLU33Nf56$#DASTB3UhU?BY zgI?HH^D7rd9b(_n^|9wig5z`Rq_!qSSucqz`bGp3{C&dvEHfCPbJ`}AP;8#!%BvfJfx$tDc!>$S771M(9TB`n2|M|`@>g&^2!osFb3cJz8}Znr?Ws~;Hv=T7 zb(m5aVtM6N=hE@)ohXzlIy#2{%;oUK&<>5`5eM-AmxsK(JRU4D(9MwBoZ~_6#aU`mue^IxBf$r*#SUJ&4%&CPXi?*=M=@dm_`(;U*r(D zyMJ>)f0lu`7+frY=Bm@8mFc*Agy>}su){NPNIZ^{?76ltCXF&aFN><4p*$Rkyfx?{ zX7o3^1P;^L{kyekm|z+K>k^<-xa?L?DKL;aE|Kk39Xr(|7Ljj;i`5yRSa+AjI2|Zi zWmNF(m$;`P!7ocV+UOGk|NQ@jwyil}q4FYG&Cj2E0i~L>Ad=awpmNFrB)rpsIe-Ej zM$s?J5E1AB1x7L_k-BR?Y4EF=sJOGRAz0E;i)(K#lSX8gkFQ?m#DUo*!?AND=y8K~ikp zwAT!?!L9Vh3x^ry8SXJg;mB0aA|6DTb(*-Ci7S=0_VK{=OzMbzf2OM)ym+#Q^iGdl zDU3jPyJwL=%MeT5Ib;4)WG-rlNeqwaMR$@7#Wj8*8`OwoK`VH*JGSl<&M6iyQTaDa37Fq zg$ko~o8mJ)Zy+*(p}IITSh#x}-z_wT7JEDN>?$ayAebuM?{iI)sHRLaUTZE zDrB$GDR_6)U&u-0jLAivSY$jogcyO_TNWdM>W_cRU=kw4@GXr!kN)R+1SUI70gf-> zLXt31U<0n0sSqeiX3lA5O*Musxbu0J?ISd37P9oGV~?ksO|M}LiAb-}AsG$@Z=))M zXa#aD9`au=0ELfo@Tde7ZN=uNOZ79I{{E1iMCAg<^^T9=4KYusEnR_3EZIlfIr?aC ztnN(OkB)0XgdfenMH6xkD7FY3q11b|(8B8P?)klQU>^h|S_)q7lO^`mue-c_Uwbth z7T9!+Kqn+D`_`(om&3c6@A5`Q54?y)4+@_X*9$#oUTW-=dcwe7e8ZG`9t=&;hg1Va zJoFOS*4ZKy$dT`&^}!`l=%NNx-sg1Fn;z$PN5^O0PMtWmG7rT|SX$D*vvPQzxc)np zNnkmU$uip1N2gR{ZpSnD)n6~dMTbnnN%tmji}*G1G`B1#vsjuk4SxUry@TKR5W3?3 z_5!L(<1h<@hCK2^(J=Tw3pTSw!*-SJdw@h4a%gO?$~t7ieWxTp#<^!awe#769~v6q zbOCY;zbnsYuk%6;{3>y!4K~J7Wv-maMrG)K0MSF~nZiWr%>BD_prfqgT9E zl=Ap-`JoINGEM(NP%yvYYupr8`=)fFGnW*IbsuxlSg?eZqSig=RE6=sC+4$Av9h8t zoSQQAp9ShuxhdOyILb-Tkh9Nzuh-e5i&ojET!gq`F~WD+D_I?e|5BCTZjIh({^nD8 zHaW#x&r5n-excWfXcTzhk($wFsFY>VQUZ>f)5JzBdB9qDg<=AL9;63_5 zm7m)m^0?bp6v~_+8Ib&iHb(dM{63A-A1!Ia-j(>&rBzRk828Mr7_ZPH>_o*3a6h&F z_~{}dU-!G2B1UEn&LGz{!UVYM7G`VV1iHzPcQv2{_FjcCU!{avKkCb$Q*K#1-V<`z z{2SU48#BEJRjTa6`Coye556i)5~ttT;?me_tllikZvTU(oWR1ecOPvkW(dpx6u4P& z#hUK7q3I2Y>LC99ABr#AX@&Wl)Yfw}J_{4=L;xr=|HsTCP}MedIJ4)8Ly ztm&3e@#);<5j1wO_Z5r9u>*P>3&Gqts;f{jbKxJi!amki@R^9h6so}U{BS*JHxYk; z!P>owJsTuQ?Xe!Rc4*QggE+;CT?o#B4PuMl!Ixi1i=Z;2lfOE^q46B9pWtHSe-oDL zxGe)#v`oNYiemQ1cIs29V|4e2Z`1Tp`+46WkJS(HW?{?HryZQf($r6|!sfgE?@^yZ z7?vYlK$Ri~qAtUKcAjGy+|$ebM}K>)Du#cLz>3vv{_;v=99?ZS0o(Tp?^lv|LpWp$%M4!6%-5z_nc<`1A1OS7c$9MtI#`+YFh>_ zvProu|4x>V2rlBtWb5GrW>>G>1KR>=bI^qxI6YP_#NO%=x6b=^up)x-jD8{iUCf1y zkXvHfRD`?oK*pu*~`KfqTE_PC_zB4{y+MEfxQYcZv?Z*3o)>B;&7tk8$VYq*~nb;vx_Jb)Q0vC)DC6mM(rJU=4O+siZIaS+z zAl>$*GF|2>sm!rIC!&~2m%kNAni2Uou70K^sk@*_CFMDZT!j%e(wd+I+VDxMlp&~E z2wk)`xZ~Rk^;BH+64T$Q31J|F4pjbg&0;a zR!onqmsqFWi`(!N%N=9r;gZ_Dgm zUin$YS>{p9gBC=-Coflw`|Tz~vgs~Rh&AkiJxD@&W(#~`n$w^db#(I}G?mCi#~MIKC9 zWrql6U9-f;s?clX(TN=`U}QY1iZ#8dsArZuta_IaSVqa8ST!v+r5Ipwcp^QEDw{Iz zhBYNePUof1AC_H4-}{Qba(MBmknnJP+x;5D>69>Uqb0v^(`(WzDwMNpX=Y2Pv<+kA3E)R4mgMv`0M>Sg1{ONeRk1$O?O3#~DdIZAMG zF+AOBFuB_sHR-5`4f*;r!|jq>f$osZzZUFJ9RV{R$Ct>`M~O;T%9ydJ32Ui0oIDC_ zwyprK`vZ9E9L*!T##i*Ho=E88s}q4Aadi`5g2DWztnxeqW}qvF20j;gTKV-1P@hED`x_i9u)WvtBOTZN{CeNl{Wsa~=07CXg6MQaY zs2l@(56ysHSIO9!ej!pa(MV%ue1zMt=!5Su3VsGKY}eH{IGS2Lbu8BkpANVBb{2l! znCN~NfQdtgThNs&w)ILOCM6!cZikIeLput+TML}K1Rrcfnkc8|^EPj3%W4vmGw~#| z@W_)V{)p&w3ar;Pr`}f1^*oh?L17vX%?t0?D#YyfA3z%P;?C@!Jl`kdn>REIqXb4q z$}{93;rXkd4K55XWj`)9DwHlIy0>!uP5WvHpz^SwU77&c^@b@YLV4J~A%@{7ub&zf zIvE1yOllI*5;123>QMRy>Po3LXhj~nc7}un8QH;wX%Yx@%IwvVUG_AWYs6HOFK|H! z4rK%R8A5OJ5hQ*=Qgn&~hf-5toAgFaEc?H=x#9{8iE_KU*xZ>Qp0ENMo>;cR58rq` zQ*1hB*Wt?BHl>;qhhQ^1n~2Ie-#W+dq5wahJH=~>B+Ked{TM=hz_E;s4m{kP+QcRl zl1ONO`nHiEJ=|*%vB-z6UC&lFi$qfqI!~5Z1BYw&?BBu`o5b%+w5zyQR0<=)>2)w zRD*qamk-kWK?m=LM&i})=0sj+H@D+T&1iOc1mhZQ(7li>JC*#-^J6B+zf!ymGO5_6Eq^_S;WG2c zMSAP}3^qf&q8GDLRhKXk;%-ne?wX!a`^*qOqlCTMrb=a}CGUDWh^=!~B(>%xNi~{8 zu1y*%tcEiY($x_Qgh%h^4DQTor->Lp3wlqOAjFM6X;4*+dAms{&y&#y2lH(*NkqmT zTc_3lEyiT#!6tgv#*pviqdK`RXw*8(NORU_ee%BdhxPMKCfd{O!)D5bf=s2&&7lt} z7u(R{D`(Q=x^%cfI?-?Mr-6D38d8j+(9G8cpKLCX@;wfN-W&G;o7Oo;q*&Lw!x-Iv#=hyL-kN8%L7FTBaugh%i1*BtDmHDrVO* zHWf;k)*c;!)jy2`nP#D5zDbx=Ct)WGeeFk;WvCRsAI0?ITf&ep8%h>^g#IPJ+}ib% z*g@BbLtT|@O@eV(Vi}(f6Q|4seiq z!U}m3_C86L=CwhLo?-C-@G#VpOr=n@_!5gD?=08VT`gqs4Jbp(I=s0}!*AUL1jD9o z#Nae;wnLZeOa?8(D*7%{hVE}g17|;|ap>sz9bn8}<^retA>edQfNr4JQR6}Zz+YX- zPfN*}#T2m)4ff#&P1WCC9NXm{X)v`X)JhAv*m%ANV5g{eu@Zyuna9$ro|M^J1r}Zv z6sFF`cW)rTNk%N>4pF#4z%f;%6yC+I@{Dy{qi}1|Y596~tad*}P6Ii(661kv4JY`n zC=UM+FsKQSb>>ZObqs!1yWjmAP#~MWeu@cB^uI~(08NxI$hFrH^}$-h<9ygXW9v9n zm$P4?xXorx?TeI7(0&aBu6rX9uXlff5yGtO@ikfCGYv9FBWKdVeR#U*7mjKQz~XI{ zsh*@5$uFx0Z;biY>5sUNtJSi8s>>X^r;GFB>+8*1C4hM~WQUon){Y0K^C54N7VS=_ zkDtdN9(*8dUAi9%LnBU@pXa6xIAKgMs24&|KwXI#asH(jiPCrRI@+x?NMcWX7J2a7 z*YVhoTakzF@|{ci=V@2qZj)E~UrokazT0AlOBuDNz0Sf{a`&wT>o`*wp1V*s*}Gg$ zp!YdLg0O3d`LrTnqPkFQ=sc2>`DYuSK9sAo4hndCK6DAPqM172QM;Nhz?^h9gncUA z(o>-6Fv$e+e2L?Wsg0@Bhi){rPEr@n88h?RtTk*|vo?cUBAE=>q+EcWVes&gph})_ z4o7!kq^h+ARdW*ySt`?~CV!@26g-a$=BHDO{2Adthch29>UZA3Y%OatHI;WK-CnUV z3)Q6|j7n4~ZJ=ki%rgP2xtloYP&&&A&W0nl8bbv?%)Ehg^&_x|UGwTnbsOWs+s}57S zO9>J(zxjn8c>>jak2sp4O&p)y$lF+MD_=iv#WK6$>d<{qIN@AR%6LIfIpNp_h6$5S z+`Mm&VN)t2*Z}s?QZZl&A09;Lp(F_;&AiZ12`Bs z;zFllIUrr-aBGia==N~Kqdx^WC`#sFjq}yJ8^hbx+b52%F3(Zuwiew?3zoJ&kX=kF zBIQvk#6&>pz8765wbhq!T-`8m@|>0~T!PO2fO4fl7g5|3nY_NKuJ*appZRujje6L^ z7~u{}e~A#Pv}9=65~Z<1RfKO|pX8$abw1d3C-(38Lfr?=p_#0n!Q+s2E;L7q!(@QUtO6jn+%} zU`zz5-y_2IeC&NrPc9^><@4p*jJM?NOl~e@$pGbYSOZsN_=H5Z)W3DqK&vhD#^1@I zXw@ycM;&_UTGb*~j{P~u+`U22BlHwD;30!sXqkQ^i=UNB%r0SXF&}=G^h31L#hm#} z9g|^;^j3C7eBYi@f_8Y~k*1iK&SeM?Kj8Lg!>zmMLKy1@`hqtpFn;q%b|G#WWQW~- z^i@;TS~bThW~vP(m35|X^b~D-&jXpsW=SH;Rs350W$N|87kQ|`KeY)z_3cir2Xs*T zR^d2B#LaoR_mtQ(*m~~6%+_9qDX9g- zo%2abkv;S`q`0)o#v=@@xFZSXpp{7=#*4I<^w9{sX>kcO#~?J3kR|r>AF%ZXEXlu% zn4g<#!Y3e1U%tlL2!wULRq5y9Ww$tCF#Jlga>QGfS_5T&`-)3xUKfI~8ID4!nPemp zhqY_Id~{{Ewp97@GxBRtlpQg`wT2F9fn++`9{kexF9ci-vM)>XfnC+c6@BZ^QNS!_ zG(gM?yWd!ol=IuU{4iN#V_#To0<-ZxOHmX{sQAJq;CWvHETq zqyOGG^RZ&)@z<)N4O@+M9+9)0L&%`Vs5B|76wv8iG>%%+eTe^59&o|*ZTQGj?TJdW z$uk@ySO9D{ps`1$*%(GWL)OrNqQqEIWPKm& z%-@D@oN43d8K*~o^`~dJO#7utaCUM;2MTwEUyz3=CZ%{t z55nNU?XmpM!dDa_#?-2IBukP6 zrMZ-`oe(b8wG4_yIzwQnl|eMQqy{5dhw>3w%jX0XUio>>SoyocKLJZH>*>1D3z!FB zfT@PGbFID=rdSEXrb1UXU$_ybNl04d%_jTy;bWoe5tH(OZ?Vq{*Nip)_#bB|<0QI@ zk(hm@l5Mm3${FkLM^2zsd~2iAVXReYcxqS|jDWGmPYLW7)zM>d6MafX1UY|?-Xw(l zb;MLw3aN+ra=QJr?}y`FM>`(%^^Zalfd^l!gY!p;@9-~i3dR!Rrz#{&8kgUX3&|RJ z{`4xcyl!^Vyf*SX*ug7ED>KHG1+yl$DPSc1{fmT6ETmWi#t%&Y9AEx4!T+tsZ=qjQ zj|7p@K~>V2mvTe&%}@Jh?>^qoz{|Y>&a#LL^+_kd63Q9e@KJ=Iti*)ylgBx&ozo^2 zdQOg~R!|T!=q%EaA-*rHo(~B_0vWwXw##XtX&_kbYc zCiu{Dy_+4vccTE7%M-F@N4E zcuPujH+yNC&)I%HyQ%0rfj@P2`+3&5=~)~@HeQrZbyuGp zTi#;LnsZSTB%w`eG_*Xt<&khcrIgPL9zQtLuvk30eFml&~V zt>4%5h81az#oIdq#B5Pe6nh9749l2m@Ks0B&4*uwdqh;gpxvsa;-EE^bL)|cl9Vo( zIiDw)2&8p3>Hk{mv&WeopDP*8^W&h@Q@aTj!a3c-LNELJ(nvF#9qS<`1bup_8v%9o^xsYnpCmzs zaHf2Hn$Lr?3M;=e?>;Y!t!F@i$rDe+Cacu*M3vc94_Ho?4w`581d-zl>P1X=dO(#m z`565ypRHOG7XR=Qs4Cz9bh*^f4{4f1=|LWArzim`IN9^ES}WS?2Sywky{~0MWK#MK zy4)8D8lrzj697@QhXaa_RBMBxh^R4BV8!8Jk+~WFN}cn}ZPRZd(jK*8&LgqURf%~x z-G8W7e(v=MGAy*UY2IHN!nik-5?hZo?{=+n-6qa&x-Q%sIW9bdJkgv#JIcRK)5)X$ zd^5mmB+sjU73;-{K7~^&xTjuFMM>#&os!(@_8>1cQNYIzve7^XKOI zC-Go-aizsxr89mTqeh2J?~=3uo+}=8&;Y-9@m*0%3lUFpe@vDTa&=9wv)w|OQ_y^g zYQFxD_vn0G)77Zs6SB2_FJAv>5|l(Gh%v0EXjr_pKnN)l&7rx7qL45lS9>L_D#;TB_cuIr%Pi_VxOKe zH(-kD|Kh8{h|}?y_%n#?Cm4Sy-3%QUAFAXhxxZF3xuAX9z_2NcxVW@V}}84tk!tzsO9=6-~27HjL^nWxa`U+10_rLxJm`GR3tr z#*W4U-9}#0VQQA>{Bl`JmV#)yKfrM_$wmK&_;{j~+ZFVrij8TQP&idp=7qvLy8X}| z+iln0Kvy#1=w(hY^C?H}1>EYB?Sy^UuXKy4%O1wBgtDxova>{F;mwd^Z11{+7tKLp z6~qo;i|)=)G0VYF95~?o4vX%4BiQwwV53|f=G}a&f-W7U3UTu9QnvVXoS3+_Nl8rp z8|}vGbtIUUt|j&`03LF+wv@br-Ok_bfnimd)9nGrwTQb3L51DR!M)8Td~5BT54lyU zs;YF-(u)L+zmV|9sE;_TO8SKY%vgCF-rFIP+M8QgknR!lA0Qu#W2>u{y21dhA-_&fv8aMYuUys14b9_vZN!v_WVX>sxcUM0OmyH8b6) z?<3&Is)K+}bzkbKvGb+;r2da#4R}+c8JbaU4h?fmF3$E_dO7WUd%c2Ycn)LuKS-ud z4D&-a0Bp@qXRW*cvT|`q4*mtLCh@!MH97Z&b&KF(yo4a(=x6vay!)2}{103I!Rtt7 zC?Lac@=c&|gA=jEr976A* zG43U6OWp?BVtaS|^fr@}#epuOZ)(c##o~Gvs;q<+LV8x5<0TOIfis=IDOs`tPvU2{ z!s}LD=j)CcYKIFAzxyxFFUFo)$FdIy%E@I6+3{tGogsqnJ}E4`%P?1>D-59wp*JmsM3(6VQg_V0L=~PrP#kWna!6CHy zi%>*pKNbJZ6h{rXTYwo+Q?}EOy*wD)^E2;$*}sHYw@%#lH@{Z*-+8R5aR`yaepA`Ho#Ow4ai^_fg>wiC0UTdu0zs2JD~ z@6AHBx4Xv9VZWzSIw*0S^;2pH=100AuLl0KzG9jxcwWYd+#t087`#-A7^m?&Eql-NsHp6!d5<$*5Ug_-ZZz2xa z^7G>`Xc|Zcp;np7tG0 zr^G%WkT6ciyQl@hBf@6#z?KjM=W~9}gTNq2Axn|atM`QJ?QS=}v>g%zQ!5oF@P*Q< z2F(jq95suUU6f~s&r?~mi-7eFFkC61jnh2QqHs&mlct^UTxOC^*h)tB$mfeF-rUxL zO0)0F_MGM?lI@?Zr|b_3#OSbrib$aEb9f*B@$tr1SX~PJ?E!Pjz2WEfzW7d;g@J(F z0ok}C6lRZonY<~!5<+ds<}nyDrD!Aoo-PG}iBrV6<*NNTZ4b#gq{31F-@giR#|N3i zN4Ym?%*HCsuz4JbUk#>{B;3tD5UwvhHm+gq1qnmEdzEQ!q=~(4i0@fxg_se^9GtLb zmmw9)nDUU}Kh&`NM#(Mu_#4*%;+s}?EBS|5P`UimVuLRK!A3i~U+Qr7>qA&Hi4ODq znxNL*npG22?n3ZG)W2Qdsox3o4k>2(1LWmAJ6@z6#e8opxS*v7gf8sbwsP6JZ$4Mj z;XL3^5-r^?f4EqSiBkG=_rBA++eT5%NZS%qFG+F}Q|4hN1m7b?^rcGU#h!MoazM^> zp*7*H>rHlT?tL)F^!HU3Q(LL9E(b}9Pn*F-g8CNc^n)$Wk0mhtuMgV>x744a!LaX@ zO6WaG_>)`ngs8@r@(cd zSJjOq7(<;Rmsm3Rq0HQPhR`cOK|vvNqwDThK75!if`F}?^`5lQA{p`rS6st?O#%2t zo=M13TrYV8;dQvQC55@h19x#@u*jiQ=~mx~xJRZrvWf80{i=QEa_kp_)KjLcwj^ab zaGRC}E-g+!wKSOQHvtR3La={%T{8T78N3HJd;FA|x*zhZI{m#wA3x1^{M_t6l9H_Q z<`U3jspun4Pyz;kxkJVo(G>}N*H11Z)(=c%%mp$O^+G`b031&g?;2omITF(uR z5P9$SUK_f1{?q-DMg(}{7nXt%>$AdmqGgf*j`tAdh!rL~HIFa-0lloR_=!fi;w{ zq0)Alr6Vl#g&~9=tZxhyVBoi5?vYwVt*dR)p)!eCHJ4*<0vU8vK(2Cb_rP%7G-pg| z5OHPPijDSP`5&dZZoe#lA!%wvMqTC3(}xdhpcc(7VzN3Hx+*lIlFcAoN7|1u)$BI! zuchK==~0P77ImC2#Pcn>y0Spa9LMpzyH{)zanvy@3eedfmG6JieV=0xAtA;u<#jib zI1;H_VCFYZlEMOm;vq%p)8_FeL1JOVKdr z3KJWoz})tO0ogA*%q;oz>R`}4afOp9i1}q`kG7KfYG(R4du4ctTgE+MuIv|#fY*6<2wc77-?h(ue2NxVq zEifST2wxL(vAW%+hc^mRJfN?>%l`t*c16l*~o4J$Nl zKg%1PO{6T+l_$KRb)KOtZocge9zNP`6IwcX* zWn{xsn?`4bdBkrw2A!w*1z#H-p*~&0Q=$B)0lW%v@|>ubr>)D&|pHUAuo0X;W~0|%Be>l&jDd;7>!cYgxPcQ4d*yA}`9 zwOv=V&+rI!bG=FBH8r0eF%AOzdj+BkofdK}==y-AbS(LTW6o}bVyi6`!Q zok*`dCRQpu8{PMop-L=-Y!@CJi^jLL-v@&SM7CPHwtnzS8Y_!+AyK>no7;)U(Y7;O z)pK%wVcqLbx&F&4PcnKr(gFov^t1U*BT=rLgQ2BnGm>aMz@Cn@9zLVYB^IF`op;S6 zUuD#3CthZ&FplP5gWa-Zx10dorK=e<(ZF+#73`1TX`Kc;8j5dta0E4Qx5}`TfP+hUsuZm!0RrFk; z`E%iHWMRHKNp8d#T{-v^u*W|x8#YmxGKh>QA0N9K>jwXiDFG1i;HgH=<;GwMSZ#sT zkC_Nyd!QN%RM|U2ewLN8lI?{Ahk`+vZYyc6KnN|J_t?kFm9pCow+Z z%}%0N{W8hS5>BN5fvB7T$!g4 zfpio*;^#Z-G@sAC;#qTXDtdi)VWX-CTd(*Hv9J+aIS*%TlYfY4A;V4fFE6o$caEX~ zDdJyWXkDwmZ@?)He#rd2$R^!n63Xuoi_!+%0bfHC1%UbW$#^kGcG>;YQ|dsCn>Or4iM7=9~!(n zy0E#Cwr}z|KGx}DAO|NO+;QFPU2-tSkBvLWi=rzy6wT4MwdPR{^+KZsLJeVtm3 ziz&hJD1~cV2;@_RzVl(cwlykf*Jm@-Q%y?`!{vxUyTk_;B}sB8NxjI#|K=iI8!VjXX2vz>Gp5S8-$uW?Pa58yFk1}~F+#3*5J4w6*aaja?twuPBjfQa z5$YuEQOkV$9sW<&dH_DrdC!9i@Brg z>s~?yv1u>eE*+7aSwR=n;=37q=mRs28`*Jn7YcLrm5pJF@mt2pWIFd~p*pqG7{?&j zkCVihHhODtLZt+*?^!>E=m*Uty5$6hlSWE|G+%0?Q|eIQLUK&XX*wfeK7HJ+I zF3;hQ9i)Xc|G#pwi9zr!Cx0bv|4gGtzxz7^!S-xRSao1jT!tjjL@k*qR^$-|y-Nqa zG%|}%y9kwdXp}-9po^-TCVX@8^KXK7RImLAhx_KC4!84iWmY^ivBG#IoZ)@|Ikm&I zKkDpZ6`DlT>-q@COtL^NoaT6PQ#A}h=E?li@rtdvKUK~r_ZDE{h-$n(6tUfX=k`4Sl_5`@1}Ua$|@ z%U4?vr>7(xG$kwl`Mp&XdZt^v+@f_^%K&jY?Wu0}T;Yn$se+*)8l+lJRobJ8y<4St zQBy&J-N|Ar>tEm9Nm!atBifk$-EVUHAr(&EE2sQh{-l&{lV#_5|l*V&H= zN*aq`M9_BJP~_BrcJ`}-qq(HutcqR&Wfc=5IBNy(dYj<><3%5>LH~O4r+PP?&fxNC z))Pg+I@S*MrPhi}oAonRoa3le-4VF`_F}`H^rljg^%WnJVlJ&SrH)de^0xn!s9|D_ zG66ks_;}%{hfKvjUlZCMwy;mI3T-sl9VY>p;WTT`hU&fTnSr;Gv`dPIJKf83GT)C_ zpkh->9G&Y!K6@H(3Q(whpo_cNuedo_FKQ0JSA1A3{etjOkR4^n+$O=F>C>5*Dt;Vt zFOejHP-$>@Or89GxVv^$D1c@6M?}n_>(T(l!j>-H=c zn`lFt!Hg0e=@lZ^oXvRC0pHF$T_^m}xjSiuza{~HGnoWywAQ(fpCtP9ZE>j~Os}95<@EhE=WTECxD?J_XQ@;9Ec5}4 zPhy1YDUh>fvw~JWl#rTquT$NFkMn#5{8*Ht=DW&hkq=vhwO$rg{AWECh^<^R5d)(e z>N88eFW~-Ht4To(%XJ*H6|}wTq9KfL_JuX+aH7!XK`#K3Y2uUO>)qpZ`KZv1&0 zaWS2U%u3A?dThUHWFB@b>`L1>iza{M%lRGM@t=KnSZ* z=47NHE}pehaCdwrk7L$0o7c{btM4?W#FS+T)>`^v0oBp|uQx&$rZp+-mwKP&7G2_B z?(Q$&S*qhGr)&Xv(*wP^x(0k26rYlfgDRIn@Ga_|4CK;@3a47uiy=}S2$f- zi7vd?tHUSZYR@B@fjn5F@)p^xj-d#0uOg2i_{eV0sKY%wi?mNNWtUC4Kf zHyq`nR4NR=Cbs^`RlyJoPsBcV<(F<0yN)h;k~&+1<`)?_P_NIp==nPVeU_aJG<(H5 zX_n`@{3O0l+CIppSnTtD9f7$->M(gC7Ag&6KHaxV>j;x8JpwD;`=(~vh84AbWX9Xe zCuGQmOcWjvZT&L8?KvFL@-58IW*DDDnPsm4UH5dDZamO)_^|vOcE7QC+`o1-PPGlc zaz6Z314CY!D+?A<3pF5Q)AJ0md2ZVN|h8SDA0$t#9Ad1zle%Hm1~be(>ISeW?f zP1~l$O35=6(tLyKBB~7eb)KpvaLZl!S>)TxmER&*d)rTs>$^zi9>gzhRy%gjrsYu) z+MgqkJ%CZ05q0dIhjRBD;1v<&A+dk%9TjG#}*-lR>jwtM|Q|u1-qSqG-j}+)Brvy^5gH zxCPgJ!L?uL5L5xaG6LSZal6!pWygdWhz-#Kw5~xbt-Q5axu3lRr50JF{lVU!g*-oR z`DuIF@{3kdqD731-s?0pH)Ri2}f*yqfD)a^`Bf+&rI%p`@+glsF`&l23d z)#>AiO6Oa=T*>bjL!M^}5qyF!CnmKFbz>iYSo=Jtgi)7jReo}(Ix9QA+?Hvm%a|to zuW%g7GgQe?bVcxukEgO4(A660%~?NeT5429<=Nm`N`8-t2j>kO&W74ZK)!t}>=2>s z4ZBY^i&;c)59(V1&W=r!2HZ`5$Lb;t(K*v@iV;YNVS{uP$19Hz_o-<>QvLv~X~p$d}z5*$43u$WR}KhM;Z-;xl1 zqS%QK=8L!(4YxnnpNpOkmHiW%UH0YWgdQHRfn15;BXbGkGN@`_@Nzg1>?r_b>+=T2T!(RNla2{YG1 z@9&^gdYOzv+;fh6+TP?aOi2^dR<4~vqM7%(gCAsl3l_LvfY46&RlGoLO*~$yd7iR| zMqX?V`z|7OV=+l?+mV|kyGnkvUPJFwnn5z}vYoxXmf=^kSYcXeX=x8i+@XKyHnfxB zt8N1Q8D8$4+4bJ|N2>`E{>>1oGQ%>=HM<)7#gBAcD$7?;?GGQN`9L(`N7Ft;oqF33 zntYmqE!=hsVqkuqHQ##!$isH*Zrk-lVu$aO01Gl4;Y|DWgdo%gguM8a;yg>(yX8@? z_)9VS)!njVJ{sLn$J5zy)m&RxjPC!lkPE=Ua&^j!sf$x?{jQwoBB9pW8`q=6p+)cK z6T#L*pOmw)`A|pcbT1wV;Xc(4W(SJ*u@iGOxc=Hlg~ZWPhlT3G`08&7*q7eciW$Va^`h zBMKFHG;f9xo4-8XvKow{5q|7LqJtt0@z2jb5GXChc)#Kjn_q0R_^-JB#}hr7H8nJ7 zLcZW46WR>k3(leDO9d%(k?Bt_O z7n<|O$lpNZ4Kn!;r9WGe-RvE!P8YMm`tRnJlSmQgnjSg+vvlWSn@;}iRd`pc(?|a7 z;l;ig5+19-ET{kyvZ?urZ{Rab={#JBFzUa0!Oy?HVv{BVR<>4y+K-8|o{rVtr6!Ee ze{}HiZ+>S3eQ(^#Mf>D1$6!ov56ykCLnYE7kV2ais+^4!i(U$a$}!<{bEtKDhnv$S zt2?0h7>KoL1NQJwW%d|U2j|d!tEp3C9+8xs%8v$DPl-#>llW-T8-^4b!k}IxYiLNO zk*9N&dF~@a3(BB(Y`Y92@z@Bgd3$|oWsMK(d4J!s;ylXY{14CR$8_@+Ko{Kt53>!W zLg&})^zV1#_F?Fxj8K`oOAsO)rR^vBcTSQ5N>zBw+5w*v)I4{Swaj0iAyw1{)WPs* z&OZM&F8Mcz1c+e+k?`&VVh9$=x9@0m8XFXg&UnRAm|3C6YK`+8@%F+d8CE>@!I6<< zV)M%_Zs(inM65Mbh3Kr0HZ$y=SHtc*W1JY$OSBQDo0l8GgJx$nn(~cFqtS%wJL9~X z#t!}O?FB3Y8*6jzPdT98M-)U^bG$Muc%B(6vl;UDN^x|dK~T$)!7N?mh6vdHJ*A(- zGyCH|_YakvB^` zkBa-`Hj4Icmg0vKWtu#PH@e$NVk57Jns@OhM^M-% z!Z+gk^r@F2nN|A>KuP09XcYy_V3Yvu2=+KhKoyPmqnwjM)EJ`ARsZ-?=LH=r{#k~w z=3menBqkA>J~=8!mkET9;&#D6`Fi|K9_RIN0bBC9HoL8Yh#H$&S}4|(B1leJ-KXmPCMQ5rnL$r!d?{UnTyCw2=*!nl(8q{4H}=F z(J7iba^wvk@2Q&i{SD)+vUT2lk;~HfGuJ~RMBDDQ*V+&I3Gdo)zPdE~19Pb5a)8)z zYsAmZjr4@#sm^ZsfkOSMOHOe7_k)(U_CXyy*Uvm8oO>%0-<#>P-2Eb#A{X*Ox@;fV z09O9Tyb(!*?T*OcLpvK9NdlFk7bJx7pabQO(&`Q~>S{SK3|sQ6f#pXc*^98c&-H{Vr3#0THd%LXPw#mrY*+~m$IN=ix+k#IgW z+8t-4b8=VTGmRvH(2#h&)D^0ocjcSUT8@K8-~G~;EjN7dc?Jt)&2^O6B&pXI@q|O_ zSdh+3$g;!#*%|%cCUBq)XtK#jA!E|=V{BZzVg`Ko`8Bu}&0KIf5u2B%&%ep(ZDvxV z4RtjOu{5Iu&91;wnpWvwK&Y$!xNwK0ka)heC-arNe7AcRM~F=tP7H4~^RepuB%V*! z)34(BcnCLDf}4NBt}Vk1^;f_uEhs=nDa)taAUVTRReE>$cRgT!0fPDM67Lc*rfxpM z)0-zAgM@A$7r>>M?Z&B;*$_H-Ia3#*3+JF#OiL})d>@LutB^I4edDT&vHfgD`lDwF zJ>}9ftam4u%a5?=$NxKiL*Ipc;TuD_$bRCYC(-CBJ34FKfts(}_z4 z20n?%%m06EU3XklN!J$TMOeD1NTiA#K~{qFrZlOtND(QbA_Sxw=?NlAQ-X>jgc1>x zhyenc0MezdA_TbvSdfHj3@u_3APKNv+*f5keeXZ>yZ1M9=H9vUoHKJ~&NGLnic)-1 z&PLfSQ4DH$x2vB4n~M_?nHxBpp&N*)4wZ*gZ8^GT9kwt zg04chuUgw1xbsN^_AW(*WGfm)&+Ox$8W;BE?aH(?rKf09*Nz<@|K$Jo5si^;Vu}0! zX2Ani`wq=BTk`pX{A+IVrPP0c$U^$GXgasHCjLg8N&e@eK65RYtIYGu){!~wdaevg zyC&&y1V~!r89xM~)?oARg*Fy4M~nii>b6{1`@xqemRNr4x&t?+i}TEBOVl|UL7}cp zz_n7P_mots)5;h{$XjU0yIdmb%4frQp;5m$mrL;*-^ri$3JC=6)gN~H8tH3Q9Kt5* zZH|UUL|h1d5velHS~nH089sLD-?K z>r_F(VgaxLA$O&8?C_K_?Ih8I5S0Y zVw}H06u)m*>SbS1!?9)bo=^I4YTKto4WSCH+yQB@7tF0t>Cht)VU#z7&ZCO~kTpaY|3iHphdK>7Hwc zsQboL^RaX|51awd!S`|E<735n9JsERF%SG3PdY9(GX_uOm$?ozH_4u z7PEtpeZ7O!R18Fc9?b!RFsQVI{S^4wWTHmr0hd2%^y*VQDF+}rr@F&SVXWI51@X@(v10`Sg6I1=YZ9n~W8b+-Ui|O?{+*SV(zV#gzs1mH~h}QLDl9y3igGHG` ziYA#5xbfuhWqZ>017!??{fyR8uOUa^i~1>5@dB#iAPNjYXtM7ibY8Zsg6%oj+gceK zG9=|{I=1f%?_*{gjp2N_0M!wu>oqxJ0Qx*oY2;}vBYA8o;iX3p4F^$QB<6GZsz_Vc zypN3Iz+=>D7cm@UXT&&5VqZ}Y$`!zeL zjnWeE5CE^QvN~l$Y-<$PU1I`6^lQTk4AQN_x}sk^e-6?q8hqQ9b}`bt$(gg=_`{Ye zXLQ-!4VCiEpE?&JSQA>WS5$?bZ(SS;R_P=V^outX3w&88m2F-pU3MqXkNs=T_(MY69;vdD zlH&qTjiirLzCYbCu-7-)RmA3qxNec(`He$mBS~B`SN3pmnFcEx4i5f`C;qnkG*fVm zz$juO*zs;g^)JmJC~Ph&DjI(3{E3Oh8s7`w2^_yQ5x&<2mkerT1RY}M(%z!uQ3H}ZR&L)zN#L8Xf{C`s;L3M)+;u{f;2TvA@*SGvfYMB|&)hwY(xVhyh z?^sT_8n4)5*cy&eBg)hGEehFOF&dlg{%`X=_Rd={v$9xbxJerxUoy!K1) zF_0v1uqmvz4~K`sJzH6P*D$;gh0+*ZblAb_;!$BW`6TBqU1PeiGsONAcw+k2{i@0S z4-C_^RztAjdV^X67jE43Nr|~;D7*aSVH2uHvtV*uM(~X|3W;u1o3sEwgP$}L^tf*c zLOHa~)N$i6B^4FZ}N0z|u!F zkPMUABrLh!4zeCK$rJ@rCXLIIcgG9v8i;=BwFJ3(NVhFtHLA)9nC=m%q#3~B8P>aXU zRN0aqSPFR{e9?d=OuvcWv+hIPbXnuf3)v*n0hg~o4_>HrxlzeKN5fgcECg_DveV5^ z%k(+$3gQVXQ;`qV7{%JlE&)g!KmxdZ9ACIE(vAbKRg!o_+8%mJ4lTl)DuI+ulOmwq zFsy)uYKG6FY&S)CVKx)iO`6Jbjo}9W2B;M{$~XYW>?G^L6l`$D^tG_sFYwXafHgiJ zg1ZCI+NdfFJ7|CKl?oCOdUF3}gToBq7~d?VFty7$EHVcMfYBE}NMgGN!Bj^#OQyCX zzrUgA(D6vWCz8*q|7Ou2V*lXOw?g09(mZS)l^g+J6!mc$@N{w(dkfMUjwVCdx#i;( zN*cxC?1$cyEC5a1PvwWP8cg5_#tN-ade}03^YeL=R`B@B(!R(Ke}~DT{4ttcnwdQe z+q*Ad1;!{lFw((9#)#?egIR?J`^sJOaDdrU@1YPBzO|g%fly1p;JW%HyGd=TUh9Ye zqTPNIXHHnG?gEp^T&-}-()b{+&ir%?hW9>;WVOF|BCw_b@3@iI8+ETsBAs^0U-a*H z|8}GPqfShhE&oxMcE$P;naRJM8{N(AA@h!ZaO>Z%FneVllI0+mbw`*$TW-)|v?Ln12%|d3^vmv{^86Fm$hn zr{~0p)J$t6d2d!WHn?W@qXIM2vdKuymAD6VekGa4Ad;ovFq22gUGDCA0GYFA z7V!ngcW38`BrdHw@Wa|M>vLDQh5Z8s^B4V(3>MZ@A7J#PqKPGJ;kk0EH35U^m?x!L zj&xvfY_IBVKOuoXC-ag@9Uzm7@X}FozEN}n8~E;8{h^>EB{%m7s^!{QTOrTS7nXaJ z{UW5IPCC`~%$a6EwLsbbEG&e!%xsJ_|7#v=`P87vb$YdC{$;m+EgmPGx|1IPU|%t@ zxUvwOMh^K!?|UgG2An;cJ91s|c6)8Se_7K16!LLu%5=+pXjI}H1>-*dOO{@;G@mjG zm(V(3@{?(LETghXrZo8S4RTteR8aa)wy*c0bTfb3K#tG8V3}CA_3X0HpG2D;Fl$DP z&+qlQsr`+mW@BC`A#C2&)#ZC*2W0uSa3#s>W&+FeCj7JP(^sW;luGu9bKv!fj_FM# zl5K<^f5d~>sf0)=4p=?iXfxZXZC|2Y4yO05fzkfMk-kmuz1@@G3Lpx?Dix1QQ3{(X zW@Q*^Z=W*I-+L&bgARI&{&J?Ky|3$Kh#GMRMZQU}<6QTNt;Pyj#PKL07mKN1L2 zRpqbmi1ViV7M*J?Lykz-B-fw8bZDDoIo2kdWLnQkG8ZT50mvy{y|b_DI1ueJGdX-0 z51rvNnJr*{A0-|-si1)p)G*m0{KS5SMc+Jp-I@~wnrs9jxQ*}YH5C9agTvbsto1SS)cTSmLA(-H{ox2CXY^XXLGFERM|85+#HPH-0&7eiqnE&%x898vJ5Dif}*)Yb`8`P41p$`Wmf zUvHY*CFZ=ny<>PYf{))T4FCIiY8A;c6a09R>yEHRKie%Q5v=7P&78*II% zvR3Bqxpw(X1NaC*U0j=R0;-%Z)ez|M)=TF;@F-hIJ$2nvqaer-({V(F#scNX5_Jt0 zt&cbMH{5Mb>XCVQfJ2PK;d}hE2XkyQf^ag?zSosj1$cdJLcpIT)k_hWVOxE7N6sA^!w4r~l)E82ElD34&*B8G{EFX>Fr zN`_hYJ$;@tGbl->c0FIHp(5}veEaQ-N~rOx^$1MQHvFw@qxK^7{t)lk#gkoZO<&Id z8u!}k1^xn3yvRUgT|+tZH#S{d1OD*%CeWF*UC^G3GdsMB-Eo0`W|A4Rlq5_^$JNaD2C<_T2M=O|3MW5V`Ml@hmOQ&> z-}zZu%$&zdWJ=tOD%B<#EwImI&=jk=aco;cgZD~0QiR4#YZyo`DDJ!$YXKR{zJQtz z#|^%syn7z*RYuNV!;j;tKHBo)G8&GK$4gG68{<0y3VbF!v}1!Zbwo>5dJ`<&ZlPeghx~(L&G~q zU-#{9XAlvNrpY=*k_Sj}rVG;%A3Od5?T`E;2F;Am)m$SumLzZ%uhY7_EvnjN9_*Ch zYkPEY8)arJ%HWDn=l?IGt7#AxuMK>5yP6W0e6F-0M2y9nEYuO=t-+%^SJ!vay6^mq zT}s84-J`oceVh!dq1skZDopfZ|E)v5zK#xbOljn;ieH{=Qp+a(b;`oQy!^!Z8~+De CfkvnR literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/images/server_log_usage_2.png b/addons/cetmix_tower_server/static/description/images/server_log_usage_2.png new file mode 100644 index 0000000000000000000000000000000000000000..9fed0cdde61637252b4b8b217826e4293e9db5af GIT binary patch literal 51286 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 N@O1TaS?83{1OVAk38w%6 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/images/user_profile.png b/addons/cetmix_tower_server/static/description/images/user_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..5a7db2f129d3a57d9e40fa824c180a99a12e9d58 GIT binary patch literal 119757 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 WfBBZdhqb){ek4U@MT&*Aef|%sxtUP_ literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_server/static/description/index.html b/addons/cetmix_tower_server/static/description/index.html new file mode 100644 index 0000000..2d33748 --- /dev/null +++ b/addons/cetmix_tower_server/static/description/index.html @@ -0,0 +1,514 @@ + + + + + +Cetmix Tower Server + + + +

    +

    Cetmix Tower Server

    + + +

    Beta License: AGPL-3 cetmix/cetmix-tower

    +

    Cetmix Tower offers a streamlined +solution for managing remote servers and applications via SSH or API +calls directly from Odoo. It is designed for +versatility across different operating systems and software +environments, providing a practical option for those looking to manage +servers without getting tied down by vendor or technology constraints.

    +

    Please refer to the official +documentation for detailed information.

    +

    Table of contents

    + +
    +

    Configuration

    +

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

    +
    +
    +

    Usage

    +

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

    +
    +
    +

    Changelog

    +
    +

    18.0.2.0.0 (2026-04-07)

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

    18.0.1.0.11 (2026-03-10)

    +
      +
    • Bugfixes: Last flight plan line post-run action was not triggered +correctly. (5120)
    • +
    +
    +
    +

    18.0.1.0.10 (2026-03-10)

    +
      +
    • Features: Improve the ‘File using template’ command flow, fix the +flight plan line view layout. (5197)
    • +
    +
    +
    +

    18.0.1.0.9 (2026-02-19)

    +
      +
    • Features: Blacklist filter for Python commands, value checker for +Vault. (5253)
    • +
    +
    +
    +

    18.0.1.0.7 (2026-02-05)

    +
      +
    • Features: Scheduled tasks: allow to select specific days of week. +(5190)
    • +
    • Bugfixes: Ensure custom values can be updated even if not provided +initially. (5175)
    • +
    +
    +
    +

    18.0.1.0.6 (2026-01-20)

    +
      +
    • Bugfixes: Make pre-defined messages and command help translatable +again. (5174)
    • +
    +
    +
    +

    18.0.1.0.4 (2025-12-23)

    +
      +
    • Bugfixes: Handle malformed expressions in flight plan line conditions. +(5154)
    • +
    +
    +
    +

    18.0.1.0.3 (2025-12-17)

    +
      +
    • Features: Parse empty or missing key values as ‘None’ instead of +leaving key reference as is. (5134)
    • +
    • Features: Improve search views, implement the search panel for +selected views. (5139)
    • +
    • Bugfixes: Custom values in flight plan are lost in a skipped command +and are not available after it. (5129)
    • +
    +
    +
    +

    18.0.1.0.2 (2025-12-08)

    +
      +
    • Bugfixes: Make variables selectable in scheduled tasks (5105)
    • +
    • Bugfixes: Save correct error message in log when SSH connection fails. +(5109)
    • +
    +
    +
    +
    +

    Bug Tracker

    +

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

    +

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

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • Cetmix
    • +
    +
    +
    +

    Maintainers

    +

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

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js new file mode 100644 index 0000000..fd3a66f --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import {AceField} from "@web/views/fields/ace/ace_field"; +import {CodeEditorTower} from "./code_editor_tower.esm"; +import {registry} from "@web/core/registry"; +import {_t} from "@web/core/l10n/translation"; + +class AceCommandField extends AceField {} + +AceCommandField.template = "cetmix_tower_server.AceCommandField"; +AceCommandField.components = { + CodeEditorTower, +}; + +registry.category("fields").add("ace_tower", { + component: AceCommandField, + displayName: _t("Ace Tower Editor"), + supportedOptions: [ + { + label: _t("Mode"), + name: "mode", + type: "string", + }, + ], + supportedTypes: ["text", "html", "char"], + extractProps: ({options}) => ({ + mode: options.mode, + }), +}); + +export {AceCommandField}; diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.scss b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.scss new file mode 100644 index 0000000..343c9ea --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.scss @@ -0,0 +1,44 @@ +// Custom styles ONLY for AceCommandField - more specific selectors +.o_field_widget.o_field_ace_tower { + display: block !important; +} + +.o_field_widget[data-field-name] .o_field_ace.ace-command-field { + min-height: 200px !important; + height: auto !important; + width: 100% !important; + display: block !important; + + .ace_editor { + min-height: 200px !important; + height: auto !important; + width: 100% !important; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + display: block !important; + } + + .ace_content { + min-height: 200px !important; + width: 100% !important; + } + + .ace_scroller { + width: 100% !important; + // Remove any scroll restrictions that might affect standard ACE + overflow: auto !important; + } +} + +// Custom autocomplete popup styles +.ace-autocomplete-popup { + .ace-autocomplete-item { + &:hover { + background-color: #e6f3ff !important; + } + + &:last-child { + border-bottom: none !important; + } + } +} diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.xml b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.xml new file mode 100644 index 0000000..1afd543 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.xml @@ -0,0 +1,17 @@ + + + +
    + +
    +
    +
    diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.esm.js b/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.esm.js new file mode 100644 index 0000000..d97bc41 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.esm.js @@ -0,0 +1,296 @@ +/** @odoo-module **/ + +import {Component, onWillDestroy, useEffect, useRef, useState} from "@odoo/owl"; + +class AutocompletePopup extends Component { + /** + * Component setup method that initializes refs, state, and effects + */ + setup() { + this.popupRef = useRef("popupRef"); + this.searchInput = useRef("searchInput"); + this.itemsContainer = useRef("itemsContainer"); + + // State for search functionality + this.state = useState({ + searchTerm: "", + }); + + useEffect( + () => { + this.scrollToSelected(); + }, + () => [this.props.selectedIndex] + ); + + // Auto-focus search input when popup opens + useEffect( + () => { + if (this.searchInput.el) { + // Use setTimeout to ensure DOM is ready + const timeoutId = setTimeout(() => { + this.searchInput.el.focus(); + }, 0); + return () => clearTimeout(timeoutId); + } + }, + () => [] + ); + + useEffect( + () => { + if (this.props.position) { + const timeoutId = setTimeout(() => { + if (this.popupRef.el) { + this.popupRef.el.style.left = `${this.props.position.left}px`; + this.popupRef.el.style.top = `${this.props.position.top}px`; + this.popupRef.el.style.position = "fixed"; + } + }, 0); + return () => clearTimeout(timeoutId); + } + }, + () => [this.props.position] + ); + + onWillDestroy(() => { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + }); + } + + /** + * Updates search term from external keyboard input (from editor) + * @param {String} char - The character typed or 'Backspace' for deletion + */ + updateSearchFromEditor(char) { + if (char === "Backspace") { + this.state.searchTerm = this.state.searchTerm.slice(0, -1); + } else if (char.length === 1) { + this.state.searchTerm += char; + } + if (this.props.onSelectedIndexChange) { + const newIndex = this.filteredCommands.length > 0 ? 0 : -1; + this.props.onSelectedIndexChange(newIndex); + } + } + + /** + * Filters commands based on search term with enhanced search capabilities + * @returns {Array} Filtered and sorted array of commands matching the search term + */ + get filteredCommands() { + if (!this.state.searchTerm.trim()) { + return this.props.commands || []; + } + + const searchTerm = this.state.searchTerm.toLowerCase(); + + const commands = this.props.commands || []; + const scoredCommands = commands + .map((command) => { + const name = (command.name || "").toLowerCase(); + const reference = (command.reference || "").toLowerCase(); + + let score = 0; + + // Exact matches get highest priority + if (name === searchTerm || reference === searchTerm) { + score = 1000; + } + // Starts with search term gets high priority + else if ( + name.startsWith(searchTerm) || + reference.startsWith(searchTerm) + ) { + score = 100; + } + // Contains search term gets medium priority + else if (name.includes(searchTerm) || reference.includes(searchTerm)) { + score = 10; + } + // No match + else { + return null; + } + + // Boost score for name matches over reference matches + if (name.includes(searchTerm)) { + score += 5; + } + + // Boost score for shorter matches (more relevant) + score += Math.max(0, 50 - Math.min(name.length, reference.length)); + + return {command, score}; + }) + .filter((item) => item !== null) + .sort((a, b) => b.score - a.score) + .map((item) => item.command); + + return scoredCommands; + } + + /** + * Debounces the search filtering + * @param {String} searchTerm - The search term to set + */ + debouncedSearch(searchTerm) { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.searchTimeout = setTimeout(() => { + this.state.searchTerm = searchTerm; + if (this.props.onSelectedIndexChange) { + const newIndex = this.filteredCommands.length > 0 ? 0 : -1; + this.props.onSelectedIndexChange(newIndex); + } + }, 150); + } + + /** + * Handles search input changes + * @param {Event} ev - The input event + */ + onSearchInput(ev) { + ev.stopPropagation(); + this.debouncedSearch(ev.target.value); + } + + /** + * Common keyboard navigation logic + * @param {KeyboardEvent} ev - The keyboard event + */ + handleKeyboardNavigation(ev) { + if (ev.key === "ArrowDown") { + ev.preventDefault(); + const len = this.filteredCommands.length; + if (len === 0) return; + const current = this.props.selectedIndex ?? -1; + const newIndex = Math.min(current + 1, len - 1); + if (this.props.onSelectedIndexChange) { + this.props.onSelectedIndexChange(newIndex); + } + } else if (ev.key === "ArrowUp") { + ev.preventDefault(); + const len = this.filteredCommands.length; + if (len === 0) return; + const current = this.props.selectedIndex ?? -1; + const newIndex = current <= 0 ? 0 : current - 1; + if (this.props.onSelectedIndexChange) { + this.props.onSelectedIndexChange(newIndex); + } + } else if (ev.key === "Enter") { + ev.preventDefault(); + const idx = this.props.selectedIndex ?? -1; + if (idx >= 0) { + const selectedCommand = this.filteredCommands[idx]; + this.onItemClick(selectedCommand); + } + } else if (ev.key === "Escape") { + ev.preventDefault(); + this.props.onItemClick(null); + } + } + + /** + * Handles keydown events on search input + * @param {KeyboardEvent} ev - The keyboard event + */ + onSearchKeyDown(ev) { + ev.stopPropagation(); + this.handleKeyboardNavigation(ev); + } + + /** + * Handles focus events on search input + * @param {FocusEvent} ev - The focus event + */ + onSearchFocus(ev) { + ev.stopPropagation(); + } + + /** + * Handles blur events on search input + * @param {FocusEvent} ev - The blur event + */ + onSearchBlur(ev) { + ev.stopPropagation(); + } + + /** + * Handles click events on search input + * @param {MouseEvent} ev - The click event + */ + onSearchClick(ev) { + ev.stopPropagation(); + } + + /** + * Handles mousedown events on search input + * @param {MouseEvent} ev - The mousedown event + */ + onSearchMouseDown(ev) { + ev.stopPropagation(); + } + + /** + * Handles item click events + * @param {Object} command - The selected command object + */ + onItemClick(command) { + this.props.onItemClick(command); + } + + /** + * Handles close button click events + */ + onCloseClick() { + this.props.onItemClick(null); + } + + /** + * Scrolls the selected item into view + */ + scrollToSelected() { + const itemsContainer = this.itemsContainer.el; + if ( + itemsContainer && + this.props.selectedIndex !== undefined && + this.props.selectedIndex >= 0 && + this.props.selectedIndex < itemsContainer.children.length + ) { + const selectedItem = itemsContainer.children[this.props.selectedIndex]; + if (selectedItem) { + selectedItem.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + } + + /** + * Returns CSS class for autocomplete item based on selection state + * @param {Number} index - The item index + * @returns {String} CSS class string + */ + getItemClass(index) { + return index === (this.props.selectedIndex ?? -1) + ? "ace-autocomplete-item ace-autocomplete-item-selected" + : "ace-autocomplete-item"; + } +} + +AutocompletePopup.template = "cetmix_tower_server.AutocompletePopup"; +AutocompletePopup.props = { + commands: {type: Array}, + onItemClick: {type: Function}, + position: {type: Object}, + selectedIndex: {type: Number, optional: true}, + onSelectedIndexChange: {type: Function, optional: true}, + type: {type: String, optional: true}, +}; + +export {AutocompletePopup}; diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.scss b/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.scss new file mode 100644 index 0000000..d78f491 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.scss @@ -0,0 +1,192 @@ +// Define z-index variable for better management +$z-index-autocomplete: 1050 !default; // Above dropdowns but below modals + +.ace-autocomplete-popup { + position: absolute; + background: white; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: $z-index-autocomplete; + min-width: 300px; + max-width: 500px; + max-height: 300px; + overflow: hidden; + font-family: monospace; + font-size: 14px; + + // Mobile adaptations + @media (max-width: 768px) { + width: 90%; + min-width: unset; + max-width: unset; + left: 50% !important; + transform: translateX(-50%); + font-size: 13px; + max-height: 90vh; + } +} + +.ace-autocomplete-search { + padding: 8px; + padding-right: 48px; // Add right padding to avoid overlap with close button + border-bottom: 1px solid var(--bs-border-color); + background: var(--bs-tertiary-bg, #f8f9fa); + + // Mobile: reduce padding + @media (max-width: 768px) { + padding: 6px; + padding-right: 56px; + } +} + +.ace-autocomplete-search-input { + width: 100%; + padding: 6px 10px; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +.ace-autocomplete-search-input:focus { + border-color: #007cba; + box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.1); +} + +.ace-autocomplete-items { + max-height: 240px; + overflow-y: auto; + color-scheme: light dark; /* Let the UA adapt colors when possible */ + + /* Standard scrollbar styling (Firefox 64+) */ + scrollbar-width: thin; + scrollbar-color: var(--bs-border-color, #c1c1c1) var(--bs-tertiary-bg, #f1f1f1); +} + +/* Scrollbar styling for webkit browsers */ +.ace-autocomplete-items::-webkit-scrollbar { + width: 6px; +} + +.ace-autocomplete-items::-webkit-scrollbar-track { + background: var(--bs-tertiary-bg, #f1f1f1); +} + +.ace-autocomplete-items::-webkit-scrollbar-thumb { + background: var(--bs-border-color, #c1c1c1); + border-radius: 3px; +} + +.ace-autocomplete-items::-webkit-scrollbar-thumb:hover { + background: var(--bs-secondary-color, #a8a8a8); +} + +.ace-autocomplete-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--bs-border-color); + display: flex; + justify-content: space-between; + align-items: center; + + // Mobile: stack items vertically with reduced padding + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + padding: 6px 12px; + } +} + +.ace-autocomplete-item:hover { + background-color: var(--bs-tertiary-bg, #f5f5f5); +} + +.ace-autocomplete-item-selected { + background-color: var(--bs-primary-bg-subtle, #e6f3ff); + color: var(--bs-primary, #0d6efd); +} + +.ace-autocomplete-item-selected:hover { + background-color: var(--bs-primary-border-subtle, #cce7ff); +} + +.command-name { + font-weight: 600; + color: #333; + flex: 1; + margin-right: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + // Mobile adaptations + @media (max-width: 768px) { + margin-right: 0; + margin-bottom: 2px; // Reduced from 4px + width: 100%; + font-size: 14px; + } +} + +.command-description { + color: #666; + font-size: 12px; + font-style: italic; + flex-shrink: 0; + + // Mobile adaptations + @media (max-width: 768px) { + font-size: 11px; + width: 100%; + word-break: break-word; + } +} + +.ace-autocomplete-no-results { + padding: 16px 12px; + text-align: center; + color: var(--bs-secondary-color, #6c757d); + font-style: italic; +} + +// Close button styles +.ace-autocomplete-close-btn { + position: absolute; + top: 8px; + right: 8px; + background: none; + border: none; + font-size: 20px; + font-weight: bold; + color: var(--bs-secondary-color, #6c757d); + cursor: pointer; + padding: 4px 8px; + line-height: 1; + border-radius: 3px; + z-index: 1; + min-width: 30px; + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: var(--bs-secondary-bg, #f0f0f0); + color: var(--bs-body-color); + } + + &:active { + background-color: var(--bs-tertiary-bg, #e0e0e0); + } + + // Mobile-friendly touch target + @media (max-width: 768px) { + min-width: 40px; + min-height: 40px; + font-size: 24px; + top: 4px; + right: 4px; + } +} diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml b/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml new file mode 100644 index 0000000..c9c42c7 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/autocomplete_popup.xml @@ -0,0 +1,76 @@ + + + +
    + + + + + +
    +
    + + +
    +
    + No secrets found + No variables found +
    +
    +
    +
    +
    diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.esm.js b/addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.esm.js new file mode 100644 index 0000000..fe7138d --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.esm.js @@ -0,0 +1,514 @@ +/** @odoo-module **/ + +import {onWillDestroy, useEffect, useState} from "@odoo/owl"; +import {AutocompletePopup} from "./autocomplete_popup.esm"; +import {CodeEditor} from "@web/core/code_editor/code_editor"; +import {useService} from "@web/core/utils/hooks"; + +const POPUP_FALLBACK_WIDTH = 500; +const POPUP_FALLBACK_HEIGHT = 300; + +export class CodeEditorTower extends CodeEditor { + static template = "cetmix_tower_server.CodeEditorTower"; + static components = { + AutocompletePopup, + }; + + setup() { + super.setup(); + this.orm = useService("orm"); + this.inputListener = null; + this.clickOutsideListener = null; + this.inputTimeout = null; + this.clickOutsideTimeout = null; + this.variables = []; + this.secrets = []; + + this.state = useState({ + showPopup: false, + popupItems: [], + popupPosition: {}, + selectedIndex: 0, + // Add popup type to distinguish between variables and secrets + popupType: "variables", + }); + + this.updateSelectedIndex = this.updateSelectedIndex.bind(this); + + useEffect( + (el) => { + if (!el) { + return; + } + + // Keep in closure + const aceEditor = window.ace.edit(el); + this.aceEditor = aceEditor; + + const session = aceEditor.getSession(); + this.setupCustomAutocompletion(aceEditor, session); + return () => { + if (aceEditor) { + aceEditor.destroy(); + } + }; + }, + () => [this.editorRef.el] + ); + + onWillDestroy(() => { + if (this.inputTimeout) { + clearTimeout(this.inputTimeout); + } + if (this.clickOutsideTimeout) { + clearTimeout(this.clickOutsideTimeout); + } + if (this.aceEditor && this.inputListener) { + this.aceEditor.getSession().off("change", this.inputListener); + } + this.hideAutocompletePopup(); + }); + } + + async loadVariables() { + try { + this.variables = await this.orm.searchRead( + "cx.tower.variable", + [], + ["name", "reference"] + ); + } catch (error) { + console.error("Failed to load variables for autocomplete:", error); + this.variables = []; + this.env.services.notification.add( + "Failed to load autocomplete variables", + {type: "warning"} + ); + } + } + + /** + * Load secrets from the backend using ORM service + * @returns {Promise} + */ + async loadSecrets() { + try { + this.secrets = await this.orm.searchRead( + "cx.tower.key", + [["key_type", "=", "s"]], + ["name", "reference"] + ); + } catch (error) { + console.error("Failed to load secrets for autocomplete:", error); + this.secrets = []; + this.env.services.notification.add("Failed to load autocomplete secrets", { + type: "warning", + }); + } + } + + /** + * Configure custom autocompletion commands and keyboard bindings for ACE editor + * @param {Object} aceEditor - The ACE editor instance + * @param {Object} session - The ACE editor session + */ + setupCustomAutocompletion(aceEditor, session) { + // Remove any existing conflicting commands first + aceEditor.commands.removeCommand("startAutocomplete"); + aceEditor.commands.removeCommand("expandSnippet"); + + // Only add the main autocomplete trigger command + aceEditor.commands.addCommand({ + name: "customAutoComplete", + bindKey: {win: "Ctrl-Space", mac: null}, + exec: (editor) => { + this.showCustomCompletions(editor); + return true; + }, + }); + + // Set up input listener for {{ and #! triggers + this.inputListener = () => { + // Clear any existing timeout + if (this.inputTimeout) { + clearTimeout(this.inputTimeout); + } + // Use setTimeout to ensure the text is fully processed + this.inputTimeout = setTimeout(() => { + const cursor = aceEditor.getCursorPosition(); + const line = session.getLine(cursor.row); + const textBeforeCursor = line.substring(0, cursor.column); + + // Check for variables trigger {{ + if (textBeforeCursor.endsWith("{{")) { + // Remove {{ symbols from editor + const startColumn = Math.max(0, cursor.column - 2); + const range = { + start: {row: cursor.row, column: startColumn}, + end: {row: cursor.row, column: cursor.column}, + }; + session.replace(range, ""); + + // Update cursor position + const newCursor = { + row: cursor.row, + column: startColumn, + }; + aceEditor.moveCursorToPosition(newCursor); + this.showCustomCompletions(aceEditor, "variables"); + } + // Check for secrets trigger #! + else if (textBeforeCursor.endsWith("#!")) { + // Remove !# symbols from editor + const startColumn = Math.max(0, cursor.column - 2); + const range = { + start: {row: cursor.row, column: startColumn}, + end: {row: cursor.row, column: cursor.column}, + }; + session.replace(range, ""); + + // Update cursor position + const newCursor = { + row: cursor.row, + column: startColumn, + }; + aceEditor.moveCursorToPosition(newCursor); + this.showCustomCompletions(aceEditor, "secrets"); + } + }, 10); + }; + + session.on("change", this.inputListener); + } + + /** + * Show custom completions popup with available variables or secrets + * @param {Object} editor - ACE editor instance + * @param {String} type - Type of completion ('variables' or 'secrets') + * @returns {Promise} + */ + async showCustomCompletions(editor, type = "variables") { + const cursor = editor.getCursorPosition(); + const session = editor.getSession(); + const line = session.getLine(cursor.row); + const textBeforeCursor = line.substring(0, cursor.column); + + let items = []; + let triggerLength = 0; + + if (type === "secrets") { + // Handle secrets + await this.loadSecrets(); + + if (!this.secrets.length) { + return; + } + + items = this.secrets; + } else { + // Handle variables + await this.loadVariables(); + + if (!this.variables.length) { + return; + } + + items = this.variables; + // Check if we're already in a variable context + const isInVariableContext = textBeforeCursor.endsWith("{{"); + + if (isInVariableContext) { + triggerLength = 2; + } + } + + const position = this.calculatePopupPosition(editor, cursor); + + // Set popup type in state + this.state.popupType = type; + + await this.showAutocompletePopup(items, position, editor, triggerLength, type); + } + + /** + * Calculate the optimal position for the autocomplete popup + * @param {Object} editor - ACE editor instance + * @param {Object} cursor - Cursor position object + * @returns {Object} Position object with left and top coordinates + */ + calculatePopupPosition(editor, cursor) { + const renderer = editor.renderer; + + // Calculate cursor position within the editor + const cursorPixelPos = renderer.textToScreenCoordinates( + cursor.row, + cursor.column + ); + + // Get scroll position + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + // Calculate the cursor position relative to the viewport + const viewportLeft = cursorPixelPos.pageX - scrollLeft; + const viewportTop = cursorPixelPos.pageY - scrollTop; + + // Position popup just below the cursor + const finalLeft = viewportLeft; + const finalTop = viewportTop + renderer.lineHeight; + + // Ensure popup doesn't go outside viewport + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const popup = document.querySelector(".ace-autocomplete-popup"); + const popupWidth = popup ? popup.offsetWidth : POPUP_FALLBACK_WIDTH; + const popupHeight = popup ? popup.offsetHeight : POPUP_FALLBACK_HEIGHT; + + let adjustedLeft = finalLeft; + let adjustedTop = finalTop; + + // Adjust if popup would go off-screen horizontally + if (finalLeft + popupWidth > viewportWidth) { + adjustedLeft = finalLeft - popupWidth; + } + + // Adjust if popup would go off-screen vertically + if (finalTop + popupHeight > viewportHeight) { + adjustedTop = finalTop - popupHeight - renderer.lineHeight; + } + + // Make sure popup is not positioned off-screen + adjustedLeft = Math.max(0, adjustedLeft); + adjustedTop = Math.max(0, adjustedTop); + + return { + left: adjustedLeft, + top: adjustedTop, + }; + } + + /** + * Display the autocomplete popup with variables or secrets at the specified position + * @param {Array} items - Array of available variables or secrets + * @param {Object} position - Position object with left and top coordinates + * @param {Object} editor - ACE editor instance + * @param {Number} triggerLength - Length of trigger text that should be replaced + * @param {String} type - Type of completion ('variables' or 'secrets') + * @returns {Promise} + */ + async showAutocompletePopup( + items, + position, + editor, + triggerLength, + type = "variables" + ) { + this.hideAutocompletePopup(); + + this.state.popupItems = items; + this.state.popupPosition = position; + this.state.showPopup = true; + this.state.selectedIndex = 0; + this.state.popupType = type; + this.currentEditor = editor; + this.currentTriggerLength = triggerLength; + this.currentType = type; + + // Add click outside listener + this.clickOutsideListener = (event) => { + // Check if click is outside the popup and ace editor + const popupElement = document.querySelector(".ace-autocomplete-popup"); + const aceElement = this.aceEditor.container; + + if ( + popupElement && + !popupElement.contains(event.target) && + aceElement && + !aceElement.contains(event.target) + ) { + this.hideAutocompletePopup(); + } + }; + + // Store timeout ID to prevent race condition + this.clickOutsideTimeout = setTimeout(() => { + // Guard against race condition: only register if popup is still shown + if (this.state.showPopup) { + document.addEventListener("click", this.clickOutsideListener, true); + } + this.clickOutsideTimeout = null; + }, 0); + } + + /** + * Hide the autocomplete popup and clean up event listeners + */ + hideAutocompletePopup() { + // Clear pending timeout to prevent race condition + if (this.clickOutsideTimeout) { + clearTimeout(this.clickOutsideTimeout); + this.clickOutsideTimeout = null; + } + + // Remove click outside listener + if (this.clickOutsideListener) { + document.removeEventListener("click", this.clickOutsideListener, true); + this.clickOutsideListener = null; + } + + this.state.showPopup = false; + this.state.popupItems = []; + this.currentEditor = null; + this.state.selectedIndex = 0; + + // Return focus to the ACE editor + if (this.aceEditor) { + this.aceEditor.focus(); + } + } + + /** + * Update the selected index in the autocomplete popup + * @param {Number} index - New selected index + */ + updateSelectedIndex(index) { + if (this.state) { + this.state.selectedIndex = index; + } + } + + /** + * Handle selection of a command from the autocomplete popup + * @param {Object} command - Selected command object + * @param {Object} editor - ACE editor instance + */ + handleCommandSelection(command, editor) { + if (!command || !command.reference) { + this.hideAutocompletePopup(); + return; + } + + const cursor = editor.getCursorPosition(); + const session = editor.getSession(); + const line = session.getLine(cursor.row); + const textBeforeCursor = line.substring(0, cursor.column); + + // Get line length for validation + const lineLength = session.getLine(cursor.row).length; + const currentType = this.currentType || this.state.popupType; + + let range = null; + let insertText = ""; + + if (currentType === "secrets") { + // Handle secrets insertion + // Check if we're inside a secret context (between #!cxtower.secret and !#) + const lastSecretStart = textBeforeCursor.lastIndexOf("#!cxtower.secret"); + const lastSecretEnd = textBeforeCursor.lastIndexOf("!#"); + + // Count occurrences of start and end delimiters for more robust validation + const startCount = (textBeforeCursor.match(/#!cxtower\.secret/g) || []) + .length; + const endCount = (textBeforeCursor.match(/!#/g) || []).length; + const isInsideSecret = + startCount > endCount && + lastSecretStart > lastSecretEnd && + lastSecretStart !== -1; + + if (isInsideSecret) { + // We're inside a secret context, replace from after #!cxtower to cursor + range = { + start: {row: cursor.row, column: lastSecretStart + 16}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = `${command.reference}!#`; + } else { + // We're not in a secret context, insert complete secret + const triggerLength = this.currentTriggerLength || 0; + range = { + start: {row: cursor.row, column: cursor.column - triggerLength}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = `#!cxtower.secret.${command.reference}!#`; + } + } else { + // Handle variables insertion (existing logic) + const lastOpenBrace = textBeforeCursor.lastIndexOf("{{"); + const lastCloseBrace = textBeforeCursor.lastIndexOf("}}"); + const isInsideVariable = + lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1; + + if (isInsideVariable) { + // We're inside a variable context, replace from after {{ to cursor + range = { + start: {row: cursor.row, column: lastOpenBrace + 2}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = ` ${command.reference} `; + } else { + // We're not in a variable context, insert complete variable + const triggerLength = this.currentTriggerLength || 0; + range = { + start: {row: cursor.row, column: cursor.column - triggerLength}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = `{{ ${command.reference} }}`; + } + } + + // Replace the text + session.replace(range, insertText); + + // Get the updated line length after replacement + const updatedLineLength = session.getLine(cursor.row).length; + + // Position cursor after the inserted text + const newCursor = { + row: cursor.row, + column: range.start.column + insertText.length, + }; + + newCursor.column = Math.max(0, Math.min(newCursor.column, updatedLineLength)); + + editor.moveCursorToPosition(newCursor); + + this.hideAutocompletePopup(); + editor.focus(); + } +} diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.xml b/addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.xml new file mode 100644 index 0000000..bd2c0aa --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/code_editor_tower.xml @@ -0,0 +1,16 @@ + + + +
    + + + + + diff --git a/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js b/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js new file mode 100644 index 0000000..bc3f948 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js @@ -0,0 +1,34 @@ +/** @odoo-module */ + +import {registry} from "@web/core/registry"; +import { + StateSelectionField, + stateSelectionField, +} from "@web/views/fields/state_selection/state_selection_field"; + +import {STATUS_COLORS, STATUS_COLOR_PREFIX} from "../../utils/server_utils.esm"; + +export class ServerStatusField extends StateSelectionField { + /** + * @override + */ + setup() { + super.setup(); + this.colorPrefix = STATUS_COLOR_PREFIX; + this.colors = STATUS_COLORS; + } + + /** + * @override + */ + get options() { + return [[false, "Undefined"], ...super.options]; + } +} + +export const serverStatusField = { + ...stateSelectionField, + component: ServerStatusField, +}; + +registry.category("fields").add("server_status", serverStatusField); diff --git a/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss b/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss new file mode 100644 index 0000000..2562835 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss @@ -0,0 +1,33 @@ +.o_server_status_bubble { + @extend .o_status; + + &.o_color_server_status_bubble_info { + background-color: $o-info; + } + &.o_color_server_status_bubble_success { + background-color: $o-success; + } + &.o_color_server_status_bubble_danger { + background-color: $o-danger; + } + &.o_color_server_status_bubble_warning { + background-color: $o-warning; + } +} +.o_field_server_status { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + margin: 0px 16px; + border-radius: 5px; + border: 1px solid #e5e5e5; + width: fit-content !important; + + .o_status_label { + color: #4c4c4c; + font-size: 14px; + margin-left: 0.5rem !important; + display: block; + } +} diff --git a/addons/cetmix_tower_server/static/src/utils/server_utils.esm.js b/addons/cetmix_tower_server/static/src/utils/server_utils.esm.js new file mode 100644 index 0000000..158d82a --- /dev/null +++ b/addons/cetmix_tower_server/static/src/utils/server_utils.esm.js @@ -0,0 +1,17 @@ +/** @odoo-module */ + +/** + * List of colors according to the selection value + */ +export const STATUS_COLORS = { + false: "info", + stopped: "danger", + starting: "warning", + running: "success", + stopping: "warning", + restarting: "warning", + delete_error: "danger", +}; + +export const STATUS_COLOR_PREFIX = + "o_server_status_bubble mx-0 o_color_server_status_bubble_"; diff --git a/addons/cetmix_tower_server/tests/__init__.py b/addons/cetmix_tower_server/tests/__init__.py new file mode 100644 index 0000000..fc3c813 --- /dev/null +++ b/addons/cetmix_tower_server/tests/__init__.py @@ -0,0 +1,42 @@ +from . import test_server +from . import test_command +from . import test_file +from . import test_file_template +from . import test_plan +from . import test_plan_line +from . import test_plan_line_action +from . import test_command_log +from . import test_plan_log +from . import test_server_log +from . import test_server_template +from . import test_variable +from . import test_variable_value +from . import test_variable_option +from . import test_command_wizard +from . import test_reference_mixin +from . import test_scheduled_task +from . import test_update_related_variable_names +from . import test_key +from . import test_cetmix_tower +from . import test_tag +from . import test_shortcut +from . import test_tools +from . import test_partner_server_btn +from . import test_vault_mixin +from . import test_tag_mixin +from . import test_jet_template +from . import test_jet_template_access +from . import test_jet_template_dependency_access +from . import test_jet_template_install +from . import test_jet_template_install_access +from . import test_jet_template_install_line_access +from . import test_jet_access +from . import test_jet_dependency_access +from . import test_jet_action_access +from . import test_jet_create_wizard +from . import test_jet_state +from . import test_jet +from . import test_server_jet_action_command +from . import test_jet_waypoint +from . import test_jet_waypoint_template_access +from . import test_jet_waypoint_access diff --git a/addons/cetmix_tower_server/tests/common.py b/addons/cetmix_tower_server/tests/common.py new file mode 100644 index 0000000..1421dab --- /dev/null +++ b/addons/cetmix_tower_server/tests/common.py @@ -0,0 +1,515 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import os +from unittest.mock import MagicMock, patch + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.base.tests.common import BaseCommon + +from ..models.constants import GENERAL_ERROR +from ..ssh.ssh import SftpService, SSHConnection + + +class TestTowerCommon(BaseCommon): + """ + Common test class for Cetmix Tower. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Disable transaction commit to avoid race conditions + cls.env = cls.env["base"].with_context(cetmix_tower_no_commit=True).env + + # ---------------------------------------------- + # -- Create core elements invoked in the tests + # ---------------------------------------------- + # Group XML records + cls.group_user = cls.env.ref("cetmix_tower_server.group_user") + cls.group_manager = cls.env.ref("cetmix_tower_server.group_manager") + cls.group_root = cls.env.ref("cetmix_tower_server.group_root") + + # Cetmix Tower helper model + cls.CetmixTower = cls.env["cetmix.tower"] + + # Tags + cls.Tag = cls.env["cx.tower.tag"] + cls.tag_test_staging = cls.Tag.create({"name": "Test Staging"}) + cls.tag_test_production = cls.Tag.create({"name": "Test Production"}) + + # Users + cls.Users = cls.env["res.users"] + cls.user_bob = cls.Users.create( + { + "name": "Bob", + "login": "bob", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + cls.user = cls.Users.create( + { + "name": "Test User", + "login": "test_user", + "email": "test_user@example.com", + "groups_id": [ + (6, 0, [cls.group_user.id, cls.env.ref("base.group_user").id]) + ], + } + ) + cls.manager = cls.Users.create( + { + "name": "Test Manager", + "login": "test_manager", + "email": "test_manager@example.com", + "groups_id": [ + (6, 0, [cls.group_manager.id, cls.env.ref("base.group_user").id]) + ], + } + ) + cls.root = cls.Users.create( + { + "name": "Test Root", + "login": "test_root", + "email": "test_root@example.com", + "groups_id": [ + (6, 0, [cls.group_root.id, cls.env.ref("base.group_user").id]) + ], + } + ) + + # OS + cls.os_debian_10 = cls.env["cx.tower.os"].create({"name": "Test Debian 10"}) + + # Server + cls.Server = cls.env["cx.tower.server"] + cls.server_test_1 = cls.Server.create( + { + "name": "Test 1", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "host_key": "test_key", + "os_id": cls.os_debian_10.id, + } + ) + + # Server Template + cls.ServerTemplate = cls.env["cx.tower.server.template"] + cls.server_template_sample = cls.ServerTemplate.create( + { + "name": "Sample Template", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + + # Server log + cls.ServerLog = cls.env["cx.tower.server.log"] + + # Variable + cls.Variable = cls.env["cx.tower.variable"] + cls.VariableValue = cls.env["cx.tower.variable.value"] + cls.VariableOption = cls.env["cx.tower.variable.option"] + + cls.variable_path = cls.Variable.create({"name": "test_path_"}) + cls.variable_dir = cls.Variable.create({"name": "test_dir"}) + cls.variable_os = cls.Variable.create({"name": "test_os"}) + cls.variable_url = cls.Variable.create({"name": "test_url"}) + cls.variable_version = cls.Variable.create({"name": "test_version"}) + + # Key + cls.Key = cls.env["cx.tower.key"] + cls.KeyValue = cls.env["cx.tower.key.value"] + + cls.key_1 = cls.Key.create( + {"name": "Test Key 1", "key_type": "k", "secret_value": "much key"} + ) + cls.secret_2 = cls.Key.create( + {"name": "Test Key 2", "key_type": "s", "secret_value": "secret top"} + ) + + # Command + cls.sudo_prefix = "sudo -S -p ''" + cls.Command = cls.env["cx.tower.command"] + cls.command_create_dir = cls.Command.create( + { + "name": "Test create directory", + "path": "/home/{{ tower.server.username }}", + "code": "cd {{ test_path_ }} && mkdir {{ test_dir }}", + } + ) + cls.command_list_dir = cls.Command.create( + { + "name": "Test create directory", + "path": "/home/{{ tower.server.username }}", + "code": "cd {{ test_path_ }} && ls -l", + } + ) + + cls.template_file_tower = cls.env["cx.tower.file.template"].create( + { + "name": "Test file template", + "file_name": "test_os.txt", + "source": "tower", + "server_dir": "/home/{{ tower.server.username }}", + "code": "Hello, world!", + } + ) + + cls.template_file_server = cls.env["cx.tower.file.template"].create( + { + "name": "Test file template", + "file_name": "test_os.txt", + "source": "server", + "server_dir": "/home/{{ tower.server.username }}", + } + ) + + cls.command_create_file_with_template_tower_source = cls.Command.create( + { + "name": "Test create file with template with tower source", + "path": "/home/{{ tower.server.username }}", + "action": "file_using_template", + "file_template_id": cls.template_file_tower.id, + "if_file_exists": "raise", + } + ) + + cls.command_create_file_with_template_server_source = cls.Command.create( + { + "name": "Test create file with template with server source", + "path": "/home/{{ tower.server.username }}", + "action": "file_using_template", + "file_template_id": cls.template_file_server.id, + "if_file_exists": "raise", + } + ) + + # Command log + cls.CommandLog = cls.env["cx.tower.command.log"] + + # File template + cls.FileTemplate = cls.env["cx.tower.file.template"] + + # File + cls.File = cls.env["cx.tower.file"] + + # Flight Plans + cls.Plan = cls.env["cx.tower.plan"] + cls.plan_line = cls.env["cx.tower.plan.line"] + cls.plan_line_action = cls.env["cx.tower.plan.line.action"] + + cls.plan_1 = cls.Plan.create( + { + "name": "Test plan 1", + "note": "Create directory and list its content", + "tag_ids": [(6, 0, [cls.tag_test_staging.id])], + } + ) + cls.plan_line_1 = cls.plan_line.create( + { + "sequence": 5, + "plan_id": cls.plan_1.id, + "command_id": cls.command_create_dir.id, + "path": "/such/much/path", + } + ) + cls.plan_line_2 = cls.plan_line.create( + { + "sequence": 20, + "plan_id": cls.plan_1.id, + "command_id": cls.command_list_dir.id, + } + ) + cls.plan_line_1_action_1 = cls.plan_line_action.create( + { + "line_id": cls.plan_line_1.id, + "sequence": 1, + "condition": "==", + "value_char": "0", + } + ) + cls.plan_line_1_action_2 = cls.plan_line_action.create( + { + "line_id": cls.plan_line_1.id, + "sequence": 2, + "condition": ">", + "value_char": "0", + "action": "ec", + "custom_exit_code": 255, + } + ) + cls.plan_line_2_action_1 = cls.plan_line_action.create( + { + "line_id": cls.plan_line_2.id, + "sequence": 1, + "condition": "==", + "value_char": "-1", + "action": "ec", + "custom_exit_code": 100, + } + ) + cls.plan_line_2_action_2 = cls.plan_line_action.create( + { + "line_id": cls.plan_line_2.id, + "sequence": 2, + "condition": ">=", + "value_char": "3", + "action": "n", + } + ) + + # Flight plan log + cls.PlanLog = cls.env["cx.tower.plan.log"] + + # Shortcut + cls.Shortcut = cls.env["cx.tower.shortcut"] + + # Model references + cls.OS = cls.env["cx.tower.os"] + cls.PlanLineAction = cls.env["cx.tower.plan.line.action"] + + # Scheduled task + cls.ScheduledTask = cls.env["cx.tower.scheduled.task"] + cls.ScheduledTaskCv = cls.env["cx.tower.scheduled.task.cv"] + # Jet State + cls.JetState = cls.env["cx.tower.jet.state"] + + # Jet Action + cls.JetAction = cls.env["cx.tower.jet.action"] + + # Jet Template Install + cls.JetTemplateInstall = cls.env["cx.tower.jet.template.install"] + + # Jet Template Install Line + cls.JetTemplateInstallLine = cls.env["cx.tower.jet.template.install.line"] + + # Jet Template Dependency + cls.JetTemplateDependency = cls.env["cx.tower.jet.template.dependency"] + + # Jet Template + cls.JetTemplate = cls.env["cx.tower.jet.template"] + cls.jet_template_sample = cls.JetTemplate.create( + { + "name": "Sample Jet Template", + "server_ids": [(4, cls.server_test_1.id)], + "variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": cls.variable_path.id, + "value_char": "/jets/templates/template1", + }, + ), + ( + 0, + 0, + {"variable_id": cls.variable_os.id, "value_char": "Debian 10"}, + ), + ( + 0, + 0, + { + "variable_id": cls.variable_url.id, + "value_char": "https://jets.example.com", + }, + ), + ( + 0, + 0, + { + "variable_id": cls.variable_dir.id, + "value_char": "jet_templates", + }, + ), + ], + } + ) + + # Jets + cls.Jet = cls.env["cx.tower.jet"] + cls.jet_sample = cls.Jet.create( + { + "name": "Sample Jet", + "jet_template_id": cls.jet_template_sample.id, + "server_id": cls.server_test_1.id, + "variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": cls.variable_path.id, + "value_char": "/jets/jet1", + }, + ) + ], + } + ) + + # apply ssh connection patches + cls.apply_patches() + + @classmethod + def apply_patches(cls): + """ + Apply mock patches for SSH-related methods to simulate various + scenarios during testing. + + Patches: + 1. SSHConnection.connect: + - Returns a mock connection with a fake exec_command method, + which returns a successful or unsuccessful result depending on the + command content. + 2. SftpService.download_file: + - Returns b"ok\x00" for files with the .zip extension and + b"ok" for the rest. + 3. SftpService.upload_file: + - Returns MagicMock, simulating file upload. + 4. SftpService.delete_file: + - Returns MagicMock, simulating file deletion. + """ + + # Patch connection SSH method + def ssh_connect(self): + connection_mock = MagicMock() + + # set up stdin with a condition for error simulation + def exec_command_side_effect(command, *args, **kwargs): + # Create mocks for stdin, stdout, and stderr + stdin_mock = MagicMock() + stdout_mock = MagicMock() + stderr_mock = MagicMock() + + if "fail" in command: + # Simulate failure + stdout_mock.channel.recv_exit_status.return_value = GENERAL_ERROR + stdout_mock.readlines.return_value = [] + stderr_mock.readlines.return_value = ["error"] + return stdin_mock, stdout_mock, stderr_mock + elif "raise" in command: + # Simulate an exception + raise Exception("error") # pylint: disable=broad-exception-raised + else: + # Simulate success + stdout_mock.channel.recv_exit_status.return_value = 0 + stdout_mock.readlines.return_value = ["ok"] + stderr_mock.readlines.return_value = [] + return stdin_mock, stdout_mock, stderr_mock + + # Apply side effect to exec_command + connection_mock.exec_command.side_effect = exec_command_side_effect + + return connection_mock + + connect_patch = patch.object(SSHConnection, "connect", new=ssh_connect) + connect_patch.start() + cls.addClassCleanup(connect_patch.stop) + + # Patch file manipulation methods for testing + def ssh_download_file(self, remote_path): + if hasattr(self, "env"): + error = self.env.context.get("raise_download_error") + if error: + raise ValidationError(error) + + _, extension = os.path.splitext(remote_path) + if extension == ".zip": + return b"ok\x00" + return b"ok" + + download_patch = patch.object( + SftpService, "download_file", new=ssh_download_file + ) + download_patch.start() + cls.addClassCleanup(download_patch.stop) + + def ssh_upload_file(self, file, remote_path): + if hasattr(self, "env"): + error = self.env.context.get("raise_upload_error") + if error: + raise ValidationError(error) + return MagicMock() + + upload_patch = patch.object(SftpService, "upload_file", new=ssh_upload_file) + upload_patch.start() + cls.addClassCleanup(upload_patch.stop) + + def ssh_delete_file(self, remote_path): + return MagicMock() + + delete_patch = patch.object(SftpService, "delete_file", new=ssh_delete_file) + delete_patch.start() + cls.addClassCleanup(delete_patch.stop) + + @classmethod + def add_to_group(cls, user, group_refs): + """Add user to groups + + Args: + user (res.users): User record + group_refs (list): Group ref OR List of group references + eg ['base.group_user', 'some_module.some_group'...] + """ + if isinstance(group_refs, str): + group = cls.env.ref(group_refs, raise_if_not_found=False) + if not group: + raise ValidationError(_("Group reference %s not found!") % group_refs) + action = [(4, group.id)] + elif isinstance(group_refs, list): + action = [] + for group_ref in group_refs: + group = cls.env.ref(group_ref, raise_if_not_found=False) + if not group: + raise ValidationError( + _("Group reference %s not found!") % group_ref + ) + action.append((4, group.id)) + else: + raise ValidationError(_("groups_ref must be string or list of strings!")) + user.write({"groups_id": action}) + + @classmethod + def remove_from_group(cls, user, group_refs): + """Remove user from groups + + Args: + user (res.users): User record + group_refs (list): List of group references + eg ['base.group_user', 'some_module.some_group'...] + """ + if isinstance(group_refs, str): + group = cls.env.ref(group_refs, raise_if_not_found=False) + if not group: + raise ValidationError(_("Group reference %s not found!") % group_refs) + action = [(3, group.id)] + elif isinstance(group_refs, list): + action = [] + for group_ref in group_refs: + group = cls.env.ref(group_ref, raise_if_not_found=False) + if not group: + raise ValidationError( + _("Group reference %s not found!") % group_ref + ) + action.append((3, group.id)) + else: + raise ValidationError(_("groups_ref must be string or list of strings!")) + user.write({"groups_id": action}) + + @classmethod + def write_and_invalidate(cls, records, **values): + """Write values and invalidate cache + + Args: + records (recordset): recordset to save values + **values (dict): values to set + """ + if values: + records.write(values) + records.invalidate_recordset(values.keys()) diff --git a/addons/cetmix_tower_server/tests/common_jets.py b/addons/cetmix_tower_server/tests/common_jets.py new file mode 100644 index 0000000..28654cb --- /dev/null +++ b/addons/cetmix_tower_server/tests/common_jets.py @@ -0,0 +1,732 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError +from odoo.tools import LazyTranslate + +from .common import TestTowerCommon + +_lt = LazyTranslate(__name__, default_lang="en_US") + + +class TestTowerJetsCommon(TestTowerCommon): + """ + Common test class for Jet and JetTemplate models with shared test data + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create jet states for testing + cls.state_initial = cls.JetState.create( + { + "name": "Test Initial", + "reference": "test_initial", + "sequence": 10, + "color": 1, + } + ) + cls.state_running = cls.JetState.create( + { + "name": "Test Running", + "reference": "test_running", + "sequence": 20, + "color": 2, + } + ) + cls.state_stopped = cls.JetState.create( + { + "name": "Test Stopped", + "reference": "test_stopped", + "sequence": 30, + "color": 3, + } + ) + cls.state_error = cls.JetState.create( + { + "name": "Test Error", + "reference": "test_error", + "sequence": 40, + "color": 4, + } + ) + + # Create transit states + cls.state_starting = cls.JetState.create( + { + "name": "Test Starting", + "reference": "test_starting", + "sequence": 15, + "color": 5, + } + ) + cls.state_stopping = cls.JetState.create( + { + "name": "Test Stopping", + "reference": "test_stopping", + "sequence": 25, + "color": 6, + } + ) + + # Create test states for pathfinding and adjacency tests + cls.state_a = cls.JetState.create( + { + "name": "Test State A", + "reference": "test_state_a", + "sequence": 30, + } + ) + cls.state_b = cls.JetState.create( + { + "name": "Test State B", + "reference": "test_state_b", + "sequence": 31, + } + ) + cls.state_c = cls.JetState.create( + { + "name": "Test State C", + "reference": "test_state_c", + "sequence": 32, + } + ) + cls.state_d = cls.JetState.create( + { + "name": "Test State D", + "reference": "test_state_d", + "sequence": 33, + } + ) + + # Create jet template for testing + cls.jet_template_test = cls.JetTemplate.create( + { + "name": "Test Jet Template", + "reference": "test_jet_template", + } + ) + + # Create dependency hierarchy for testing: + # Odoo -> Postgres, Nginx -> Docker -> Tower Core + # Level 1: Base dependencies + cls.jet_template_tower_core = cls.JetTemplate.create( + { + "name": "Tower Core", + "reference": "tower_core", + } + ) + + # Level 2: Infrastructure + cls.jet_template_docker = cls.JetTemplate.create( + { + "name": "Docker", + "reference": "docker", + } + ) + # Docker requires Tower Core to be running + cls._create_jet_template_dependency( + template=cls.jet_template_docker, + template_required=cls.jet_template_tower_core, + state_required_id=cls.state_running.id, + ) + + # Level 3: Services + cls.jet_template_nginx = cls.JetTemplate.create( + { + "name": "Nginx", + "reference": "nginx", + } + ) + # Nginx requires Docker to be running + cls._create_jet_template_dependency( + template=cls.jet_template_nginx, + template_required=cls.jet_template_docker, + state_required_id=cls.state_running.id, + ) + + # Level 3: Database + cls.jet_template_postgres = cls.JetTemplate.create( + { + "name": "Postgres", + "reference": "postgres", + } + ) + # Postgres requires Docker to be running + cls._create_jet_template_dependency( + template=cls.jet_template_postgres, + template_required=cls.jet_template_docker, + state_required_id=cls.state_running.id, + ) + + cls.jet_template_mariadb = cls.JetTemplate.create( + { + "name": "MariaDB", + "reference": "mariadb", + } + ) + # MariaDB requires Docker to be running + cls._create_jet_template_dependency( + template=cls.jet_template_mariadb, + template_required=cls.jet_template_docker, + state_required_id=cls.state_running.id, + ) + + # Level 5: Applications + cls.jet_template_odoo = cls.JetTemplate.create( + { + "name": "Odoo", + "reference": "odoo", + } + ) + # Odoo requires Postgres to be running + cls._create_jet_template_dependency( + template=cls.jet_template_odoo, + template_required=cls.jet_template_postgres, + state_required_id=cls.state_running.id, + ) + # Odoo requires Nginx to be running + cls._create_jet_template_dependency( + template=cls.jet_template_odoo, + template_required=cls.jet_template_nginx, + state_required_id=cls.state_running.id, + ) + + cls.jet_template_wordpress = cls.JetTemplate.create( + { + "name": "WordPress", + "reference": "wordpress", + } + ) + # WordPress requires MariaDB to be running + cls._create_jet_template_dependency( + template=cls.jet_template_wordpress, + template_required=cls.jet_template_mariadb, + state_required_id=cls.state_running.id, + ) + # WordPress requires Nginx to be running + cls._create_jet_template_dependency( + template=cls.jet_template_wordpress, + template_required=cls.jet_template_nginx, + state_required_id=cls.state_running.id, + ) + + # Level 6: E-commerce Integration + cls.jet_template_woocommerce_odoo = cls.JetTemplate.create( + { + "name": "WooCommerce with Odoo", + "reference": "woocommerce_odoo", + } + ) + # WooCommerce requires WordPress to be running + cls._create_jet_template_dependency( + template=cls.jet_template_woocommerce_odoo, + template_required=cls.jet_template_wordpress, + state_required_id=cls.state_running.id, + ) + # WooCommerce requires Odoo to be running + cls._create_jet_template_dependency( + template=cls.jet_template_woocommerce_odoo, + template_required=cls.jet_template_odoo, + state_required_id=cls.state_running.id, + ) + + # Create test jets for different templates + cls.jet_test = cls._create_jet( + name="Test Jet", + reference="test_jet", + template=cls.jet_template_test, + server=cls.server_test_1, + ) + + cls.jet_odoo = cls._create_jet( + name="Odoo Jet", + reference="odoo_jet", + template=cls.jet_template_odoo, + server=cls.server_test_1, + ) + + cls.jet_wordpress = cls._create_jet( + name="WordPress Jet", + reference="wordpress_jet", + template=cls.jet_template_wordpress, + server=cls.server_test_1, + ) + + cls.jet_woocommerce = cls._create_jet( + name="WooCommerce Jet", + reference="woocommerce_jet", + template=cls.jet_template_woocommerce_odoo, + server=cls.server_test_1, + ) + + # Add some dependencies with different state requirements for testing + # Create a monitoring template that requires services to be in "running" state + cls.jet_template_monitoring = cls.JetTemplate.create( + { + "name": "Monitoring", + "reference": "monitoring", + } + ) + + # Monitoring requires Odoo to be running (for business metrics) + cls._create_jet_template_dependency( + template=cls.jet_template_monitoring, + template_required=cls.jet_template_odoo, + state_required_id=cls.state_running.id, + ) + + # Create a backup template that requires services to be in "stopped" state + cls.jet_template_backup = cls.JetTemplate.create( + { + "name": "Backup", + "reference": "backup", + } + ) + + # Backup requires Postgres to be stopped for safe backup + cls._create_jet_template_dependency( + template=cls.jet_template_backup, + template_required=cls.jet_template_postgres, + state_required_id=cls.state_stopped.id, + ) + + # Create common actions for testing + cls.action_running_to_stopped = cls.JetAction.create( + { + "name": "Stop Action", + "reference": "stop_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_running.id, + "state_to_id": cls.state_stopped.id, + "state_transit_id": cls.state_stopping.id, + "priority": 10, + } + ) + + cls.action_stopped_to_running = cls.JetAction.create( + { + "name": "Start Action", + "reference": "start_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_stopped.id, + "state_to_id": cls.state_running.id, + "state_transit_id": cls.state_starting.id, + "priority": 10, + } + ) + + cls.action_running_to_error = cls.JetAction.create( + { + "name": "Error Action", + "reference": "error_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_running.id, + "state_to_id": cls.state_error.id, + "state_transit_id": cls.state_error.id, + "priority": 20, + } + ) + + cls.action_error_to_running = cls.JetAction.create( + { + "name": "Recover Action", + "reference": "recover_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_error.id, + "state_to_id": cls.state_running.id, + "state_transit_id": cls.state_starting.id, + "priority": 10, + } + ) + + cls.action_initial_to_running = cls.JetAction.create( + { + "name": "Initialize Action", + "reference": "initialize_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_initial.id, + "state_to_id": cls.state_running.id, + "state_transit_id": cls.state_starting.id, + "priority": 5, + } + ) + + # Create actions for pathfinding tests (A -> B -> C -> D) + cls.action_a_to_b = cls.JetAction.create( + { + "name": "Action A to B", + "reference": "action_a_to_b", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_a.id, + "state_to_id": cls.state_b.id, + "state_transit_id": cls.state_starting.id, + "priority": 10, + } + ) + + cls.action_b_to_c = cls.JetAction.create( + { + "name": "Action B to C", + "reference": "action_b_to_c", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_b.id, + "state_to_id": cls.state_c.id, + "state_transit_id": cls.state_stopping.id, + "priority": 10, + } + ) + + cls.action_c_to_d = cls.JetAction.create( + { + "name": "Action C to D", + "reference": "action_c_to_d", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_c.id, + "state_to_id": cls.state_d.id, + "state_transit_id": cls.state_stopping.id, + "priority": 10, + } + ) + + cls.action_a_to_c = cls.JetAction.create( + { + "name": "Action A to C (direct)", + "reference": "action_a_to_c", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_a.id, + "state_to_id": cls.state_c.id, + "state_transit_id": cls.state_stopping.id, + "priority": 10, + } + ) + + # Create border actions (create and destroy) + cls.action_create = cls.JetAction.create( + { + "name": "Create Action", + "reference": "create_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": False, # No initial state + "state_to_id": cls.state_running.id, + "state_transit_id": cls.state_starting.id, + "priority": 1, + } + ) + + cls.action_destroy = cls.JetAction.create( + { + "name": "Destroy Action", + "reference": "destroy_action", + "jet_template_id": cls.jet_template_test.id, + "state_from_id": cls.state_running.id, + "state_to_id": False, # No final state + "state_transit_id": cls.state_stopping.id, + "priority": 1, + } + ) + + # Create a clean template for tests that need isolation from common actions + cls.clean_template = cls.JetTemplate.create( + { + "name": "Clean Template", + "reference": "clean_template", + } + ) + + # Create waypoint template for testing + cls.waypoint_template = cls.env["cx.tower.jet.waypoint.template"].create( + { + "name": "Test Waypoint Template", + "jet_template_id": cls.jet_template_test.id, + } + ) + cls.waypoint_template_2 = cls.env["cx.tower.jet.waypoint.template"].create( + { + "name": "Test Waypoint Template 2", + "jet_template_id": cls.jet_template_test.id, + } + ) + + # Create waypoint for testing + cls.waypoint = cls.env["cx.tower.jet.waypoint"].create( + { + "name": "Test Waypoint", + "jet_id": cls.jet_test.id, + "waypoint_template_id": cls.waypoint_template.id, + } + ) + + # Model references reused by helpers + cls.JetDependency = cls.env["cx.tower.jet.dependency"] + cls.JetWaypointTemplate = cls.env["cx.tower.jet.waypoint.template"] + cls.JetWaypoint = cls.env["cx.tower.jet.waypoint"] + + @classmethod + def _create_jet( + cls, + name, + reference, + template=None, + server=None, + user_ids=None, + manager_ids=None, + server_user_ids=None, + server_manager_ids=None, + with_user=None, + ): + """ + Helper method to create a jet + with specified access configuration + + Args: + name (str): Name of the jet + reference (str): Reference of the jet + template (cx.tower.jet.template): Template for the jet + (if None, defaults to jet_template_test) + server (cx.tower.server): Server for the jet + (if None, defaults to server_test_1) + user_ids (list): List of user IDs for the jet + manager_ids (list): List of manager IDs for the jet + server_user_ids (list): List of user IDs for the server + server_manager_ids (list): List of manager IDs for the server + with_user (res.users): Optional user + to create the jet as (for access rule testing) + + Returns: + cx.tower.jet: Created jet record + """ + if template is None: + template = cls.jet_template_test + if server is None: + server = cls.server_test_1 + + # Configure server access + if server_user_ids is not None or server_manager_ids is not None: + server.write( + { + "user_ids": server_user_ids + if server_user_ids is not None + else [(5, 0, 0)], + "manager_ids": server_manager_ids + if server_manager_ids is not None + else [(5, 0, 0)], + } + ) + + # Create jet with access configuration + jet_vals = { + "name": name, + "reference": reference, + "jet_template_id": template.id, + "server_id": server.id, + "user_ids": user_ids if user_ids is not None else [(5, 0, 0)], + "manager_ids": manager_ids if manager_ids is not None else [(5, 0, 0)], + } + jet_model = cls.Jet.with_user(with_user) if with_user else cls.Jet + jet = jet_model.create(jet_vals) + return jet + + @classmethod + def _create_jet_dependency( + cls, + jet_name, + jet_reference, + depends_on_name, + depends_on_reference, + jet_user_ids=None, + jet_manager_ids=None, + depends_on_user_ids=None, + depends_on_manager_ids=None, + jet_server_user_ids=None, + jet_server_manager_ids=None, + depends_on_server_user_ids=None, + depends_on_server_manager_ids=None, + with_user=None, + jet_template=None, + depends_on_template=None, + ): + """Helper method to create a dependency between two jets + + Args: + jet_name (str): Name of the main jet + jet_reference (str): Reference of the main jet + depends_on_name (str): Name of the jet this depends on + depends_on_reference (str): Reference of the jet this depends on + jet_user_ids (list): User IDs for the main jet + jet_manager_ids (list): Manager IDs for the main jet + depends_on_user_ids (list): User IDs for the depends_on jet + depends_on_manager_ids (list): Manager IDs for the depends_on jet + jet_server_user_ids (list): User IDs for the main jet's server + jet_server_manager_ids (list): Manager IDs for the main jet's server + depends_on_server_user_ids (list): User IDs for the depends_on jet's server + depends_on_server_manager_ids (list): Manager IDs for the depends_on + jet's server (if None, defaults to server_test_1) + with_user (res.users): Optional user to create the dependency as + (for access rule testing) + jet_template: Optional template for the main jet + (if None, defaults to jet_template_test) + depends_on_template: Optional template for the depends_on jet + (if None, defaults to jet_template_tower_core) + + Returns: + tuple: (jet, depends_on_jet, dependency) + """ + + # Use different templates to avoid self-dependency error + # Default to jet_template_test for the main jet and + # jet_template_tower_core for depends_on + jet_template = jet_template or cls.jet_template_test + depends_on_template = depends_on_template or cls.jet_template_tower_core + + # Check if template dependency already exists, if so reuse it + template_dep = cls.JetTemplateDependency.search( + [ + ("template_id", "=", jet_template.id), + ("template_required_id", "=", depends_on_template.id), + ], + limit=1, + ) + if not template_dep: + # Create template dependency first + # to ensure templates are different + ( + _template, + _required_template, + template_dep, + ) = cls._create_jet_template_dependency( + template=jet_template, + template_required=depends_on_template, + ) + + # Create first jet + # (always create as root to ensure proper setup) + jet = cls._create_jet( + jet_name, + jet_reference, + template=jet_template, + user_ids=jet_user_ids, + manager_ids=jet_manager_ids, + server_user_ids=jet_server_user_ids, + server_manager_ids=jet_server_manager_ids, + with_user=None, # Create as root to ensure proper setup + ) + + # Create second jet (depended on) + # (also create as root to ensure proper setup) + depends_on_jet = cls._create_jet( + depends_on_name, + depends_on_reference, + template=depends_on_template, + user_ids=depends_on_user_ids, + manager_ids=depends_on_manager_ids, + server_user_ids=depends_on_server_user_ids, + server_manager_ids=depends_on_server_manager_ids, + with_user=None, # Create as root to ensure proper setup, + ) + + # If creating dependency with a user context, verify access first + if with_user: + # Verify manager can access both jets by searching in their context + # This ensures the access rule domain can evaluate correctly + # when creating the dependency + jet_search = cls.Jet.with_user(with_user).search([("id", "=", jet.id)]) + depends_search = cls.Jet.with_user(with_user).search( + [("id", "=", depends_on_jet.id)] + ) + + if not jet_search or not depends_search: + raise AccessError( + _lt("Manager must have access to both jets before creating") + ) + # Force cache refresh to ensure Many2one relations are accessible, + jet.invalidate_recordset(["manager_ids", "user_ids"]) + depends_on_jet.invalidate_recordset(["user_ids", "manager_ids"]) + + # Create dependency + dependency_vals = { + "jet_id": jet.id, + "jet_depends_on_id": depends_on_jet.id, + "jet_template_dependency_id": template_dep.id, + } + dependency_model = ( + cls.JetDependency.with_user(with_user) if with_user else cls.JetDependency + ) + dependency = dependency_model.create(dependency_vals) + + return jet, depends_on_jet, dependency + + @classmethod + def _create_jet_template_dependency( + cls, + template_name=None, + template_reference=None, + access_level="2", + user_ids=None, + manager_ids=None, + template=None, + template_required=None, + state_required_id=None, + with_user=None, + ): + """Helper method to create a dependency between two templates + + Args: + template_name (str, optional): Name of the template (if creating new) + template_reference (str, optional): Reference of the template + (if creating new) + access_level (str): Access level for the template + (if creating new, defaults to "2") + user_ids (list): List of user IDs for the template + manager_ids (list): List of manager IDs for the template + template: Existing template record or None to create new + (if None, defaults to jet_template_test) + template_required: Existing required template record or None to create new + (if None, defaults to jet_template_tower_core) + state_required_id: Optional state required ID for the dependency + + Returns: + tuple: (template, required_template, dependency) + """ + # Create or use existing template + if template is None: + template_vals = { + "name": template_name, + "reference": template_reference, + "access_level": access_level, + "user_ids": user_ids if user_ids is not None else [(5, 0, 0)], + "manager_ids": manager_ids if manager_ids is not None else [(5, 0, 0)], + } + template = cls.JetTemplate.create(template_vals) + + # Create or use existing required template + if template_required is None: + required_template = cls.JetTemplate.create( + { + "name": "Required Template", + "reference": "required_template", + "access_level": "2", + } + ) + else: + required_template = template_required + + # Create dependency + dependency_vals = { + "template_id": template.id if hasattr(template, "id") else template, + "template_required_id": required_template.id + if hasattr(required_template, "id") + else required_template, + "state_required_id": state_required_id + if state_required_id is not None + else cls.state_running.id, + } + dependency_model = ( + cls.JetTemplateDependency.with_user(with_user) + if with_user + else cls.JetTemplateDependency + ) + dependency = dependency_model.create(dependency_vals) + + return template, required_template, dependency diff --git a/addons/cetmix_tower_server/tests/test_cetmix_tower.py b/addons/cetmix_tower_server/tests/test_cetmix_tower.py new file mode 100644 index 0000000..031396b --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_cetmix_tower.py @@ -0,0 +1,244 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from unittest.mock import patch + +from odoo.tools import mute_logger + +from ..models.constants import GENERAL_ERROR, NOT_FOUND, SSH_CONNECTION_ERROR +from .common import TestTowerCommon + + +class TestCetmixTower(TestTowerCommon): + """ + Tests for the 'cetmix.tower' helper model + """ + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_server_set_variable_value(self): + """Test plan line action naming""" + + # -- 1-- + # Create new variable + variable_meme = self.Variable.create( + {"name": "Meme Variable", "reference": "meme_variable"} + ) + + # Set variable for Server 1 + result = self.CetmixTower.server_set_variable_value( + server_reference=self.server_test_1.reference, + variable_reference=variable_meme.reference, + value="Doge", + ) + + # Check exit code + self.assertEqual(result["exit_code"], 0, "Exit code must be equal to 0") + + # Check variable value + variable_value = self.VariableValue.search( + [("variable_id", "=", variable_meme.id)] + ) + + self.assertEqual(len(variable_value), 1, "Must be 1 result") + self.assertEqual(variable_value.value_char, "Doge", "Must be Doge!") + + # -- 2 -- + # Update existing variable value + + # Set variable for Server 1 + result = self.CetmixTower.server_set_variable_value( + server_reference=self.server_test_1.reference, + variable_reference=variable_meme.reference, + value="Pepe", + ) + + # Check exit code + self.assertEqual(result["exit_code"], 0, "Exit code must be equal to 0") + + # Check variable value + variable_value = self.VariableValue.search( + [("variable_id", "=", variable_meme.id)] + ) + + self.assertEqual(len(variable_value), 1, "Must be 1 result") + self.assertEqual(variable_value.value_char, "Pepe", "Must be Pepe!") + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_server_get_variable_value(self): + """Test getting value for server""" + variable_meme = self.Variable.create( + {"name": "Meme Variable", "reference": "meme_variable"} + ) + global_value = self.VariableValue.create( + {"variable_id": variable_meme.id, "value_char": "Memes Globalvs"} + ) + + # -- 1 -- Get value for Server with no server value defined + value = self.CetmixTower.server_get_variable_value( + self.server_test_1.reference, variable_meme.reference + ) + self.assertEqual(value, global_value.value_char) + + # -- 2 -- Add server value and try again + server_value = self.VariableValue.create( + { + "variable_id": variable_meme.id, + "value_char": "Memes Servervs", + "server_id": self.server_test_1.id, + } + ) + value = self.CetmixTower.server_get_variable_value( + self.server_test_1.reference, variable_meme.reference + ) + self.assertEqual(value, server_value.value_char) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_server_check_ssh_connection(self): + """ + Test SSH connection check with a mocked function that + either returns a dictionary or raises an exception. + """ + + # Test successful connection + result = self.env["cetmix.tower"].server_check_ssh_connection( + self.server_test_1.reference, + ) + self.assertEqual(result["exit_code"], 0, "SSH connection should be successful.") + + def test_ssh_connection(this, *args, **kwargs): + return {"status": GENERAL_ERROR} + + with patch.object( + self.registry["cx.tower.server"], "test_ssh_connection", test_ssh_connection + ): + # Test connection timeout after max attempts + result = self.env["cetmix.tower"].server_check_ssh_connection( + self.server_test_1.reference, + attempts=2, + wait_time=1, + ) + self.assertEqual( + result["exit_code"], + SSH_CONNECTION_ERROR, + "SSH connection should timeout after maximum attempts.", + ) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_server_run_command(self): + """Test running command on server""" + # Create test command + command = self.Command.create( + { + "name": "Test Command", + "reference": "test_command", + "code": "echo 'Hello World'", + "action": "ssh_command", + } + ) + + # -- 1 -- Test with non-existent server + result = self.CetmixTower.server_run_command( + server_reference="non_existent", + command_reference=command.reference, + ) + self.assertEqual(result["exit_code"], NOT_FOUND) + self.assertEqual(result["message"], "Server not found") + + # -- 2 -- Test with non-existent command + result = self.CetmixTower.server_run_command( + server_reference=self.server_test_1.reference, + command_reference="non_existent", + ) + self.assertEqual(result["exit_code"], NOT_FOUND) + self.assertEqual(result["message"], "Command not found") + + # -- 3 -- Test successful command execution + result = self.CetmixTower.server_run_command( + server_reference=self.server_test_1.reference, + command_reference=command.reference, + ) + self.assertEqual(result["exit_code"], 0) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_server_run_flight_plan(self): + """Test running flight plan on server""" + # Create test flight plan + flight_plan = self.Plan.create( + { + "name": "Test Flight Plan", + "reference": "test_flight_plan", + } + ) + + # -- 1 -- Test with non-existent server + result = self.CetmixTower.server_run_flight_plan( + server_reference="non_existent", + flight_plan_reference=flight_plan.reference, + ) + self.assertFalse(result, "Should return False for non-existent server") + + # -- 2 -- Test with non-existent flight plan + result = self.CetmixTower.server_run_flight_plan( + server_reference=self.server_test_1.reference, + flight_plan_reference="non_existent", + ) + self.assertFalse(result, "Should return False for non-existent flight plan") + + # -- 3 -- Test successful flight plan execution + with patch.object(self.server_test_1.__class__, "run_flight_plan") as mock_run: + # Setup mock to return a plan log record + plan_log = self.PlanLog.create( + { + "name": "Test Log", + "server_id": self.server_test_1.id, + "plan_id": flight_plan.id, + } + ) + mock_run.return_value = plan_log + + # Run flight plan + result = self.CetmixTower.server_run_flight_plan( + server_reference=self.server_test_1.reference, + flight_plan_reference=flight_plan.reference, + ) + + # Verify result + self.assertEqual(result, plan_log, "Should return plan log record") + mock_run.assert_called_once_with(flight_plan) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_server_run_command_with_variable_values(self): + """Test running command with variable values""" + # Create test command + command = self.Command.create( + { + "name": "Test Command", + "reference": "test_command", + "code": "result = {'exit_code': 0, 'message': {{ test_version }}}", + "action": "python_code", + } + ) + # Set variable value for the server + self.CetmixTower.server_set_variable_value( + server_reference=self.server_test_1.reference, + variable_reference=self.variable_version.reference, + value="prod", + ) + + # -- 1 -- + # Run command without modifying variable values + result = self.CetmixTower.server_run_command( + server_reference=self.server_test_1.reference, + command_reference=command.reference, + ) + self.assertEqual(result["exit_code"], 0) + self.assertEqual(result["message"], "prod") + + # -- 2 -- + # Run command with modified variable values + result = self.CetmixTower.server_run_command( + server_reference=self.server_test_1.reference, + command_reference=command.reference, + **{"test_version": "dev"}, + ) + self.assertEqual(result["exit_code"], 0) + self.assertEqual(result["message"], "dev") diff --git a/addons/cetmix_tower_server/tests/test_command.py b/addons/cetmix_tower_server/tests/test_command.py new file mode 100644 index 0000000..9e3a647 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_command.py @@ -0,0 +1,1964 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import timedelta +from unittest.mock import patch + +from odoo.exceptions import AccessError, ValidationError +from odoo.fields import Datetime +from odoo.tests import Form +from odoo.tools import mute_logger + +from ..models.constants import ( + ANOTHER_COMMAND_RUNNING, + COMMAND_TIMED_OUT, + COMMAND_TIMED_OUT_MESSAGE, + GENERAL_ERROR, +) +from .common import TestTowerCommon + + +class TestTowerCommand(TestTowerCommon): + """ + Test the command model. + + Important! + As this model inherits from the `cx.tower.template.mixin` + we will tests template rendering methods in this class too. + + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Save variable values for Server 1 + with Form(cls.server_test_1) as f: + with f.variable_value_ids.new() as line: + line.variable_id = cls.variable_dir + line.value_char = "test-odoo-1" + with f.variable_value_ids.new() as line: + line.variable_id = cls.variable_path + line.value_char = "/opt/tower" + f.save() + + # Secret key + cls.secret_folder_key = cls.Key.create( + { + "name": "Folder", + "reference": "FOLDER", + "secret_value": "secretFolder", + "key_type": "s", + } + ) + cls.secret_python_key = cls.Key.create( + { + "name": "python", + "reference": "PYTHON", + "secret_value": "secretPythonCode", + "key_type": "s", + } + ) + + # secret value as multi line string + cls.python_ssh_key = cls.Key.create( + { + "name": "Test Python SSH Key", + "reference": "test_python_ssh_key", + "key_type": "s", + "secret_value": """ + Python + much + key + """, + } + ) + + cls.secret_test_rsa_key = cls.Key.create( + { + "name": "test rsa", + "reference": "test_rsa", + "secret_value": """-----BEGIN RSA PRIVATE KEY----- +VeryMuchNiceKey +-----END RSA PRIVATE KEY----- """, + "key_type": "s", + } + ) + # Command + cls.command_create_new_command = cls.Command.create( + { + "name": "Create new command", + "action": "python_code", + "code": """ +server_name = {{ tower.server.name }} +if server_name and #!cxtower.secret.FOLDER!# == "secretFolder": + # We don't actually create a new command because it will raise + # access error if user doesn't have access to 'create' operation. + # Instead we just return a dummy command result. + command = "new command" + result = {"exit_code": 0, "message": "New command was created"} +else: + result = {"exit_code": -100, "message": "error"} + """, + } + ) + + cls.command_python_command_1 = cls.Command.create( + { + "name": "Python command with secret #1", + "action": "python_code", + "code": """ +result = { + "exit_code": 0, + "message": #!cxtower.secret.PYTHON!#, +} + """, + } + ) + + cls.command_python_command_2 = cls.Command.create( + { + "name": "Python command with secret #2", + "action": "python_code", + "code": """ +result = { + "exit_code": 0, + "message": 'We use #!cxtower.secret.PYTHON!#' , +} + """, + } + ) + + cls.command_python_command_3 = cls.Command.create( + { + "name": "Python command with secret #3", + "action": "python_code", + "code": """ +result = { + "exit_code": 0, + "message": ""#!cxtower.secret.test_rsa!#"" , +} + """, + } + ) + + cls.command_python_command_4 = cls.Command.create( + { + "name": "Python command with secret #4", + "action": "python_code", + "code": """ +top_secret = #!cxtower.secret.test_python_ssh_key!# +result = { + "exit_code": 0, + "message": top_secret , +} + """, + } + ) + cls.server = cls.Server.create( + { + "name": "Test Server", + "user_ids": [(6, 0, [cls.user.id])], + "manager_ids": [(6, 0, [cls.manager.id])], + "ssh_username": "test", + "ssh_password": "test", + "ip_v4_address": "127.0.0.1", + } + ) + + def _create_command(self, **kwargs): + """Helper to create a command record with default values.""" + vals = { + "name": "Test Command", + "access_level": "1", # override default + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [])], + } + if kwargs: + vals.update(kwargs) + return self.Command.create(vals) + + def test_user_read_access(self): + """ + For a user: + Read access is allowed if access_level == "1" and either the command's + own user_ids includes the user OR a related server (via server_ids) + includes the user in its user_ids. + """ + # Case 1: Command with access_level "1" and user in command.user_ids. + cmd1 = self._create_command( + **{ + "access_level": "1", + "user_ids": [(6, 0, [self.user.id])], + } + ) + recs1 = self.Command.with_user(self.user).search([("id", "=", cmd1.id)]) + self.assertIn( + cmd1, + recs1, + "User should see the command if in command.user_ids" + " and access_level == '1'.", + ) + + # Case 2: Command with access_level "1" and user not in command.user_ids + # but in a related server. + cmd2 = self._create_command( + **{ + "access_level": "1", + "user_ids": [(6, 0, [])], + "server_ids": [(6, 0, [self.server.id])], + } + ) + recs2 = self.Command.with_user(self.user).search([("id", "=", cmd2.id)]) + self.assertIn( + cmd2, + recs2, + "User should see the command if related server.user_ids includes the user.", + ) + + # Negative: If access_level is "1" but neither command.user_ids + # nor server_ids.user_ids includes the user. + cmd3 = self._create_command( + **{ + "access_level": "1", + "user_ids": [(6, 0, [])], + "server_ids": [(6, 0, [])], + } + ) + recs3 = self.Command.with_user(self.user).search([("id", "=", cmd3.id)]) + self.assertNotIn( + cmd3, + recs3, + "User should not see the command if not granted access.", + ) + + def test_manager_read_access(self): + """ + For a manager: + Allowed to read a command if access_level <= "2" AND + (either the command itself grants access via user_ids or manager_ids + OR there are no related servers OR a related server grants access via + its user_ids or manager_ids). + """ + # Case 1: Command with access_level "2" and command.manager_ids + # includes the manager but the server is not related to the command. + another_server = self.Server.create( + { + "name": "Another Server", + "ip_v4_address": "127.0.0.2", + "ssh_username": "test", + "ssh_password": "test", + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + } + ) + cmd1 = self._create_command( + **{ + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + "server_ids": [(6, 0, [another_server.id])], + } + ) + recs1 = self.Command.with_user(self.manager).search([("id", "=", cmd1.id)]) + self.assertIn( + cmd1, + recs1, + "Manager should see the command if in command.manager_ids" + " and access_level <= '2'.", + ) + + # Case 2: Command with access_level "2" that does not grant access + # on the command itself, but a related server grants access via + # but a related server grants access via its manager_ids. + cmd2 = self._create_command( + **{ + "access_level": "2", + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [self.server.id])], + } + ) + recs2 = self.Command.with_user(self.manager).search([("id", "=", cmd2.id)]) + self.assertIn( + cmd2, + recs2, + "Manager should see the command if related server.manager_ids" + " includes the manager.", + ) + + # Positive: Command with access_level "2" without any granted access. + cmd3 = self._create_command( + **{ + "access_level": "2", + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [])], + } + ) + recs3 = self.Command.with_user(self.manager).search([("id", "=", cmd3.id)]) + self.assertIn( + cmd3, + recs3, + "Manager should see the command if not granted access " + "but not related to any server.", + ) + + # Case 3: Remove from manager in the cmd1. + # Should not see the command because it belongs to another server. + cmd1.manager_ids = [(3, self.manager.id)] + recs4 = self.Command.with_user(self.manager).search([("id", "=", cmd1.id)]) + self.assertNotIn( + cmd1, + recs4, + "Manager should not see the command if " + "removed from command.manager_ids." + " and command belongs to another server.", + ) + + def test_manager_write_create_access(self): + """ + For a manager: + Allowed to write and create a command if access_level <= "2" AND + the command's own manager_ids includes the manager. + """ + # Case: Command with access_level "2" and manager_ids includes the manager. + cmd1 = self._create_command( + **{ + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + try: + cmd1.with_user(self.manager).write({"name": "Manager Updated Command"}) + except AccessError: + self.fail( + "Manager should be able to update the command " + "if in command.manager_ids." + ) + self.assertEqual(cmd1.with_user(self.manager).name, "Manager Updated Command") + + # Attempt to create a command as manager without including their ID + # in manager_ids should fail. + cmd_invalid_vals = { + "name": "Invalid Manager Create", + "access_level": "2", + "manager_ids": [(6, 0, [])], + "action": "python_code", + "code": "print('dummy')", + } + with self.assertRaises(AccessError): + self.Command.with_user(self.manager).create(cmd_invalid_vals) + + def test_manager_unlink_access(self): + """ + For a manager: + Allowed to delete a command if access_level <= "2", + the current user is the record creator, + AND the command's own manager_ids includes the manager. + """ + # Scenario 1: Command created by the manager with manager_ids + # including the manager. + cmd1 = self.Command.with_user(self.manager).create( + { + "name": "Manager Created Command", + "access_level": "2", + } + ) + try: + cmd1.with_user(self.manager).unlink() + except AccessError: + self.fail( + "Manager should be able to delete a command " + "they created if in command.manager_ids." + ) + + # Scenario 2: Command created by someone else + # even if manager_ids includes the manager. + cmd2 = self._create_command( + **{ + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + with self.assertRaises(AccessError): + cmd2.with_user(self.manager).unlink() + + def test_root_unrestricted_access(self): + """ + For a root user: + Unlimited access: root can read, write, create, and delete commands + regardless of access_level or related servers. + """ + cmd = self._create_command( + **{ + "access_level": "3", # above the threshold for managers + } + ) + recs = self.Command.with_user(self.root).search([("id", "=", cmd.id)]) + self.assertIn( + cmd, + recs, + "Root should see the command regardless of restrictions.", + ) + try: + cmd.with_user(self.root).write({"name": "Root Updated Command"}) + except AccessError: + self.fail( + "Root should be able to update the command " "without restrictions." + ) + self.assertEqual(cmd.with_user(self.root).name, "Root Updated Command") + cmd2 = self.Command.with_user(self.root).create( + { + "name": "Root Created Command", + "access_level": "3", + "action": "python_code", + "code": "print('root')", + } + ) + self.assertTrue( + cmd2, + "Root should be able to create a command " "without restrictions.", + ) + cmd2.with_user(self.root).unlink() + recs_after = self.Command.with_user(self.root).search([("id", "=", cmd2.id)]) + self.assertFalse( + recs_after, + "Root should be able to delete the command without restrictions.", + ) + + def test_ssh_command_prepare_method_without_path(self): + """Test ssh command preparation in different modes without path""" + + server = self.server_test_1 + + single_command = "ls -a /tmp" + multiple_commands = "ls -a /tmp && mkdir /tmp/test" + + sudo_mode = "p" + + # Prepare single command for sudo with password + cmd = server._prepare_ssh_command(single_command, path=None, sudo=sudo_mode) + self.assertEqual( + cmd, + [f"{self.sudo_prefix} {single_command}"], + msg=( + "Single command for sudo with password should be " + "equal to list with the original command" + "as an only element" + ), + ) + + # Prepare multiple commands for sudo with password + cmd = server._prepare_ssh_command(multiple_commands, path=None, sudo=sudo_mode) + self.assertEqual( + cmd, + [ + f"{self.sudo_prefix} ls -a /tmp", + f"{self.sudo_prefix} mkdir /tmp/test", + ], + msg=( + "Multiple commands with sudo with password should be " + "a list of separated commands from original line" + ), + ) + + sudo_mode = "n" + + # Prepare single command for sudo without password + cmd = server._prepare_ssh_command(single_command, path=None, sudo=sudo_mode) + self.assertEqual( + cmd, + f"{self.sudo_prefix} {single_command}", + msg=( + "Single command with sudo without password should be " + f'equal to the original command prefixed with "{self.sudo_prefix}"' + ), + ) + + # Prepare multiple commands for sudo without password + cmd = server._prepare_ssh_command(multiple_commands, path=None, sudo=sudo_mode) + self.assertEqual( + cmd, + f"{self.sudo_prefix} ls -a /tmp && {self.sudo_prefix} mkdir /tmp/test", + msg=( + "Multiple commands with sudo with password should be " + "a re-joined string from list of separated original " + f'each prefixed with "{self.sudo_prefix}"' + ), + ) + + # Prepare single command without sudo + cmd = server._prepare_ssh_command(single_command) + self.assertEqual( + cmd, + single_command, + msg=( + "Single command without sudo should be " + "equal to the original command " + ), + ) + + # Prepare multiple without sudo + cmd = server._prepare_ssh_command(multiple_commands) + self.assertEqual( + cmd, + multiple_commands, + msg=( + "Multiple commands without sudo should be " + "equal to the original line of commands" + ), + ) + + def test_ssh_command_prepare_method_with_path(self): + """Test command preparation in different modes with path""" + + server = self.server_test_1 + + single_command = "ls -a /tmp" + multiple_commands = "ls -a /tmp && mkdir /tmp/test" + path = "/home/doge" + + sudo_mode = "p" + + # Prepare single command for sudo with password + cmd = server._prepare_ssh_command(single_command, path=path, sudo=sudo_mode) + self.assertEqual( + cmd, + [f"cd {path}", f"{self.sudo_prefix} {single_command}"], + msg=( + "Single command for sudo with password should be " + "equal to list of two elements:" + " change directory and original command" + ), + ) + + # Prepare multiple commands for sudo with password + cmd = server._prepare_ssh_command(multiple_commands, path=path, sudo=sudo_mode) + self.assertEqual( + cmd, + [ + f"cd {path}", + f"{self.sudo_prefix} ls -a /tmp", + f"{self.sudo_prefix} mkdir /tmp/test", + ], + msg=( + "Multiple commands with sudo with password should be " + "a list of separated commands from original line" + ), + ) + + sudo_mode = "n" + + # Prepare single command for sudo without password + cmd = server._prepare_ssh_command(single_command, path=path, sudo=sudo_mode) + self.assertEqual( + cmd, + f"cd {path} && {self.sudo_prefix} {single_command}", + msg=( + "Single command with sudo without password should be " + f'equal to the original command prefixed with "{self.sudo_prefix}"' + ), + ) + + # Prepare multiple commands for sudo without password + cmd = server._prepare_ssh_command(multiple_commands, path=path, sudo=sudo_mode) + self.assertEqual( + cmd, + f"cd {path} && {self.sudo_prefix} ls -a /tmp && {self.sudo_prefix} mkdir /tmp/test", # noqa + msg=( + "Multiple commands with sudo with password should be " + "a re-joined string from list of separated original " + f'each prefixed with "{self.sudo_prefix}"' + ), + ) + + # Prepare single command without sudo + cmd = server._prepare_ssh_command(single_command, path=path) + self.assertEqual( + cmd, + f"cd {path} && {single_command}", + msg=( + "Single command for without sudo should be " + "equal to the the original command" + "with 'cd {{ path }} && ' prefix" + ), + ) + + # Prepare multiple commands without sudo + cmd = server._prepare_ssh_command(multiple_commands, path=path) + self.assertEqual( + cmd, + f"cd {path} && {multiple_commands}", # noqa + msg=( + "Multiple commands without sudo should be " + "original command with 'change directory' command prepended" + ), + ) + + def test_ssh_command_no_split_for_sudo_without_path(self): + """If no_split_for_sudo=True, even '&&' shouldn’t split into a list.""" + server = self.server_test_1 + cmd_line = "echo a && echo b" + sudo_mode = "p" + result = server._prepare_ssh_command( + cmd_line, sudo=sudo_mode, no_split_for_sudo=True + ) + expected = [f"{self.sudo_prefix} {cmd_line}"] + self.assertEqual( + result, expected, "With no_split_for_sudo, '&&' must not produce a list" + ) + + def test_ssh_command_no_split_for_sudo_with_path(self): + """Same, but with a custom cwd prefix.""" + server = self.server_test_1 + cmd_line = "echo a && echo b" + path = "/tmp" + sudo_mode = "p" + result = server._prepare_ssh_command( + cmd_line, path=path, sudo=sudo_mode, no_split_for_sudo=True + ) + expected = [f"cd {path}", f"{self.sudo_prefix} {cmd_line}"] + self.assertEqual( + result, + expected, + "With no_split_for_sudo and path, the entire '&&' string remains un-split", + ) + + def test_server_render_command(self): + """Test rendering command using `_render_command` method + of cx.tower.server + """ + + # -- 1 -- + # Test with default path + rendered_command = self.server_test_1._render_command(self.command_create_dir) + rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" + rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 2 -- + # Test with custom path + rendered_command = self.server_test_1._render_command( + self.command_create_dir, path="/such/much/path" + ) + rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" + rendered_path_expected = "/such/much/path" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 3 -- + # Set variable_path to None and check again + variable_value_path = self.server_test_1.variable_value_ids.filtered( + lambda var_val: var_val.variable_id.id == self.variable_path.id + ) + variable_value_path.value_char = None + rendered_command = self.server_test_1._render_command(self.command_create_dir) + rendered_code_expected = "cd False && mkdir test-odoo-1" + rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 4 -- + # Set both path and code to None + self.write_and_invalidate( + self.command_create_dir, **{"code": None, "path": None} + ) + rendered_command = self.server_test_1._render_command(self.command_create_dir) + + self.assertFalse( + rendered_command["rendered_code"], "Rendered code doesn't match" + ) + self.assertFalse( + rendered_command["rendered_path"], "Rendered path doesn't match" + ) + + def test_server_render_command_with_custom_variable_values(self): + """Test rendering command using `_render_command` method + of cx.tower.server with custom variable values + """ + self.write_and_invalidate( + self.server_test_1, + **{"user_ids": [(4, self.user.id)], "manager_ids": [(4, self.manager.id)]}, + ) + # -- 1 -- + # Set custom variable values + custom_variable_values = { + "test_path_": "/pepe/memes", + "other_path": "/etc/chad", + } + + # Modify command path + self.write_and_invalidate( + self.command_create_dir, + **{"path": "{{ other_path }}/{{ tower.server.username }}"}, + ) + + # Render command + rendered_command = self.server_test_1.with_user(self.manager)._render_command( + self.command_create_dir, custom_variable_values=custom_variable_values + ) + rendered_code_expected = "cd /pepe/memes && mkdir test-odoo-1" + rendered_path_expected = f"/etc/chad/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 2 -- + # Test with user who doesn't have access to the server + rendered_command = self.server_test_1.with_user(self.user)._render_command( + self.command_create_dir, custom_variable_values=custom_variable_values + ) + rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" + rendered_path_expected = f"None/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + def test_server_render_command_variable_with_value_modifier(self): + """Test rendering command using `_render_command` method + of cx.tower.server. + Use variable with value modifier for testing. + """ + + # -- 1 -- + # Set modifiers for variables + modifier_for_path = """ +if 'opt' in value: + result = value.replace('opt', 'home') +else: + result = value +""" + self.variable_path.applied_expression = modifier_for_path + + modifier_for_dir = """ +pattern = r'(?i)odoo' +replacement = 'sap' +result = re.sub(pattern, replacement, value) +""" + self.variable_dir.applied_expression = modifier_for_dir + + # -- 1 -- + # Test with default path + rendered_command = self.server_test_1._render_command(self.command_create_dir) + rendered_code_expected = "cd /home/tower && mkdir test-sap-1" + rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 2 -- + # Set invalid expression modifier + self.variable_path.applied_expression = "invalid" + with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_variable"): + rendered_command = self.server_test_1._render_command( + self.command_create_dir + ) + rendered_code_expected = "cd /opt/tower && mkdir test-sap-1" + rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 3 -- + # Test with variable in variable value + complex_variable = self.Variable.create( + { + "name": "Complex Variable", + "applied_expression": "result = value.replace('opt', 'meme')", + } + ) + # Create a complex variable value + self.VariableValue.create( + { + "variable_id": complex_variable.id, + "value_char": "{{ test_path_ }}/{{ test_dir }}", + } + ) + command_with_complex_variable = self.Command.create( + { + "name": "Command with complex variable", + "code": "cd {{ complex_variable }}", + "action": "ssh_command", + } + ) + with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_variable"): + rendered_command = self.server_test_1._render_command( + command_with_complex_variable + ) + rendered_code_expected = "cd /meme/tower/test-sap-1" + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + + # -- 4 -- + # Remove modifier from variable "Path" and check again + self.variable_dir.applied_expression = None + with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_variable"): + rendered_command = self.server_test_1._render_command( + command_with_complex_variable + ) + rendered_code_expected = "cd /meme/tower/test-odoo-1" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + + def test_render_code_generic(self): + """Test generic (aka ssh) code template direct rendering""" + + # Only 'test_path_' must be rendered + args = {"test_path_": "/tmp", "test_os": "debian"} + res = self.command_create_dir.render_code(**args) + rendered_code = res.get(self.command_create_dir.id) + rendered_code_expected = "cd /tmp && mkdir " + self.assertEqual( + rendered_code, + rendered_code_expected, + msg=f"Must be rendered as '{rendered_code_expected}'", + ) + + # 'test_path_' and 'dir' must be rendered + args = {"test_path_": "/tmp", "os": "debian", "test_dir": "odoo"} + res = self.command_create_dir.render_code(**args) + rendered_code = res.get(self.command_create_dir.id) + self.assertEqual( + rendered_code, + "cd /tmp && mkdir odoo", + msg="Must be rendered as 'cd /tmp && mkdir odoo'", + ) + + def test_run_command_with_variables(self): + """Test code execution using command log records""" + + x = 1 # Used to distinguish labels + + # Check with all available "sudo" option + for sudo in [False, "n", "p"]: + # Add label to track command log + self.server_test_1.use_sudo = sudo + command_label = f"Test Command {x}" + custom_values = {"log": {"label": command_label}} + + # Run command for Server 1 + self.server_test_1.run_command( + self.command_create_dir, sudo=sudo, **custom_values + ) + + # Expected rendered command code + rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" + + # Get command log + log_record = self.CommandLog.search([("label", "=", command_label)]) + + # Check log values + self.assertEqual(len(log_record), 1, msg="Must be a single log record") + self.assertEqual( + log_record.server_id.id, + self.server_test_1.id, + msg="Record must belong to Test 1", + ) + self.assertEqual( + log_record.command_id.id, + self.command_create_dir.id, + msg="Record must belong to command 'Create dir'", + ) + self.assertEqual( + log_record.code, + rendered_code_expected, + msg=f"Rendered code must be '{rendered_code_expected}'", + ) + self.assertEqual( + log_record.command_status, 0, msg="Command status must be equal to 0" + ) + self.assertEqual( + log_record.use_sudo, + sudo, + msg="'sudo' param in log doesn't match the command one", + ) + + # Increment label counter + x += 1 + + def test_run_command_with_keys(self): + """Test command with keys in code""" + + # Command + code = "cd {{ test_path_ }} && mkdir #!cxtower.secret.FOLDER!#" + command_with_keys = self.Command.create( + {"name": "Command with keys", "code": code} + ) + + # Parse command with key parser to ensure key is parsed correctly + code_parsed_expected = "cd {{ test_path_ }} && mkdir secretFolder" + code_parsed = self.Key._parse_code(code) + self.assertEqual( + code_parsed, + code_parsed_expected, + msg="Parsed code doesn't match expected one", + ) + + # Add label to track command log + command_label = "Test Command with keys" + custom_values = {"log": {"label": command_label}} + + # Run command for Server 1 + self.server_test_1.run_command(command_with_keys, **custom_values) + + # Expected rendered command code + rendered_code_expected = "cd /opt/tower && mkdir #!cxtower.secret.FOLDER!#" + + # Get command log + log_record = self.CommandLog.search([("label", "=", command_label)]) + + # Check log values + self.assertEqual(len(log_record), 1, msg="Must be a single log record") + self.assertEqual( + log_record.server_id.id, + self.server_test_1.id, + msg=("Record must belong %s", self.server_test_1.name), + ) + self.assertEqual( + log_record.command_id.id, + command_with_keys.id, + msg=("Record must belong to command %s", command_with_keys.name), + ) + self.assertEqual( + log_record.code, + rendered_code_expected, + msg=f"Rendered code must be '{rendered_code_expected}'", + ) + self.assertEqual( + log_record.command_status, 0, msg="Command status must be equal to 0" + ) + + def test_parse_ssh_command_result(self): + """Test ssh command result parsing""" + + placeholder = self.Key.SECRET_VALUE_PLACEHOLDER + # ------------------------------------------------------- + # Case 1: regular command execution result with no error + # We are testing secret value placeholder here + # ------------------------------------------------------- + status = 0 + response = ["Such much", f"Doge like SSH {placeholder}"] + error = [] + + ssh_command_result = self.Server._parse_command_results( + status, response, error, key_values=[f"{self.secret_2.secret_value}"] + ) + + # Get result + result_status = ssh_command_result["status"] + result_response = ssh_command_result["response"] + result_error = ssh_command_result["error"] + + self.assertEqual( + result_status, + status, + "Status in result must be the same as the initial one", + ) + self.assertEqual( + result_response, + f"Such muchDoge like SSH {placeholder}", + "Response in result doesn't match expected", + ) + self.assertIsNone(result_error, "Error in response must be set to None") + + # ------------------------------------------------------- + # Case 2: no response but an error + # ------------------------------------------------------- + status = 1 + response = [] + error = ["Ooops", "I did", "it again"] + + ssh_command_result = self.Server._parse_command_results(status, response, error) + + # Get result + result_status = ssh_command_result["status"] + result_response = ssh_command_result["response"] + result_error = ssh_command_result["error"] + + self.assertEqual( + result_status, + status, + "Status in result must be the same as the initial one", + ) + self.assertIsNone(result_response, "Response in response must be set to None") + self.assertEqual( + result_error, "OoopsI didit again", "Error in result doesn't match expected" + ) + + # ------------------------------------------------------- + # Case 3: several codes all 0, no response but an error + # ------------------------------------------------------- + status = [0, 0, 0] + response = [] + error = ["Ooops", "I did", "it again"] + + ssh_command_result = self.Server._parse_command_results(status, response, error) + + # Get result + result_status = ssh_command_result["status"] + result_response = ssh_command_result["response"] + result_error = ssh_command_result["error"] + + self.assertEqual( + result_status, 0, "Status in result doesn't match expected one" + ) + self.assertIsNone(result_response, "Response in response must be set to None") + self.assertEqual( + result_error, "OoopsI didit again", "Error in result doesn't match expected" + ) + + # ------------------------------------------------------- + # Case 4: codes [0,1,0,4,0], no response but an error + # ------------------------------------------------------- + status = [0, 1, 0, 4, 0] + response = [] + error = ["Ooops", "I did", "it again"] + + ssh_command_result = self.Server._parse_command_results(status, response, error) + + # Get result + result_status = ssh_command_result["status"] + result_response = ssh_command_result["response"] + result_error = ssh_command_result["error"] + + self.assertEqual( + result_status, 4, "Status in result doesn't match expected one" + ) + self.assertIsNone(result_response, "Response in response must be set to None") + self.assertEqual( + result_error, "OoopsI didit again", "Error in result doesn't match expected" + ) + + # ------------------------------------------------------- + # Case 5: regular command execution result with no error + # However the command result is saved in the "error" value. + # For example this happens in 'docker build'. + # ------------------------------------------------------- + status = 0 + error = ["Such much", f"Doge like SSH {placeholder}"] + response = [] + + ssh_command_result = self.Server._parse_command_results( + status, response, error, key_values=[f"{self.secret_2.secret_value}"] + ) + + # Get result + result_status = ssh_command_result["status"] + result_response = ssh_command_result["response"] + result_error = ssh_command_result["error"] + + self.assertEqual( + result_status, + status, + "Status in result must be the same as the initial one", + ) + self.assertEqual( + result_error, + f"Such muchDoge like SSH {placeholder}", + "Response in result doesn't match expected", + ) + self.assertIsNone(result_response, "Error in response must be set to None") + + def test_tower_command_action_file_using_template(self): + """ + Test action file using template for tower source + """ + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_server.CxTowerServer.upload_file", + return_value="ok", + ): + self.server_test_1.run_command( + self.command_create_file_with_template_tower_source + ) + + log_text_create_success = "File created and uploaded successfully" + log_text_file_exists = "An error occurred: File already exists on server." + + # Get command log + log_record = self.CommandLog.search( + [ + ("server_id", "=", self.server_test_1.id), + ( + "command_id", + "=", + self.command_create_file_with_template_tower_source.id, + ), + ("command_response", "=", log_text_create_success), + ] + ) + + self.assertEqual(len(log_record), 1, msg="Must be a single log record") + + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_server.CxTowerServer.upload_file", + return_value="ok", + ): + self.server_test_1.run_command( + self.command_create_file_with_template_tower_source + ) + + log_record_2 = self.CommandLog.search( + [ + ("server_id", "=", self.server_test_1.id), + ( + "command_id", + "=", + self.command_create_file_with_template_tower_source.id, + ), + ("command_error", "=", log_text_file_exists), + ] + ) + + self.assertEqual(len(log_record_2), 1, msg="Must be a single log record") + + def test_server_command_action_file_using_template(self): + """ + Test action file using template for server source + """ + self.assertFalse(self.template_file_server.file_ids) + + def download_file(this, remote_path): + return b"Hello, world!" + + cx_tower_server_obj = self.registry["cx.tower.server"] + + with patch.object(cx_tower_server_obj, "download_file", download_file): + self.server_test_1.run_command( + self.command_create_file_with_template_server_source + ) + + log_text_create_success = "File created and uploaded successfully" + log_text_file_exists = "An error occurred: File already exists on server." + + # Get command log + log_record = self.CommandLog.search( + [ + ("server_id", "=", self.server_test_1.id), + ( + "command_id", + "=", + self.command_create_file_with_template_server_source.id, + ), + ("command_response", "=", log_text_create_success), + ] + ) + + self.assertEqual(len(log_record), 1, msg="Must be a single log record") + self.assertEqual( + len(self.template_file_server.file_ids), 1, msg="Must be one file!" + ) + self.assertEqual( + self.template_file_server.file_ids.source, + "server", + msg="The File source must be 'server'", + ) + + with patch.object(cx_tower_server_obj, "download_file", download_file): + self.server_test_1.run_command( + self.command_create_file_with_template_server_source + ) + + log_record_2 = self.CommandLog.search( + [ + ("server_id", "=", self.server_test_1.id), + ( + "command_id", + "=", + self.command_create_file_with_template_server_source.id, + ), + ("command_error", "=", log_text_file_exists), + ] + ) + + self.assertEqual(len(log_record_2), 1, msg="Must be a single log record") + + def test_run_command_no_command_log(self): + """Run command without creating a log record. + Such commands return execution result directly. + """ + # Add label to track command log + command_label = "Test Command with keys" + custom_values = {"log": {"label": command_label}} + + # Run command for Server 1 + command_result = self.server_test_1.with_context( + no_command_log=True + ).run_command(self.command_create_dir, **custom_values) + self.assertEqual( + command_result["status"], 0, "Command status doesn't match expected one" + ) + self.assertEqual( + command_result["response"], + "ok", + "Command response doesn't match expected one", + ) + self.assertIsNone( + command_result["error"], "Command error doesn't match expected one" + ) + + def test_another_command_is_running(self): + """Test a case when another command is running on the same server""" + + # Remove all existing command logs + self.CommandLog.search([]).unlink() + + # Create a new command log + initial_command_log = self.CommandLog.create( + { + "server_id": self.server_test_1.id, + "command_id": self.command_create_new_command.id, + "start_date": Datetime.now(), + } + ) + + # Run the command without creating a log record + command_result = self.server_test_1.with_context( + no_command_log=True + ).run_command(self.command_create_new_command) + self.assertEqual(command_result["status"], ANOTHER_COMMAND_RUNNING) + + # Run the command with creating a log record + command_result = self.server_test_1.run_command(self.command_create_new_command) + + # Get the command log + command_log = self.CommandLog.search( + [ + ("server_id", "=", self.server_test_1.id), + ("command_id", "=", self.command_create_new_command.id), + ("id", "!=", initial_command_log.id), + ] + ) + self.assertEqual(len(command_log), 1, "Must be a single log record") + self.assertEqual(command_log.command_status, ANOTHER_COMMAND_RUNNING) + + def test_file_using_template_create_if_exists(self): + """Test uploading file using template if it exists on server.""" + + command = self.command_create_file_with_template_server_source + command.write({"if_file_exists": "skip"}) + + # Create file to make sure that it exists on the server + file_template = command.file_template_id + orig_file = file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists=command.if_file_exists, + ) + + self.assertTrue(orig_file, "File must be created on the server") + + # Test if file exists and command is set to "skip" + skipped_file = file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists=command.if_file_exists, + ) + self.assertEqual( + orig_file, + skipped_file, + "Skip should return the existing file, not create a new one", + ) + self.assertEqual( + self.env["cx.tower.file"].search_count( + [ + ("template_id", "=", file_template.id), + ("server_id", "=", self.server_test_1.id), + ] + ), + 1, + "There must be exactly one physical file record after skip", + ) + + # Change command to raise an error if file exists + command.write({"if_file_exists": "raise"}) + with self.assertRaisesRegex( + ValidationError, + "File already exists on server.", + ): + file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists=command.if_file_exists, + ) + # Change command to "overwrite" file if it exists + command.write({"if_file_exists": "overwrite"}) + # Run command again, it should overwrite the file + file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists=command.if_file_exists, + ) + self.assertEqual( + self.env["cx.tower.file"].search_count( + [ + ("template_id", "=", file_template.id), + ("server_id", "=", self.server_test_1.id), + ("server_dir", "=", file_template.server_dir), + ] + ), + 1, + "There must be exactly one physical file record after overwrite", + ) + self.assertEqual( + orig_file.code, + file_template.code, + "File code must match template after overwrite", + ) + self.assertEqual( + orig_file.name, + file_template.file_name, + "File name must match template after overwrite", + ) + self.assertEqual( + orig_file.source, + file_template.source, + "File source must match template after overwrite", + ) + + def test_is_file_disconnected_from_template(self): + """Test if file is disconnected from template after being created.""" + + initial_files = self.server_test_1.file_ids + command = self.command_create_file_with_template_server_source + + command.disconnect_file = True + self.server_test_1.run_command(command=command) + + new_files = self.server_test_1.file_ids - initial_files + self.assertEqual(len(new_files), 1, "Must be one new file created") + self.assertEqual( + new_files.code_on_server, + command.file_template_id.code, + "File code must match template", + ) + self.assertFalse( + new_files.template_id, "File must be disconnected from template" + ) + + # --------------------- + # ********************* + # Python commands + # ********************* + # --------------------- + + def test_render_code_python(self): + """Test Python code template direct rendering""" + + rendered_command = self.server_test_1._render_command( + self.command_create_new_command + ) + + # Note: this is rendered as for Server Test 1 + rendered_code_pythonic = ( + f""" +server_name = "{self.server_test_1.name}" +if server_name and #!cxtower.secret.FOLDER!# == "secretFolder": + # We don't actually create a new command because it will raise + # access error if user doesn't have access to 'create' operation. + # Instead we just return a dummy command result. + command = "new command" + result = {{"exit_code": 0, "message": "New command was created"}} +else: + result = {{"exit_code": %s, "message": "error"}} + """ + % GENERAL_ERROR + ) + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_pythonic, + "Rendered code doesn't match", + ) + + def test_execute_python_command(self): + """ + Run command with python action. + """ + command_result = self.server_test_1.with_context( + no_command_log=True + ).run_command(self.command_create_new_command) + self.assertEqual( + command_result["status"], 0, "The command result status must be 0" + ) + self.assertEqual( + command_result["response"], + "New command was created", + "The response must be text", + ) + + # Check error is raises + self.secret_folder_key.secret_value = "not_a_secretFolder" + command_result = self.server_test_1.with_context( + no_command_log=True + ).run_command(self.command_create_new_command) + self.assertEqual( + command_result["status"], + GENERAL_ERROR, + "The command result status must be GENERAL_ERROR", + ) + self.assertEqual( + command_result["error"], + "error", + "The error response must be contain text - error", + ) + + def test_run_python_code_banned_keywords(self): + """ + Test that _run_python_code raises ValidationError when code contains + banned keywords (e.g. _set_secret_values, _get_secret_value, + _get_secret_values). + """ + banned_keywords = self.Command._get_banned_python_code_keywords() + for banned_keyword in banned_keywords: + with self.subTest(banned_keyword=banned_keyword): + code = f""" +result = {{"exit_code": 0, "message": "ok"}} +# Banned: {banned_keyword} +""" + with self.assertRaises(ValidationError) as cm: + self.server_test_1._run_python_code(code, raise_on_error=True) + self.assertIn( + banned_keyword, + str(cm.exception), + "ValidationError must mention the banned keyword", + ) + + def test_run_python_code(self): + """ + Test python execution code + """ + rendered_command = self.server_test_1._render_command( + self.command_create_new_command + ) + + command_result = self.server_test_1._run_python_code( + rendered_command["rendered_code"] + ) + self.assertEqual( + command_result["status"], 0, "The command result status must be 0" + ) + self.assertEqual( + command_result["response"], + "New command was created", + "The response must be text", + ) + self.assertIsNone( + command_result["error"], + "Error in command result must be set to None", + ) + + def test_run_command_without_set_server_status(self): + """ + Test command execution without setting server status + """ + # Set command access level to "user" + self.command_create_new_command.write({"access_level": "1"}) + + # Add user to command + self.write_and_invalidate( + self.server_test_1, **{"user_ids": [(4, self.user.id)]} + ) + + # Reset access rule cache + self.env["ir.rule"].invalidate_recordset() + + # Run command + server_status = self.server_test_1.status + + result = ( + self.server_test_1.with_context(no_command_log=True) + .with_user(self.user) + .run_command(self.command_create_new_command) + ) + + # Check command result + self.assertEqual(result["status"], 0, "Command status must be 0") + self.assertEqual( + self.server_test_1.status, server_status, "Server status must be 'running'" + ) + + def test_run_command_with_set_server_status(self): + """ + Test command execution with setting server status + """ + # Set server status to "down" + self.command_create_new_command.write({"server_status": "stopping"}) + + # Run command + self.server_test_1.with_context(no_command_log=True).run_command( + self.command_create_new_command + ) + + # Check command result + self.assertEqual( + self.server_test_1.status, "stopping", "Server status must be 'stopping'" + ) + + def test_run_python_code_with_secret(self): + """ + Test execution of Python code with a secret value. + This test ensures that a command is rendered and executed correctly, + and that the secret value is correctly handled and replaced in the output. + """ + + placeholder = self.Key.SECRET_VALUE_PLACEHOLDER + # Case 1 + # Render the command using server_test_1 + rendered_command = self.server_test_1._render_command( + self.command_python_command_1 + ) + + # Run the rendered Python code + command_result = self.server_test_1._run_python_code( + rendered_command["rendered_code"] + ) + + # Assert that the command execution status is 0 (indicating success) + self.assertEqual( + command_result["status"], 0, "The command result status must be 0" + ) + + # Assert that the response contains the secret spoiler text + self.assertEqual( + command_result["response"], + placeholder, + "The response must correctly include the secret value placeholder", + ) + + # Assert that no error occurred during execution (error should be None) + self.assertIsNone( + command_result["error"], + "The error in command result must be None", + ) + + # Case 2 + # Render the command using server_test_1 + rendered_command = self.server_test_1._render_command( + self.command_python_command_2 + ) + + # Run the rendered Python code + command_result = self.server_test_1._run_python_code( + rendered_command["rendered_code"] + ) + + # Assert that the command execution status is 0 (indicating success) + self.assertEqual( + command_result["status"], 0, "The command result status must be 0" + ) + + # Assert that the response contains the secret spoiler text + self.assertEqual( + command_result["response"], + f'We use "{placeholder}"', + "The response must correctly include the secret value placeholder", + ) + + # Assert that no error occurred during execution (error should be None) + self.assertIsNone( + command_result["error"], + "The error in command result must be None", + ) + + # Case 3 + # Render the command using server_test_1 + rendered_command = self.server_test_1._render_command( + self.command_python_command_3 + ) + + # Run the rendered Python code + command_result = self.server_test_1._run_python_code( + rendered_command["rendered_code"] + ) + + # Assert that the command execution status is 0 (indicating success) + self.assertEqual( + command_result["status"], 0, "The command result status must be 0" + ) + + # Assert that the response contains the secret spoiler text + self.assertEqual( + command_result["response"], + placeholder, + "The response must correctly include the secret value placeholder", + ) + + # Assert that no error occurred during execution (error should be None) + self.assertIsNone( + command_result["error"], + "The error in command result must be None", + ) + + # Case 4 + # Render the command using server_test_1 + rendered_command = self.server_test_1._render_command( + self.command_python_command_4 + ) + + # Run the rendered Python code + # SSH keys are not parsed inline, so the command returns a successful + # placeholder response + command_result = self.server_test_1._run_python_code( + rendered_command["rendered_code"] + ) + + # Assert that the command execution status is 0 (indicating success) + self.assertEqual( + command_result["status"], 0, "The command result status must be 0" + ) + + # Assert that the response contains the secret spoiler text + self.assertEqual( + command_result["response"], + placeholder, + "The response must correctly include the secret value placeholder", + ) + + # Assert that no error occurred during execution (error should be None) + self.assertIsNone( + command_result["error"], + "The error in command result must be None", + ) + + def test_command_with_secret(self): + """ + Test case to verify that when a command includes a secret reference, + the secret key is automatically linked with the command. + """ + + # Command with a secret reference + code = "cd {{ test_path_ }} && mkdir #!cxtower.secret.FOLDER!#" + + secrets = self.Command._extract_secret_ids(code) + secret_folder_key = self.secret_folder_key + self.assertIn( + secret_folder_key, + secrets, + msg=( + f"The expected secret ID #{secret_folder_key.id} " + "was not found in the provided code." + ), + ) + + command_with_keys = self.Command.create( + {"name": "Command with keys", "code": code} + ) + + # -- 1 -- + # Assert that the secret key is linked with the command + self.assertIn( + secret_folder_key, + command_with_keys.secret_ids, + msg="The secret key is not linked with the command.", + ) + + # -- 2 -- + # Update the command's code to remove the secret reference + updated_code = "cd {{ test_path_ }} && mkdir new_folder" + command_with_keys.code = updated_code + + self.assertFalse( + command_with_keys.secret_ids, + msg=( + "The secret_ids field should be empty after " + "removing the secret reference from command." + ), + ) + + # -- 3 -- + # Create a secret with the same reference but connected to another server + another_server = self.server_test_1.copy({"name": "another server"}) + another_secret = self.Key.create( + { + "name": "another secret", + "reference": secret_folder_key.reference, + "key_type": "s", + } + ) + another_secret_value = self.KeyValue.create( + { + "key_id": another_secret.id, + "server_id": another_server.id, + "secret_value": "another secret value", + } + ) + # Set original code again + command_with_keys.code = code + self.assertEqual( + len(command_with_keys.secret_ids), + 1, + msg="Must be only one secret", + ) + self.assertIn( + secret_folder_key, + command_with_keys.secret_ids, + msg="The secret key is not linked with the command.", + ) + self.assertNotIn( + another_secret, + command_with_keys.secret_ids, + msg="The another secret is linked with the command.", + ) + + # -- 4 -- + # Connect command to server and secret to another server + # and ensure it's unlinked from the command. + yet_one_more_server = self.server_test_1.copy({"name": "yet one more server"}) + + self.write_and_invalidate( + another_secret_value, **{"server_id": yet_one_more_server.id} + ) + self.write_and_invalidate( + command_with_keys, **{"server_ids": self.server_test_1} + ) + self.assertEqual( + len(command_with_keys.secret_ids), + 1, + msg="Must be one secret", + ) + + def test_check_zombie_commands(self): + """Test checking and marking zombie commands""" + # Create test commands + ssh_command = self.Command.create( + { + "name": "Test SSH Command", + "code": "ls -la", + "action": "ssh_command", + } + ) + python_command = self.Command.create( + { + "name": "Test Python Command", + "code": "print('test')", + "action": "python_code", + } + ) + plan_command = self.Command.create( + { + "name": "Test Plan Command", + "code": "test plan", + "action": "plan", + } + ) + + # Set command timeout to 10 seconds + self.env["ir.config_parameter"].sudo().set_param( + "cetmix_tower_server.command_timeout", "10" + ) + + # Create command logs with different start times + now = Datetime.now() + old_time = now - timedelta(seconds=20) # Older than timeout + recent_time = now - timedelta(seconds=5) # Within timeout + + # Create zombie SSH command log + zombie_ssh_log = self.CommandLog.create( + { + "command_id": ssh_command.id, + "server_id": self.server_test_1.id, + "start_date": old_time, + } + ) + + # Create zombie Python command log + zombie_python_log = self.CommandLog.create( + { + "command_id": python_command.id, + "server_id": self.server_test_1.id, + "start_date": old_time, + } + ) + + # Create non-zombie command logs + active_ssh_log = self.CommandLog.create( + { + "command_id": ssh_command.id, + "server_id": self.server_test_1.id, + "start_date": recent_time, + } + ) + + plan_log = self.CommandLog.create( + { + "command_id": plan_command.id, + "server_id": self.server_test_1.id, + "start_date": old_time, + } + ) + + # Test with timeout set + self.server_test_1._check_zombie_commands() + + # Check zombie commands are marked as finished + self.assertFalse( + zombie_ssh_log.is_running, "Zombie SSH command should be marked as finished" + ) + self.assertFalse( + zombie_python_log.is_running, + "Zombie Python command should be marked as finished", + ) + self.assertEqual( + zombie_ssh_log.command_status, + COMMAND_TIMED_OUT, + "Zombie SSH command should have timed out status", + ) + self.assertEqual( + zombie_python_log.command_error, + str(COMMAND_TIMED_OUT_MESSAGE), + "Zombie Python command should have timeout error message", + ) + + # Check non-zombie commands are still running + self.assertTrue( + active_ssh_log.is_running, "Recent command should still be running" + ) + self.assertTrue( + plan_log.is_running, "Plan command should not be affected by timeout" + ) + + # Test with timeout disabled + self.env["ir.config_parameter"].sudo().set_param( + "cetmix_tower_server.command_timeout", "0" + ) + + # Create new zombie command log + new_zombie_log = self.CommandLog.create( + { + "command_id": ssh_command.id, + "server_id": self.server_test_1.id, + "start_date": old_time, + } + ) + + self.server_test_1._check_zombie_commands() + self.assertNotEqual( + new_zombie_log.command_status, + COMMAND_TIMED_OUT, + "Commands should not be marked as timed out when timeout is disabled", + ) + + def test_command_with_malformed_code(self): + """Test rendering command using `_render_command` method + of cx.tower.server with malformed code + """ + + with self.assertRaises(ValidationError): + self.Command.create( + { + "name": "Test Malformed Command", + "code": "cd {{ !@238203 }} && mkdir #!cxtower.secret.FOLDER!#", + "action": "ssh_command", + } + ) + + def test_server_render_command_with_jet(self): + """Test rendering command using `_render_command` method + of cx.tower.server + """ + + # -- 1 -- + # Test with default path and jet + rendered_command = self.server_test_1._render_command( + command=self.command_create_dir, + jet_template=self.jet_template_sample, + jet=self.jet_sample, + ) + rendered_code_expected = "cd /jets/jet1 && mkdir jet_templates" + rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) + self.assertEqual( + rendered_command["rendered_path"], + rendered_path_expected, + "Rendered path doesn't match", + ) + + # -- 2 -- + # Test with custom variable values + custom_variable_values = {"test_path_": "/such/much/jet"} + rendered_command = self.server_test_1._render_command( + command=self.command_create_dir, + jet_template=self.jet_template_sample, + jet=self.jet_sample, + custom_variable_values=custom_variable_values, + ) + rendered_code_expected = "cd /such/much/jet && mkdir jet_templates" + rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" + + self.assertEqual( + rendered_command["rendered_code"], + rendered_code_expected, + "Rendered code doesn't match", + ) diff --git a/addons/cetmix_tower_server/tests/test_command_log.py b/addons/cetmix_tower_server/tests/test_command_log.py new file mode 100644 index 0000000..9c1ed86 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_command_log.py @@ -0,0 +1,282 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.exceptions import AccessError + +from .common import TestTowerCommon + + +class TestTowerCommandLog(TestTowerCommon): + """Test the cx.tower.command.log model access rights.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create commands with different access levels + cls.command_level_1 = cls.Command.create( + { + "name": "Test Command L1", + "action": "ssh_command", + "access_level": "1", + } + ) + + cls.command_level_2 = cls.Command.create( + { + "name": "Test Command L2", + "action": "ssh_command", + "access_level": "2", + } + ) + + cls.command_level_3 = cls.Command.create( + { + "name": "Test Command L3", + "action": "ssh_command", + "access_level": "3", + } + ) + + # Create test command logs with specific users + cls.command_log_1 = ( + cls.CommandLog.with_user(cls.user) + .sudo() + .create( + { + "server_id": cls.server_test_1.id, + "command_id": cls.command_level_1.id, + "start_date": fields.Datetime.now(), + } + ) + ) + + cls.command_log_2 = ( + cls.CommandLog.with_user(cls.manager) + .sudo() + .create( + { + "server_id": cls.server_test_1.id, + "command_id": cls.command_level_1.id, + "start_date": fields.Datetime.now(), + } + ) + ) + + # Create additional server for testing + cls.server_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "test2", + "ssh_password": "test2", + "ssh_port": 22, + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + } + ) + + def test_user_read_access(self): + """Test user read access to command logs""" + # Add user to server's user_ids to isolate creator check + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + + # Case 1: User should be able to read when: + # - access_level == "1" + # - created by user + # - user is in server's user_ids + recs = self.CommandLog.with_user(self.user).search( + [("id", "in", [self.command_log_1.id, self.command_log_2.id])] + ) + self.assertEqual( + len(recs), + 1, + "User should only be able to read their own logs", + ) + self.assertIn( + self.command_log_1, + recs, + "User should be able to read own logs when conditions are met", + ) + self.assertNotIn( + self.command_log_2, + recs, + "User should not be able to read logs created by others", + ) + + # Case 2: User should not be able to read when not in server's user_ids + self.server_test_1.write( + { + "user_ids": [(5, 0, 0)], # Remove all users + } + ) + recs = self.CommandLog.with_user(self.user).search( + [("id", "=", self.command_log_1.id)] + ) + self.assertNotIn( + self.command_log_1, + recs, + "User should not be able to read when not in server's user_ids", + ) + + # Case 3: User should not be able to read when access_level > "1" + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + high_access_log = ( + self.CommandLog.with_user(self.user) + .sudo() + .create( + { + "server_id": self.server_test_1.id, + "command_id": self.command_level_2.id, # Using command with access_level "2" # noqa: E501 + "start_date": fields.Datetime.now(), + } + ) + ) + recs = self.CommandLog.with_user(self.user).search( + [("id", "=", high_access_log.id)] + ) + self.assertNotIn( + high_access_log, + recs, + "User should not be able to read logs with access_level > '1'" + " even if created by them", + ) + + def test_manager_read_access(self): + """Test manager read access to command logs""" + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in server's manager_ids + self.server_test_1.write( + { + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.CommandLog.with_user(self.manager).search( + [("id", "in", [self.command_log_1.id, self.command_log_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when in server's manager_ids", + ) + + # Case 2: Manager should be able to read when in server's user_ids + self.server_test_1.write( + { + "manager_ids": [(5, 0, 0)], # Remove all managers + "user_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.CommandLog.with_user(self.manager).search( + [("id", "in", [self.command_log_1.id, self.command_log_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when in server's user_ids", + ) + + # Case 3: Manager should not be able to read when access_level > "2" + high_access_log = ( + self.CommandLog.with_user(self.manager) + .sudo() + .create( + { + "server_id": self.server_test_1.id, + "command_id": self.command_level_3.id, # Using command with access_level "3" # noqa: E501 + "start_date": fields.Datetime.now(), + } + ) + ) + recs = self.CommandLog.with_user(self.manager).search( + [("id", "=", high_access_log.id)] + ) + self.assertNotIn( + high_access_log, + recs, + "Manager should not be able to read logs with access_level > '2'", + ) + + # Case 4: Manager should not be able to read when he is not + # in users_ids or manager_ids + self.server_test_1.write( + { + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + } + ) + recs = self.CommandLog.with_user(self.manager).search( + [("id", "in", [self.command_log_1.id, self.command_log_2.id])] + ) + self.assertNotIn( + self.command_log_1, + recs, + "Manager should not be able to read logs when he is not" + " in users_ids or manager_ids", + ) + + def test_root_read_only_access(self): + """Root can read all command logs, but cannot create/modify/delete""" + # Create test logs with sudo() + test_logs = self.CommandLog.sudo().create( + [ + { + "server_id": self.server_2.id, + "command_id": command.id, + "start_date": fields.Datetime.now(), + } + for command in [ + self.command_level_1, + self.command_level_2, + self.command_level_3, + ] + ] + ) + # Root cannot create logs + with self.assertRaises(AccessError): + self.CommandLog.with_user(self.root).create( + { + "server_id": self.server_2.id, + "command_id": self.command_level_1.id, + "start_date": fields.Datetime.now(), + } + ) + + # Root cannot modify logs + with self.assertRaises(AccessError): + test_logs.with_user(self.root).write({"start_date": fields.Datetime.now()}) + + # Root cannot delete logs + with self.assertRaises(AccessError): + test_logs.with_user(self.root).unlink() + + # Root should be able to read all logs regardless of: + # - access_level + # - server relationships + # - who created them + recs = self.CommandLog.with_user(self.root).search( + [("id", "in", test_logs.ids)] + ) + self.assertEqual( + len(recs), + 3, + "Root should have unrestricted read access to all logs", + ) + + # Test read on all records + all_recs = self.CommandLog.with_user(self.root).search([]) + self.assertGreater( + len(all_recs), + 0, + "Root should be able to read all command logs", + ) diff --git a/addons/cetmix_tower_server/tests/test_command_wizard.py b/addons/cetmix_tower_server/tests/test_command_wizard.py new file mode 100644 index 0000000..f0b0ab2 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_command_wizard.py @@ -0,0 +1,572 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, ValidationError + +from .common import TestTowerCommon + + +class TestTowerCommandWizard(TestTowerCommon): + """Test Tower Command Run Wizard""" + + def test_user_access_rules(self): + """Test user access rules""" + + # Add Bob to `root` group in order to create a wizard + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + + # Create new wizard + test_wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create( + { + "server_ids": [self.server_test_1.id], + "command_id": self.command_create_dir.id, + } + ) + ).with_user(self.user_bob) + + # Force rendered code computation + test_wizard._compute_rendered_code() + + # Remove bob from all cxtower_server groups + self.remove_from_group( + self.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + # Ensure that regular user cannot execute command in wizard + with self.assertRaises(AccessError): + test_wizard.run_command_in_wizard() + + # Add bob back to `user` group and try again + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + with self.assertRaises(AccessError): + test_wizard.run_command_in_wizard() + + # Now promote bob to `manager` group and try again + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + test_wizard.run_command_in_wizard() + + def test_execute_code_without_a_command(self): + """Run command code without a command selected""" + + # Add Bob to `root` group in order to create a wizard + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + + # Create new wizard + test_wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create( + { + "server_ids": [self.server_test_1.id], + } + ) + ).with_user(self.user_bob) + + # Should not allow to run command on server if no command is selected + with self.assertRaises(ValidationError): + test_wizard.run_command_on_server() + + def test_run_command_on_server_access_rights(self): + """Test access rights for executing command on server""" + + # Add Bob to `root` group + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + + # Create new wizard with Bob as a root user + test_wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create( + { + "server_ids": [self.server_test_1.id], + "command_id": self.command_create_dir.id, + } + ) + ).with_user(self.user_bob) + + # Ensure command can be executed by root + test_wizard.run_command_on_server() + + # Remove Bob from all tower server groups + self.remove_from_group( + self.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + + # Ensure that regular user cannot execute command on server + with self.assertRaises(AccessError): + test_wizard.run_command_on_server() + + # Add Bob to `user` group and ensure he can execute commands + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + test_wizard.run_command_on_server() + # Ensure that Bob has access to path field but can't read its value + allowed_path = ( + self.user_bob.has_group("cetmix_tower_server.group_manager") + and test_wizard.path + ) + + self.assertEqual(allowed_path, False) + # Ensure that Bob can write to the path field as a member of `group_user` + # the result will be None + test_wizard.write({"path": "/new/invalid/path"}) + allowed_path = ( + test_wizard.path + if self.user_bob.has_group("cetmix_tower_server.group_manager") + and test_wizard.path + else None + ) + self.assertEqual(allowed_path, None) + + # Add Bob to `manager` group and ensure access to execute commands + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + test_wizard.run_command_on_server() + # Check that path access is valid for the manager + test_wizard.read(["path"]) + + def test_run_command_with_sensitive_vars_on_server_access_rights(self): + """Test access rights for executing command on server""" + # create new command + command = self.Command.create( + { + "name": "Create new command", + "action": "python_code", + "code": """ + properties = { + "Server Name": {{ tower.server.name }}, + "Server Reference": {{ tower.server.reference }}, + "SSH Username": {{ tower.server.username }}, + "IPv4 Address": {{ tower.server.ipv4 }}, + "IPv6 Address": {{ tower.server.ipv6 }}, + "Partner Name": {{ tower.server.partner_name }} + } + result = {"exit_code": 0, "message": properties} + """, + "access_level": "1", + } + ) + + # Add Bob to `root` group in order to create a wizard + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + + server = self.Server.with_user(self.user_bob).create( + { + "name": "Test 2", + "ip_v4_address": "localhost", + "ssh_username": "root", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + } + ) + + self.remove_from_group( + self.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + + # Add user bob to group user + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + + # Create new wizard with Bob + test_wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create( + { + "server_ids": [server.id], + "command_id": command.id, + } + ) + ).with_user(self.user_bob) + + # Add Bob as a user to the command + command.write({"user_ids": [(4, self.user_bob.id)]}) + + # Ensure command can be executed by user + test_wizard.run_command_on_server() + + def test_run_command_in_wizard_multiple_servers(self): + """ + Test that raises an error when multiple servers are selected + """ + + # Add Bob to `root` group in order to create a wizard + + server_test_2 = self.Server.create( + { + "name": "Test 2", + "ip_v4_address": "localhost", + "ssh_username": "root", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + } + ) + + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + + # Create new wizard with multiple servers selected + test_wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create( + { + "server_ids": [self.server_test_1.id, server_test_2.id], + "command_id": self.command_create_dir.id, + } + ) + ).with_user(self.user_bob) + + # Force rendered code computation + test_wizard._compute_rendered_code() + + # Ensure that executing command with multiple servers + # selected raises a ValidationError + with self.assertRaises( + ValidationError, + msg="You cannot run custom code on multiple servers at once.", + ): + test_wizard.run_command_in_wizard() + + # Now, test with a single server selected + test_wizard.server_ids = [self.server_test_1.id] + + # Ensure that executing command works with a single server selected + test_wizard.run_command_in_wizard() + self.assertTrue( + test_wizard.result, + msg="Command execution should succeed with a single server selected", + ) + + def test_custom_variable_value_ids_creation(self): + """ + Test that custom variable values are created properly + when command has variables + """ + # Add manager as server user + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + + # Create variables that will be used in command + variable = self.Variable.create( + { + "name": "Test Variable", + "reference": "test_var", + "variable_type": "s", # string type + } + ) + option_variable = self.Variable.create( + { + "name": "Option Variable", + "reference": "opt_var", + "variable_type": "o", # option type + } + ) + option = self.VariableOption.create( + { + "name": "Test Option", + "value_char": "option_value", + "variable_id": option_variable.id, + } + ) + + # Add variable values to server + self.VariableValue.create( + [ + { + "variable_id": variable.id, + "server_id": self.server_test_1.id, + "value_char": "server value", + }, + { + "variable_id": option_variable.id, + "server_id": self.server_test_1.id, + "value_char": "option_value", + }, + ] + ) + + # Create command that uses these variables in its code + command = self.Command.create( + { + "name": "Test Command with Variables", + "action": "ssh_command", + "code": "echo {{ test_var }} && echo {{ opt_var }}", + } + ) + + # Create wizard + wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.manager) + .create( + { + "server_ids": [self.server_test_1.id], + "command_id": command.id, + "action": "ssh_command", + } + ) + ) + + # Trigger onchange to generate custom_variable_value_ids + wizard._onchange_command_variable_ids() + + # Check that custom variable values were created + self.assertEqual(len(wizard.custom_variable_value_ids), 2) + + # Check char variable value + char_value = wizard.custom_variable_value_ids.filtered( + lambda v: v.variable_id == variable + ) + self.assertTrue(char_value) + self.assertEqual(char_value.value_char, "server value") + + # Check option variable value + option_value = wizard.custom_variable_value_ids.filtered( + lambda v: v.variable_id == option_variable + ) + self.assertTrue(option_value) + self.assertEqual(option_value.value_char, "option_value") + self.assertEqual(option_value.option_id, option) + + # Try to change variable value when user doesn't have write access + char_value.value_char = "custom value" + + # Run command + wizard.run_command_on_server() + + # Get latest command log + command_log = self.env["cx.tower.command.log"].search( + [ + ("server_id", "=", self.server_test_1.id), + ("command_id", "=", command.id), + ], + order="create_date desc", + limit=1, + ) + + # Verify that original server values were used + self.assertEqual(command_log.code, "echo server value && echo option_value") + + def test_custom_variable_value_ids_with_manager_access(self): + """ + Test that custom variable values are applied + when manager has write access + """ + # Add manager as server manager + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Create variables that will be used in command + variable = self.Variable.create( + { + "name": "Test Variable", + "reference": "test_var", + "variable_type": "s", # string type + } + ) + + # Add variable value to server + self.VariableValue.create( + { + "variable_id": variable.id, + "server_id": self.server_test_1.id, + "value_char": "server value", + } + ) + + # Create command that uses the variable + command = self.Command.create( + { + "name": "Test Command with Variables", + "action": "ssh_command", + "code": "echo {{ test_var }}", + } + ) + + # Create wizard + wizard = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.manager) + .create( + { + "server_ids": [self.server_test_1.id], + "command_id": command.id, + "action": "ssh_command", + } + ) + ) + + # Trigger onchange to generate custom_variable_value_ids + wizard._onchange_command_variable_ids() + + # Modify variable value + wizard.custom_variable_value_ids.filtered( + lambda v: v.variable_id == variable + ).value_char = "manager value" + + # Run command + wizard.run_command_on_server() + + # Get latest command log + command_log = self.env["cx.tower.command.log"].search( + [ + ("server_id", "=", self.server_test_1.id), + ("command_id", "=", command.id), + ], + order="create_date desc", + limit=1, + ) + + # Verify that custom value was used + self.assertEqual(command_log.code, "echo manager value") + + def test_default_applicability_for_regular_and_manager(self): + """sets applicability='this' for regular users, keeps default for managers.""" + # Regular user (no special groups) + default_usr = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .default_get(["applicability"]) + ) + self.assertEqual(default_usr.get("applicability"), "this") + + # Manager user should receive the original default ("shared") + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + default_mgr = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .default_get(["applicability"]) + ) + self.assertEqual(default_mgr.get("applicability"), "shared") + + def test_compute_show_servers_behavior(self): + """Should enforce 'this' for regular users but preserve manager choice.""" + # Grant Bob the basic 'user' group so he can read servers and create the wizard + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + + # Ensure Bob has read access to the first server + self.server_test_1.write({"user_ids": [(4, self.user_bob.id)]}) + # Create a second server and grant Bob read access to it + srv2 = self.Server.create( + { + "name": "Server 2", + "ip_v4_address": "127.0.0.2", + "ssh_username": "root", + "ssh_password": "pwd", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + } + ) + srv2.write({"user_ids": [(4, self.user_bob.id)]}) + + # --- Regular user scenario --- + wiz_usr = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create({"server_ids": [self.server_test_1.id, srv2.id]}) + ) + # Compute show_servers under Bob; he should see both servers + wiz_usr._compute_show_servers() + self.assertTrue(wiz_usr.show_servers) + # Enforcement should set applicability to 'this' + self.assertEqual(wiz_usr.applicability, "this") + + # --- Manager user scenario --- + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + # Grant Bob manager access to both servers + self.server_test_1.write({"manager_ids": [(4, self.user_bob.id)]}) + srv2.write({"manager_ids": [(4, self.user_bob.id)]}) + + wiz_mgr = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.user_bob) + .create({"server_ids": [self.server_test_1.id, srv2.id]}) + ) + # Compute show_servers under Bob as manager + wiz_mgr._compute_show_servers() + # Manager should also see both servers + self.assertTrue(wiz_mgr.show_servers) + # Enforcement should not override manager's choice of 'shared' + self.assertEqual(wiz_mgr.applicability, "shared") + + def test_required_variable_validation(self): + """ + Wizard must block execution when a required variable is empty + and allow it after the value is provided. + """ + # Create a required variable + var = self.Variable.create( + { + "name": "Req Var", + "reference": "req_var", + "variable_type": "s", + } + ) + self.VariableValue.create( + { + "variable_id": var.id, + "server_id": self.server_test_1.id, + "required": True, + "value_char": "", + } + ) + + # Create command that uses this variable + cmd = self.Command.create( + { + "name": "Echo Req Var", + "action": "ssh_command", + "code": "echo {{ req_var }}", + "variable_ids": [(4, var.id)], + } + ) + + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + + # Create wizard as manager user + wiz = ( + self.env["cx.tower.command.run.wizard"] + .with_user(self.manager) + .create( + { + "server_ids": [self.server_test_1.id], + "command_id": cmd.id, + } + ) + ) + + # Create lines of configuration + wiz._onchange_command_variable_ids() + wiz._compute_has_missing_required_values() + + # Test blocking behavior + self.assertTrue(wiz.has_missing_required_values) + with self.assertRaises(ValidationError): + wiz.run_command_on_server() + + # Fill the value directly in the wizard line + wiz.custom_variable_value_ids.filtered( + lambda line: line.variable_id == var + ).value_char = "filled" + + # Recompute the flag + wiz._compute_has_missing_required_values() + self.assertFalse(wiz.has_missing_required_values) + + # Now the execution should pass + wiz.run_command_on_server() diff --git a/addons/cetmix_tower_server/tests/test_file.py b/addons/cetmix_tower_server/tests/test_file.py new file mode 100644 index 0000000..46f9432 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_file.py @@ -0,0 +1,482 @@ +from odoo import exceptions +from odoo.exceptions import AccessError + +from .common import TestTowerCommon + + +class TestTowerFile(TestTowerCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.file_template = cls.FileTemplate.create( + { + "name": "Test", + "file_name": "test.txt", + "server_dir": "/var/tmp", + "code": "Hello, world!", + } + ) + cls.file = cls.File.create( + { + "name": "tower_demo_1.txt", + "source": "tower", + "template_id": cls.file_template.id, + "server_id": cls.server_test_1.id, + } + ) + cls.file_2 = cls.File.create( + { + "name": "test.txt", + "source": "server", + "server_id": cls.server_test_1.id, + "server_dir": "/var/tmp", + } + ) + + # Create a dummy Server record that will be referenced by file records. + cls.server = cls.Server.create( + { + "name": "Test Server", + "manager_ids": [(6, 0, [cls.manager.id])], + "user_ids": [(6, 0, [cls.user.id])], + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "skip_host_key": True, + "os_id": cls.os_debian_10.id, + "ip_v4_address": "localhost", + } + ) + + def test_user_read_access(self): + """ + Test that a user in the custom User group can read a file record + when their ID is in the related server's user_ids. + """ + file_record = self.File.create( + { + "name": "Test File", + "server_dir": "/tmp", + "file_type": "text", + "source": "tower", + "server_id": self.server.id, + } + ) + # As the user, the file record should be visible. + files_for_user = self.File.with_user(self.user).search( + [("id", "=", file_record.id)] + ) + self.assertTrue( + files_for_user, + "User should be able to read the file record " + "because they are in server.user_ids.", + ) + + # Remove user from server.user_ids. + self.server.write({"user_ids": [(3, self.user.id)]}) + files_for_user = self.File.with_user(self.user).search( + [("id", "=", file_record.id)] + ) + self.assertFalse( + files_for_user, + "User should not be able to read the file record " + "because he is not in server.user_ids.", + ) + + def test_manager_write_create_access(self): + """ + Test that a manager in the custom Manager group can create and write + file records when his ID is in the related server's manager_ids. + """ + # Test creation: the manager is in server.manager_ids. + file_record = self.File.with_user(self.manager).create( + { + "name": "Manager Created File", + "server_dir": "/tmp", + "file_type": "text", + "source": "tower", + "server_id": self.server.id, + } + ) + self.assertTrue( + file_record, + "Manager should be able to create a file record " + "because they are in server.manager_ids.", + ) + + # Test updating (write access). + try: + file_record.with_user(self.manager).write({"name": "Manager Updated File"}) + except AccessError: + self.fail( + "Manager should be able to update the file record " + "because he is in server.manager_ids." + ) + self.assertEqual( + file_record.with_user(self.manager).name, + "Manager Updated File", + "File record name should be updated by the manager.", + ) + + # Test that a manager who is not in the server's manager_ids + # cannot write or create. + # Remove manager from server.manager_ids. + self.server.write({"manager_ids": [(3, self.manager.id)]}) + # Create a file record on this server. + file_record2 = self.File.create( + { + "name": "File on Server Without Manager", + "server_dir": "/tmp", + "file_type": "text", + "source": "tower", + "server_id": self.server.id, + } + ) + with self.assertRaises(AccessError): + file_record2.with_user(self.manager).write({"name": "Should Not Update"}) + + # Test create access for a manager not in manager_ids. + with self.assertRaises(AccessError): + self.File.with_user(self.manager).create( + { + "name": "Invalid File", + "server_dir": "/tmp", + "file_type": "text", + "source": "tower", + "server_id": self.server.id, + } + ) + + def test_manager_unlink_access(self): + """ + Test that a manager in the custom Manager group can unlink (delete) a file + record only if he is in the related server's manager_ids + and they are the record's creator. + """ + # Scenario 1: Record created by the manager. + file_record = self.File.with_user(self.manager).create( + { + "name": "File to Delete", + "server_dir": "/tmp", + "file_type": "text", + "source": "tower", + "server_id": self.server.id, + } + ) + try: + file_record.with_user(self.manager).unlink() + except AccessError: + self.fail( + "Manager should be able to delete their own file" + " record when in server.manager_ids." + ) + + # Scenario 2: Record created by someone else (e.g., the admin). + file_record2 = self.File.create( + { + "name": "File Not Deletable by Manager", + "server_dir": "/tmp", + "file_type": "text", + "source": "tower", + "server_id": self.server.id, + } + ) + with self.assertRaises(AccessError): + file_record2.with_user(self.manager).unlink() + + def test_upload_file(self): + """ + Upload file from tower to server + """ + self.file.action_push_to_server() + self.assertEqual(self.file.server_response, "ok") + + def test_delete_file(self): + """ + Delete file remotely from server + """ + result = self.file.action_delete_from_server() + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["params"]["message"], "File deleted!") + + def test_delete_file_access(self): + """ + Test delete file access + """ + with self.assertRaises(exceptions.AccessError): + self.file.with_user(self.user_bob).delete(raise_error=True) + + def test_download_file(self): + """ + Download file from server to tower + """ + self.file_2.action_pull_from_server() + self.assertEqual(self.file_2.code, "ok") + + self.file_2.name = "binary.zip" + res = self.file_2.action_pull_from_server() + self.assertTrue( + isinstance(res, dict) and res["tag"] == "display_notification", + msg=( + "If file type is 'binary', then the result must be a dict " + "representing the display_notification action." + ), + ) + + def test_get_current_server_code(self): + """ + Download file from server to tower + """ + self.file.action_push_to_server() + self.assertEqual(self.file.server_response, "ok") + + self.file.action_get_current_server_code() + self.assertEqual(self.file.code_on_server, "ok") + + def test_modify_template_code(self): + """Test how template code modification affects related files""" + code = "Pepe frog is happy as always" + self.file_template.code = code + + # Check file code before modifications + self.assertTrue( + self.file.code == code, + msg="File code must be the same " + "as template code before any modifications", + ) + # Check file rendered code before modifications + self.assertTrue( + self.file.rendered_code == code, + msg="File rendered code must be the same" + " as template code before any modifications", + ) + + # Make possible to modify file code + self.file.action_unlink_from_template() + + # Check if template was removed from file + self.assertFalse( + self.file.template_id, + msg="File template should be removed after modifying code.", + ) + + # Check if file code remains the same + self.assertTrue( + self.file.code == code, msg="File code should be the same as template." + ) + + def test_modify_template_related_files(self): + """ + Check that after change file template + all related files will update + """ + self.assertEqual(self.file_template.file_name, "test.txt") + # related files + self.assertTrue( + all(file.name == "test.txt" for file in self.file_template.file_ids) + ) + + # update file template name + self.file_template.file_name = "new_test.txt" + # Related files must updated + self.assertTrue( + all(file.name == "new_test.txt" for file in self.file_template.file_ids) + ) + + self.assertEqual(self.file_template.code, "Hello, world!") + # update file template code + self.file_template.code = "New code" + # Related files must updated + self.assertTrue( + all(file.code == "New code" for file in self.file_template.file_ids) + ) + + def test_create_file_with_template(self): + """ + Test if file is created with template code + """ + file_template = self.env["cx.tower.file.template"].create( + { + "name": "Test", + "file_name": "test.txt", + "server_dir": "/var/tmp", + "code": "Hello, world!", + } + ) + + file = file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists="overwrite", + ) + self.assertEqual(file.code, self.file_template.code) + self.assertEqual(file.template_id, file_template) + self.assertEqual(file.server_id, self.server_test_1) + self.assertEqual(file.source, "tower") + self.assertEqual(file.server_dir, self.file_template.server_dir) + + with self.assertRaises(exceptions.ValidationError): + file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists="raise", + ) + + another_file = file_template.create_file( + server=self.server_test_1, + server_dir=file_template.server_dir, + if_file_exists="skip", + ) + self.assertEqual(another_file, file) + + def test_create_file_with_template_custom_server_dir(self): + """ + Test if file is created with template code and custom server dir + """ + file_template = self.env["cx.tower.file.template"].create( + { + "name": "Test", + "file_name": "test.txt", + "server_dir": "/var/tmp", + "code": "Hello, world!", + } + ) + + file = file_template.create_file( + server=self.server_test_1, server_dir="/var/tmp/custom" + ) + self.assertEqual(file.code, self.file_template.code) + self.assertEqual(file.template_id, file_template) + self.assertEqual(file.server_id, self.server_test_1) + self.assertEqual(file.source, "tower") + self.assertEqual(file.server_dir, "/var/tmp/custom") + + with self.assertRaises(exceptions.ValidationError): + file_template.create_file( + server=self.server_test_1, + server_dir="/var/tmp/custom", + if_file_exists="raise", + ) + + another_file = file_template.create_file( + server=self.server_test_1, + server_dir="/var/tmp/custom", + if_file_exists="skip", + ) + self.assertEqual(another_file, file) + + def test_file_with_secret_key(self): + """ + Test case to verify that when a file includes a secret reference, + the secret key is automatically linked with the file. + """ + + # Create a secret key + secret_python_key = self.Key.create( + { + "name": "python", + "reference": "PYTHON", + "secret_value": "secretPythonCode", + "key_type": "s", + } + ) + + # Create a file template with a reference to the secret key + file_template = self.env["cx.tower.file.template"].create( + { + "name": "Test", + "file_name": "test.txt", + "server_dir": "/var/tmp", + "code": "Please use this secret #!cxtower.secret.PYTHON!#", + } + ) + + # Create a file from the file template + file = file_template.create_file( + server=self.server_test_1, server_dir="/var/tmp/custom" + ) + + # Assert that the file's code matches the file template's code + self.assertEqual( + file.code, + file_template.code, + msg="The file's code does not match the file template's code.", + ) + + # Assert that the secret key is associated with the file + self.assertIn( + secret_python_key, + file.secret_ids, + msg="The secret key is not associated with the file.", + ) + + # Update the file's code to remove the secret reference + file.code = "Only text" + + self.assertFalse( + file.secret_ids, + msg=( + "The secret_ids field should be empty after " + "removing the secret reference from file." + ), + ) + + def test_file_with_sensitive_variable(self): + """ + Test case to verify that user has access to use file with sensitive variables. + """ + # Create file with sensitive variable + file = self.File.create( + { + "source": "tower", + "name": "test.txt", + "server_id": self.server_test_1.id, + "code": "'IPv4 Address': {{ tower.server.ipv4 }}", + } + ) + # Remove user_bob from all cx_tower_server groups + self.remove_from_group( + self.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + # Add bob to user group + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + # Add bob as subscriber of the server to allow upload file + self.server_test_1.write({"user_ids": [(4, self.user_bob.id)]}) + # Upload file to server + self.assertTrue(file.server_response != "ok") + file.with_user(self.user_bob).action_push_to_server() + self.assertEqual(file.server_response, "ok") + + def test_sanitize_values(self): + """ + Test case to verify that the sanitize_values method works correctly. + """ + # 1. Root directory + values = self.File._sanitize_values({"server_dir": "/"}) + self.assertEqual(values["server_dir"], "/") + + # 2. Trailing slash + values = self.File._sanitize_values({"server_dir": "/var/tmp/"}) + self.assertEqual(values["server_dir"], "/var/tmp") + + # 3. Trailing whitespace + values = self.File._sanitize_values({"server_dir": "/var/tmp/ "}) + self.assertEqual(values["server_dir"], "/var/tmp") + + # 4. Leading whitespace + values = self.File._sanitize_values({"server_dir": " /var/tmp/"}) + self.assertEqual(values["server_dir"], "/var/tmp") + + # 5. Leading and trailing whitespace + values = self.File._sanitize_values({"server_dir": " /var/tmp/ "}) + self.assertEqual(values["server_dir"], "/var/tmp") + + # 6. Leading and trailing whitespace just one slash + values = self.File._sanitize_values({"server_dir": " / "}) + self.assertEqual(values["server_dir"], "/") diff --git a/addons/cetmix_tower_server/tests/test_file_template.py b/addons/cetmix_tower_server/tests/test_file_template.py new file mode 100644 index 0000000..7b4275a --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_file_template.py @@ -0,0 +1,234 @@ +from odoo.exceptions import AccessError + +from .common import TestTowerCommon + + +class TestCxTowerFileTemplateAccessRules(TestTowerCommon): + def test_user_no_access(self): + """ + Verify that a user in the User group has no access + to any file template records. + """ + # Create a file template record as admin. + record = self.FileTemplate.create( + { + "name": "Template 1", + "file_name": "template1.txt", + "code": "Sample code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + } + ) + # As the user, search for the record – expect no records. + with self.assertRaises(AccessError): + self.FileTemplate.with_user(self.user).search([("id", "=", record.id)]) + + # Attempting to create a record as a user should raise an AccessError. + with self.assertRaises(AccessError): + self.FileTemplate.with_user(self.user).create( + { + "name": "Template 2", + "file_name": "user_template.txt", + "code": "User code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + } + ) + + def test_manager_read_access(self): + """ + Verify that a manager can read file template records + if he is not in user_ids or manager_ids. + """ + # Create a record with the manager in manager_ids. + rec1 = self.FileTemplate.create( + { + "name": "Template 1", + "file_name": "template_manager.txt", + "code": "Manager code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + # Create a record with the manager in user_ids. + rec2 = self.FileTemplate.create( + { + "name": "Template 2", + "file_name": "template_user.txt", + "code": "User code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "user_ids": [(6, 0, [self.manager.id])], + } + ) + # Create a record that does not include the manager. + rec3 = self.FileTemplate.create( + { + "name": "Template 3", + "file_name": "template_none.txt", + "code": "None code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + } + ) + recs = self.FileTemplate.with_user(self.manager).search([]) + self.assertIn(rec1, recs, "Manager should read records if in manager_ids.") + self.assertIn(rec2, recs, "Manager should read records if in user_ids.") + self.assertNotIn( + rec3, + recs, + "Manager should not see records if not in user_ids or manager_ids.", + ) + + def test_manager_write_create_access(self): + """ + Verify that a manager can write and create file template records + only if he is in manager_ids. + """ + # Create a record with manager_ids including the manager. + rec = self.FileTemplate.create( + { + "name": "Template 1", + "file_name": "template_for_update.txt", + "code": "Initial code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + # Manager should be able to update the record. + try: + rec.with_user(self.manager).write({"file_name": "template_updated.txt"}) + except AccessError: + self.fail( + "Manager should be able to update the record when in manager_ids." + ) + self.assertEqual(rec.with_user(self.manager).file_name, "template_updated.txt") + + # Manager should be able to create a record if included in manager_ids. + rec2 = self.FileTemplate.with_user(self.manager).create( + { + "name": "Template 2", + "file_name": "manager_created_template.txt", + "code": "Manager created", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + self.assertTrue( + rec2, + "Manager should be able to create a record when included in manager_ids.", + ) + + # Creating a record without including the manager should raise an AccessError. + with self.assertRaises(AccessError): + self.FileTemplate.with_user(self.manager).create( + { + "name": "Template 3", + "file_name": "invalid_template.txt", + "code": "Invalid", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "manager_ids": [(5, 0, 0)], + } + ) + + def test_manager_unlink_access(self): + """ + Verify that a manager can delete a file template record only if + he is in manager_ids and is the creator. + """ + # Scenario 1: Record created by the manager. + rec = self.FileTemplate.with_user(self.manager).create( + { + "name": "Template 1", + "file_name": "template_to_delete.txt", + "code": "Code to delete", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + try: + rec.with_user(self.manager).unlink() + except AccessError: + self.fail( + "Manager should be able to delete a record " + "he created when in manager_ids." + ) + # Scenario 2: Record created by admin (or another user) + # even though manager_ids includes the manager. + rec2 = self.FileTemplate.create( + { + "name": "Template 2", + "file_name": "template_not_deletable.txt", + "code": "Admin created code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + with self.assertRaises(AccessError): + rec2.with_user(self.manager).unlink() + + def test_root_unrestricted_access(self): + """ + Verify that a user in the Root group has unlimited access + to all file template records. + """ + # Create a file template record (with no particular restrictions). + rec = self.FileTemplate.create( + { + "name": "Template 1", + "file_name": "template_for_root.txt", + "code": "Root code", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + } + ) + # As the root user, the record should be visible. + recs = self.FileTemplate.with_user(self.root).search([("id", "=", rec.id)]) + self.assertTrue(recs, "Root should see the record regardless of restrictions.") + # Root should be able to update the record. + try: + rec.with_user(self.root).write({"file_name": "root_updated_template.txt"}) + except AccessError: + self.fail("Root should be able to update the record without restrictions.") + self.assertEqual( + rec.with_user(self.root).file_name, "root_updated_template.txt" + ) + # Root should be able to create a record. + rec2 = self.FileTemplate.with_user(self.root).create( + { + "name": "Template 2", + "file_name": "root_created_template.txt", + "code": "Created by root", + "server_dir": "/templates", + "file_type": "text", + "source": "tower", + } + ) + self.assertTrue( + rec2, "Root should be able to create a record without restrictions." + ) + # Root should be able to delete a record. + rec2.with_user(self.root).unlink() + recs_after = self.FileTemplate.with_user(self.root).search( + [("id", "=", rec2.id)] + ) + self.assertFalse( + recs_after, "Root should be able to delete the record without restrictions." + ) diff --git a/addons/cetmix_tower_server/tests/test_jet.py b/addons/cetmix_tower_server/tests/test_jet.py new file mode 100644 index 0000000..1012398 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet.py @@ -0,0 +1,1750 @@ +# 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 fields +from odoo.exceptions import AccessError, ValidationError +from odoo.tools import mute_logger + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJet(TestTowerJetsCommon): + """ + Test the Jet model functionality + """ + + # All jet-related test data is now inherited from TestTowerJetsCommon + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # _on_is_available Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_on_is_available_explicit_request_marked_processing_before_dispatch(self): + """ + Regression: explicit request must be attached to the jet and set to + processing before transition dispatch starts. + + We patch _bring_to_state (the actual dispatch) rather than + _serve_jet_request so that _serve_jet_request runs for real and its + side-effects (served_jet_request_id, request.state) are observable. + A side_effect captures both values at the exact moment dispatch is + triggered, proving ordering rather than just eventual state. + """ + self.jet_test.write( + {"state_id": self.state_initial.id, "target_state_id": False} + ) + # Isolate the scenario: keep only the request created in this test. + preexisting_new_requests = self.env["cx.tower.jet.request"].search( + [("jet_id", "=", self.jet_test.id), ("state", "=", "new")] + ) + if preexisting_new_requests: + preexisting_new_requests.unlink() + request = self.env["cx.tower.jet.request"].create( + { + "server_id": self.server_test_1.id, + "jet_id": self.jet_test.id, + "jet_template_id": self.jet_test.jet_template_id.id, + "state_requested_id": self.state_running.id, + "state": "new", + } + ) + + # Capture the observable state of jet + request at dispatch time. + observed = {} + + def capture(jet_self, target_state): + jet_self.invalidate_recordset(["served_jet_request_id"]) + request.invalidate_recordset(["state"]) + observed["served_request_id"] = jet_self.served_jet_request_id.id + observed["request_state"] = request.state + + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._bring_to_state", + autospec=True, + side_effect=capture, + ): + self.jet_test._on_is_available() + + self.assertTrue( + observed, + "_bring_to_state must have been called; check that the request " + "targets a different state than the jet's current state", + ) + self.assertEqual( + observed["served_request_id"], + request.id, + "Request must be saved to served_jet_request_id before dispatch", + ) + self.assertEqual( + observed["request_state"], + "processing", + "Request must be set to 'processing' before _bring_to_state is called", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # _compute_available_actions Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_compute_available_actions_no_state(self): + """ + Test _compute_available_actions when jet has no current state + """ + # Jet has template but no state + self.jet_test.state_id = False + + # action_available_ids should include only the create action + self.assertEqual( + len(self.jet_test.action_available_ids), + 1, + "Available actions should include create action when jet has no state", + ) + self.assertEqual( + {action.id for action in self.jet_test.action_available_ids}, + {self.action_create.id}, + "Available action should be the create action", + ) + + def test_compute_available_actions_with_state_running(self): + """ + Test _compute_available_actions when jet has state running. + Create action is not available (no state_from_id); destroy and + transition actions are available. + """ + self.jet_test.state_id = self.state_running + + expected_actions = ( + self.action_running_to_stopped + | self.action_running_to_error + | self.action_destroy + ) + actual_ids = {action.id for action in self.jet_test.action_available_ids} + + self.assertEqual( + len(actual_ids), + 3, + "Should have 3 available actions from running state", + ) + self.assertNotIn( + self.action_create.id, + actual_ids, + "Create action should not be available when jet has state", + ) + self.assertIn( + self.action_destroy.id, + actual_ids, + "Destroy action should be available", + ) + self.assertEqual( + actual_ids, + {action.id for action in expected_actions}, + "Should have exact set: running_to_stopped, running_to_error, destroy", + ) + + def test_compute_available_actions_complex_scenario(self): + """ + Test _compute_available_actions with complex scenario + """ + # Use common actions from setup + + # Test different states + test_cases = [ + (self.state_initial, [self.action_initial_to_running]), + ( + self.state_running, + [ + self.action_running_to_stopped, + self.action_running_to_error, + self.action_destroy, + ], + ), + (self.state_stopped, [self.action_stopped_to_running]), + (self.state_error, [self.action_error_to_running]), + ] + + for state, expected_actions in test_cases: + self.jet_test.state_id = state + actual_actions = self.jet_test.action_available_ids + expected_actions_set = {action.id for action in expected_actions} + actual_actions_set = {action.id for action in actual_actions} + + self.assertEqual( + actual_actions_set, + expected_actions_set, + f"State {state.name} should have correct available actions", + ) + + def test_compute_available_actions_dependencies(self): + """ + Test that _compute_available_actions has correct dependencies + """ + # Use existing action from common setup + action = self.action_running_to_stopped + + # Set initial state + self.jet_test.state_id = self.state_running + # Should have all actions from running state + expected_actions = ( + self.action_running_to_stopped + | self.action_running_to_error + | self.action_destroy + ) + self.assertEqual( + {action.id for action in self.jet_test.action_available_ids}, + {action.id for action in expected_actions}, + "Should have all actions from running state initially", + ) + + # Change action's state_from_id (this should trigger recomputation) + action.state_from_id = self.state_stopped + + # Jet should no longer have this specific action available + # but should still have other actions from running state + expected_remaining_actions = self.action_running_to_error | self.action_destroy + self.assertEqual( + {action.id for action in self.jet_test.action_available_ids}, + {action.id for action in expected_remaining_actions}, + "Should have remaining actions after changing one action's state_from_id", + ) + + # Change jet state to match action's new state_from_id + self.jet_test.state_id = self.state_stopped + + # Now the modified action should be available again, + # plus any other actions from stopped state + expected_actions = action | self.action_stopped_to_running + self.assertEqual( + {action.id for action in self.jet_test.action_available_ids}, + {action.id for action in expected_actions}, + "Should have the modified action plus other actions from stopped state", + ) + + def test_compute_available_actions_cross_template_isolation(self): + """ + Test that jets only see actions from their own template + """ + # Create action for Odoo template + odoo_action = self.JetAction.create( + { + "name": "Odoo Action", + "reference": "odoo_action", + "jet_template_id": self.jet_template_odoo.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Create action for WordPress template + wp_action = self.JetAction.create( + { + "name": "WordPress Action", + "reference": "wordpress_action", + "jet_template_id": self.jet_template_wordpress.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Set both jets to running state + self.jet_odoo.state_id = self.state_running + self.jet_wordpress.state_id = self.state_running + + # Each jet should only see its own template's actions + self.assertEqual( + {action.id for action in self.jet_odoo.action_available_ids}, + {odoo_action.id}, + "Odoo jet should only see Odoo actions", + ) + self.assertEqual( + {action.id for action in self.jet_wordpress.action_available_ids}, + {wp_action.id}, + "WordPress jet should only see WordPress actions", + ) + + # Odoo jet should not see WordPress actions + self.assertNotIn( + wp_action.id, + {action.id for action in self.jet_odoo.action_available_ids}, + "Odoo jet should not see WordPress actions", + ) + # WordPress jet should not see Odoo actions + self.assertNotIn( + odoo_action.id, + {action.id for action in self.jet_wordpress.action_available_ids}, + "WordPress jet should not see Odoo actions", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Complex Template Hierarchy Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_jet_template_domain_computation(self): + """ + Test _compute_jet_template_domain method + """ + # Test with server set + jet_with_server = self.Jet.create( + { + "name": "Jet With Server", + "reference": "jet_with_server", + "jet_template_id": self.jet_template_test.id, + "server_id": self.server_test_1.id, + } + ) + domain = jet_with_server.jet_template_domain + expected_domain = [("server_ids", "in", [self.server_test_1.id])] + self.assertEqual(domain, expected_domain, "Domain should include server filter") + + # Test domain computation with a different server + server_test_2 = self.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "192.168.1.2", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "host_key": "test_key_2", + "os_id": self.os_debian_10.id, + } + ) + jet_with_different_server = self.Jet.create( + { + "name": "Jet With Different Server", + "reference": "jet_with_different_server", + "jet_template_id": self.jet_template_test.id, + "server_id": server_test_2.id, + } + ) + domain = jet_with_different_server.jet_template_domain + expected_domain = [("server_ids", "in", [server_test_2.id])] + self.assertEqual( + domain, + expected_domain, + "Domain should include server filter for different server", + ) + + # Test the domain computation method directly to verify the else branch + # Create a temporary jet object to test the method without saving + temp_jet = self.Jet.new( + { + "name": "Temp Jet", + "jet_template_id": self.jet_template_test.id, + "server_id": False, + } + ) + temp_jet._compute_jet_template_domain() + self.assertEqual( + temp_jet.jet_template_domain, + [], + "Domain should be empty when server_id is False", + ) + + def test_jet_requires_ids_computation(self): + """ + Test _compute_jet_requires_ids method with complex dependencies + """ + # Test Odoo jet dependencies + odoo_deps = self.jet_odoo.jet_requires_ids + self.assertEqual( + len(odoo_deps), 2, "Odoo jet should have 2 direct dependencies" + ) + + # Check that dependencies are for postgres and nginx + dep_template_ids = odoo_deps.mapped( + "jet_template_dependency_id.template_required_id.id" + ) + expected_ids = {self.jet_template_postgres.id, self.jet_template_nginx.id} + self.assertEqual( + set(dep_template_ids), expected_ids, "Should depend on postgres and nginx" + ) + + # Test WooCommerce jet dependencies + # (should include both Odoo and WordPress deps) + woocommerce_deps = self.jet_woocommerce.jet_requires_ids + self.assertEqual( + len(woocommerce_deps), + 2, + "WooCommerce jet should have 2 direct dependencies", + ) + + # Check that dependencies are for wordpress and odoo + dep_template_ids = woocommerce_deps.mapped( + "jet_template_dependency_id.template_required_id.id" + ) + expected_ids = {self.jet_template_wordpress.id, self.jet_template_odoo.id} + self.assertEqual( + set(dep_template_ids), expected_ids, "Should depend on wordpress and odoo" + ) + + def test_jet_limit_per_server_same_server_rejected(self): + """Constraint rejects creating more jets than template limit per server.""" + template = self.JetTemplate.create( + { + "name": "Template With Limit", + "reference": "template_with_limit", + "limit_per_server": 1, + } + ) + self.Jet.create( + { + "name": "Limited Jet 1", + "reference": "limited_jet_1", + "jet_template_id": template.id, + "server_id": self.server_test_1.id, + } + ) + + with self.assertRaisesRegex(ValidationError, "Jet limit per server reached"): + self.Jet.create( + { + "name": "Limited Jet 2", + "reference": "limited_jet_2", + "jet_template_id": template.id, + "server_id": self.server_test_1.id, + } + ) + + def test_jet_limit_per_server_different_servers_allowed(self): + """ + Constraint allows same template on different servers + but within per-server limit. + """ + template = self.JetTemplate.create( + { + "name": "Template With Per-Server Limit", + "reference": "template_with_per_server_limit", + "limit_per_server": 1, + } + ) + server_test_2 = self.Server.create( + { + "name": "Jet Limit Test Server 2", + "ip_v4_address": "192.168.1.22", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "host_key": "jet_limit_test_server_2_key", + "os_id": self.os_debian_10.id, + } + ) + + jet_on_server_1 = self.Jet.create( + { + "name": "Limited Jet Server 1", + "reference": "limited_jet_server_1", + "jet_template_id": template.id, + "server_id": self.server_test_1.id, + } + ) + jet_on_server_2 = self.Jet.create( + { + "name": "Limited Jet Server 2", + "reference": "limited_jet_server_2", + "jet_template_id": template.id, + "server_id": server_test_2.id, + } + ) + + self.assertTrue( + jet_on_server_1.exists(), "Jet on first server should be created" + ) + self.assertTrue( + jet_on_server_2.exists(), "Jet on second server should be created" + ) + + def test_jet_requires_ids_template_change(self): + """ + Test _compute_jet_requires_ids for different templates + """ + # Create jets for different templates + jet_tower_core = self.Jet.create( + { + "name": "Tower Core Jet", + "reference": "tower_core_jet", + "jet_template_id": self.jet_template_tower_core.id, + "server_id": self.server_test_1.id, + } + ) + self.assertEqual( + len(jet_tower_core.jet_requires_ids), + 0, + "Tower core should have no dependencies", + ) + + jet_odoo = self.Jet.create( + { + "name": "Odoo Jet Test", + "reference": "odoo_jet_test", + "jet_template_id": self.jet_template_odoo.id, + "server_id": self.server_test_1.id, + } + ) + self.assertEqual( + len(jet_odoo.jet_requires_ids), 2, "Odoo should have 2 dependencies" + ) + + jet_woocommerce = self.Jet.create( + { + "name": "WooCommerce Jet Test", + "reference": "woocommerce_jet_test", + "jet_template_id": self.jet_template_woocommerce_odoo.id, + "server_id": self.server_test_1.id, + } + ) + self.assertEqual( + len(jet_woocommerce.jet_requires_ids), + 2, + "WooCommerce should have 2 dependencies", + ) + + def test_jet_requires_ids_dependency_removal(self): + """ + Test _compute_jet_requires_ids when template dependencies are removed + """ + # Create jet with Odoo template + jet_odoo = self.Jet.create( + { + "name": "Odoo Jet Test", + "reference": "odoo_jet_test", + "jet_template_id": self.jet_template_odoo.id, + "server_id": self.server_test_1.id, + } + ) + initial_deps = len(jet_odoo.jet_requires_ids) + self.assertEqual(initial_deps, 2, "Should have 2 dependencies initially") + + # Remove one dependency from template + postgres_dep = self.JetTemplateDependency.search( + [ + ("template_id", "=", self.jet_template_odoo.id), + ("template_required_id", "=", self.jet_template_postgres.id), + ] + ) + postgres_dep.unlink() + + # Jet dependencies should be updated + self.assertEqual( + len(jet_odoo.jet_requires_ids), 1, "Should have 1 dependency after removal" + ) + remaining_dep = jet_odoo.jet_requires_ids[0] + self.assertEqual( + remaining_dep.jet_template_dependency_id.template_required_id, + self.jet_template_nginx, + "Remaining dependency should be nginx", + ) + + def test_jet_requires_ids_dependency_addition(self): + """ + Test _compute_jet_requires_ids when template dependencies are added + """ + # Create jet with tower core (no dependencies) + jet_tower_core = self.Jet.create( + { + "name": "Tower Core Jet", + "reference": "tower_core_jet", + "jet_template_id": self.jet_template_tower_core.id, + "server_id": self.server_test_1.id, + } + ) + self.assertEqual( + len(jet_tower_core.jet_requires_ids), + 0, + "Should have no dependencies initially", + ) + + # Add dependency to tower core + # (use a template that won't create circular dependency) + new_dep = self.JetTemplateDependency.create( + { + "template_id": self.jet_template_tower_core.id, + "template_required_id": self.jet_template_test.id, + "state_required_id": self.state_running.id, + } + ) + + # Jet dependencies should be updated + self.assertEqual( + len(jet_tower_core.jet_requires_ids), + 1, + "Should have 1 dependency after addition", + ) + added_dep = jet_tower_core.jet_requires_ids[0] + self.assertEqual( + added_dep.jet_template_dependency_id, + new_dep, + "Added dependency should match the new dependency", + ) + + def test_jet_requires_ids_multiple_jets_same_template(self): + """ + Test _compute_jet_requires_ids with multiple jets using same template + """ + # Create another Odoo jet + jet_odoo_2 = self.Jet.create( + { + "name": "Odoo Jet 2", + "reference": "odoo_jet_2", + "jet_template_id": self.jet_template_odoo.id, + "server_id": self.server_test_1.id, + } + ) + + # Both jets should have same dependencies + deps_1 = self.jet_odoo.jet_requires_ids + deps_2 = jet_odoo_2.jet_requires_ids + + self.assertEqual( + len(deps_1), + len(deps_2), + "Both jets should have same number of dependencies", + ) + + # Check that dependencies are the same + deps_1_template_ids = deps_1.mapped( + "jet_template_dependency_id.template_required_id.id" + ) + deps_2_template_ids = deps_2.mapped( + "jet_template_dependency_id.template_required_id.id" + ) + self.assertEqual( + set(deps_1_template_ids), + set(deps_2_template_ids), + "Both jets should have same dependency templates", + ) + + def test_jet_requires_ids_consistency_with_template(self): + """ + Test that jet dependencies are consistent with template dependencies + """ + # Test with different templates + templates_to_test = [ + (self.jet_template_tower_core, 0), + (self.jet_template_docker, 1), + (self.jet_template_nginx, 1), + (self.jet_template_postgres, 1), + (self.jet_template_mariadb, 1), + (self.jet_template_odoo, 2), + (self.jet_template_wordpress, 2), + (self.jet_template_woocommerce_odoo, 2), + ] + + for template, expected_dep_count in templates_to_test: + # Create a jet with this template + test_jet = self.Jet.create( + { + "name": f"Test Jet for {template.name}", + "reference": f"test_jet_{template.reference}", + "jet_template_id": template.id, + "server_id": self.server_test_1.id, + } + ) + + # Check dependency count + actual_dep_count = len(test_jet.jet_requires_ids) + self.assertEqual( + actual_dep_count, + expected_dep_count, + f"{template.name} should have {expected_dep_count} " + f"dependencies, got {actual_dep_count}", + ) + + # Verify that all jet dependencies correspond to template dependencies + template_deps = template.template_requires_ids + jet_deps = test_jet.jet_requires_ids + + if template_deps: + self.assertEqual( + len(jet_deps), + len(template_deps), + "Jet dependencies count should match" + f" template dependencies for {template.name}", + ) + + # Check that each jet dependency corresponds to a template dependency + jet_dep_template_ids = jet_deps.mapped("jet_template_dependency_id.id") + template_dep_ids = template_deps.ids + self.assertEqual( + set(jet_dep_template_ids), + set(template_dep_ids), + "Jet dependencies should match template" + f" dependencies for {template.name}", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # bring_to_state Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_bring_to_state_success_user_level(self): + """ + Test bring_to_state succeeds when user has sufficient access level. + User (level 1) can access state with level 1. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to initial state + self.jet_test.write({"state_id": self.state_initial.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # User should be able to bring jet to user-level state + self.jet_test.with_user(self.user).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_running") + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Jet should be brought to user-level state by user", + ) + + def test_bring_to_state_success_manager_level(self): + """ + Test bring_to_state succeeds when manager has sufficient access level. + Manager (level 2) can access state with level 2. + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Manager should be able to bring jet to manager-level state + self.jet_test.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_stopped") + self.assertEqual( + self.jet_test.state_id, + self.state_stopped, + "Jet should be brought to manager-level state by manager", + ) + + def test_bring_to_state_success_root_level(self): + """ + Test bring_to_state succeeds when root has sufficient access level. + Root (level 3) can access state with level 3. + """ + # Use existing state and set it to Root access level (3) + self.state_error.access_level = "3" + self.state_error.invalidate_recordset(["access_level"]) + + # Root has full access, but ensure access for consistency + self.jet_test.write({"manager_ids": [(4, self.root.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.root.id)]}) + + # Set jet to running state (which has action to error) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Root should be able to bring jet to root-level state + self.jet_test.with_user(self.root).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_error") + self.assertEqual( + self.jet_test.state_id, + self.state_error, + "Jet should be brought to root-level state by root", + ) + + def test_bring_to_state_access_error_user_to_manager(self): + """ + Test bring_to_state raises AccessError when user (level 1) + tries to access manager-level state (level 2). + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet (for the access check to work) + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # User should not be able to bring jet to manager-level state + with self.assertRaises(AccessError) as context: + self.jet_test.with_user(self.user).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_stopped") + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_stopped.name, + str(context.exception), + "Error message should include state name", + ) + + def test_bring_to_state_access_error_user_to_root(self): + """ + Test bring_to_state raises AccessError when user (level 1) + tries to access root-level state (level 3). + """ + # Use existing state and set it to Root access level (3) + self.state_error.access_level = "3" + self.state_error.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet (for the access check to work) + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to running state (which has action to error) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # User should not be able to bring jet to root-level state + with self.assertRaises(AccessError) as context: + self.jet_test.with_user(self.user).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_error") + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_error.name, + str(context.exception), + "Error message should include state name", + ) + + def test_bring_to_state_access_error_manager_to_root(self): + """ + Test bring_to_state raises AccessError when manager (level 2) + tries to access root-level state (level 3). + """ + # Use existing state and set it to Root access level (3) + self.state_error.access_level = "3" + self.state_error.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet (for the access check to work) + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Set jet to running state (which has action to error) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Manager should not be able to bring jet to root-level state + with self.assertRaises(AccessError) as context: + self.jet_test.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_error") + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_error.name, + str(context.exception), + "Error message should include state name", + ) + + def test_bring_to_state_manager_can_access_user_level(self): + """ + Test bring_to_state succeeds when manager (level 2) who IS in manager_ids + accesses user-level state (level 1). + Higher access levels can access lower level states. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet + # Manager IS in manager_ids, so they keep their manager access level (2) + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Set jet to initial state + self.jet_test.write({"state_id": self.state_initial.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Manager should be able to bring jet to user-level state + self.jet_test.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_running") + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Manager should be able to access user-level state", + ) + + def test_bring_to_state_manager_not_in_manager_ids_treated_as_user(self): + """ + Test bring_to_state treats manager (level 2) who is NOT in manager_ids + as user (level 1). + Manager should be able to set user-level state but not manager-level state. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet via user_ids but NOT via manager_ids + self.jet_test.write({"user_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + # Explicitly ensure manager is NOT in manager_ids + self.jet_test.write({"manager_ids": [(5, 0, 0)]}) + + # Set jet to initial state + self.jet_test.write({"state_id": self.state_initial.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Manager (treated as user) should be able to bring jet to user-level state + self.jet_test.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_running") + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Manager not in manager_ids should be able to access user-level state", + ) + + def test_bring_to_state_manager_not_in_manager_ids_cannot_access_manager_level( + self, + ): + """ + Test bring_to_state raises AccessError when manager (level 2) who is NOT + in manager_ids tries to access manager-level state (level 2). + Manager should be treated as user (level 1) and cannot access level 2. + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet via user_ids but NOT via manager_ids + self.jet_test.write({"user_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + # Explicitly ensure manager is NOT in manager_ids + self.jet_test.write({"manager_ids": [(5, 0, 0)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Manager (treated as user) should not be able to bring jet + # to manager-level state + with self.assertRaises(AccessError) as context: + self.jet_test.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_stopped") + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_stopped.name, + str(context.exception), + "Error message should include state name", + ) + + def test_bring_to_state_root_can_access_manager_level(self): + """ + Test bring_to_state succeeds when root (level 3) + accesses manager-level state (level 2). + Higher access levels can access lower level states. + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Root has full access, but ensure access for consistency + self.jet_test.write({"manager_ids": [(4, self.root.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.root.id)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.write({"state_id": self.state_running.id}) + self.jet_test.invalidate_recordset(["state_id"]) + + # Root should be able to bring jet to manager-level state + self.jet_test.with_user(self.root).with_context( + cetmix_tower_no_commit=True + ).bring_to_state("test_stopped") + self.assertEqual( + self.jet_test.state_id, + self.state_stopped, + "Root should be able to access manager-level state", + ) + + def test_bring_to_state_invalid_reference(self): + """ + Test bring_to_state raises ValidationError when state reference is invalid. + """ + # Set jet to initial state + self.jet_test.state_id = self.state_initial + + # Should raise ValidationError for invalid state reference + with self.assertRaises(ValidationError) as context: + self.jet_test.with_context(cetmix_tower_no_commit=True).bring_to_state( + "invalid_state_reference" + ) + + self.assertIn( + "State 'invalid_state_reference' not found", + str(context.exception), + "Should raise ValidationError with appropriate message", + ) + self.assertIn( + self.jet_test.display_name, + str(context.exception), + "Error message should include jet display name", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # _get_user_effective_access_level Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_get_user_effective_access_level_user(self): + """ + Test _get_user_effective_access_level returns "1" for user. + """ + # Ensure user has access to the jet + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + + # User should have effective access level "1" + effective_level = self.jet_test.with_user( + self.user + )._get_user_effective_access_level() + self.assertEqual( + effective_level, + "1", + "User should have effective access level 1", + ) + + def test_get_user_effective_access_level_manager_in_manager_ids(self): + """ + Test _get_user_effective_access_level returns "2" for manager + who IS in manager_ids. + """ + # Ensure manager has access to the jet and IS in manager_ids + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + + # Manager in manager_ids should have effective access level "2" + effective_level = self.jet_test.with_user( + self.manager + )._get_user_effective_access_level() + self.assertEqual( + effective_level, + "2", + "Manager in manager_ids should have effective access level 2", + ) + + def test_get_user_effective_access_level_manager_not_in_manager_ids(self): + """ + Test _get_user_effective_access_level returns "1" for manager + who is NOT in manager_ids (downgraded to user level). + """ + # Ensure manager has access to the jet via user_ids but NOT via manager_ids + self.jet_test.write({"user_ids": [(4, self.manager.id)]}) + # Explicitly ensure manager is NOT in manager_ids + self.jet_test.write({"manager_ids": [(5, 0, 0)]}) + + # Manager not in manager_ids should have effective access level "1" + effective_level = self.jet_test.with_user( + self.manager + )._get_user_effective_access_level() + self.assertEqual( + effective_level, + "1", + "Manager not in manager_ids should have effective access level 1", + ) + + def test_get_user_effective_access_level_root(self): + """ + Test _get_user_effective_access_level returns "3" for root. + """ + # Root should have effective access level "3" regardless of manager_ids + effective_level = self.jet_test.with_user( + self.root + )._get_user_effective_access_level() + self.assertEqual( + effective_level, + "3", + "Root should have effective access level 3", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # unlink Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_unlink_deletable_jet_with_files(self): + """ + Test unlink succeeds when jet is deletable and has files. + Files should be unlinked after the jet is deleted. + """ + # Create a deletable jet (deletable defaults to True) + jet = self._create_jet( + "Deletable Jet", + "deletable_jet", + ) + + # Create files linked to the jet + file1 = self.File.create( + { + "name": "test_file_1.txt", + "source": "tower", + "server_id": self.server_test_1.id, + "server_dir": "/tmp", + "jet_id": jet.id, + "file_type": "text", + } + ) + file2 = self.File.create( + { + "name": "test_file_2.txt", + "source": "tower", + "server_id": self.server_test_1.id, + "server_dir": "/tmp", + "jet_id": jet.id, + "file_type": "text", + } + ) + + # Verify files exist + self.assertEqual(len(jet.file_ids), 2, "Jet should have 2 files") + self.assertIn(file1, jet.file_ids, "File 1 should be linked to jet") + self.assertIn(file2, jet.file_ids, "File 2 should be linked to jet") + + # Store file IDs before deletion + file_ids = {file1.id, file2.id} + + # Unlink the jet + jet.unlink() + + # Verify jet is deleted + self.assertFalse(jet.exists(), "Jet should be deleted") + + # Verify files are also deleted + remaining_files = self.File.browse(list(file_ids)) + self.assertFalse( + remaining_files.exists(), + "Files should be unlinked after jet deletion", + ) + + def test_unlink_deletable_jet_without_files(self): + """ + Test unlink succeeds when jet is deletable but has no files. + """ + # Create a deletable jet without files (deletable defaults to True) + jet = self._create_jet( + "Deletable Jet No Files", + "deletable_jet_no_files", + ) + + # Verify jet has no files + self.assertEqual(len(jet.file_ids), 0, "Jet should have no files") + + # Unlink the jet + jet.unlink() + + # Verify jet is deleted + self.assertFalse(jet.exists(), "Jet should be deleted") + + def test_unlink_not_deletable_jet_raises_error(self): + """ + Test unlink raises ValidationError when jet is not deletable. + """ + # Create a non-deletable jet + jet = self._create_jet( + "Not Deletable Jet", + "not_deletable_jet", + ) + jet.write({"deletable": False}) + + # Attempt to unlink should raise ValidationError + with self.assertRaises(ValidationError) as context: + jet.unlink() + + # Verify error message contains jet display name + self.assertIn( + "cannot be deleted", + str(context.exception), + "Error message should mention deletion restriction", + ) + self.assertIn( + jet.display_name, + str(context.exception), + "Error message should include jet display name", + ) + + # Verify jet still exists + self.assertTrue(jet.exists(), "Jet should not be deleted") + + def test_unlink_multiple_jets_mixed_deletable(self): + """ + Test unlink with multiple jets where some are deletable and some are not. + Should raise ValidationError listing non-deletable jets. + """ + # Create deletable jet (deletable defaults to True) + deletable_jet = self._create_jet( + "Deletable Jet", + "deletable_jet_multi", + ) + + # Create non-deletable jet + not_deletable_jet = self._create_jet( + "Not Deletable Jet", + "not_deletable_jet_multi", + ) + not_deletable_jet.write({"deletable": False}) + + # Attempt to unlink both should raise ValidationError + jets = deletable_jet | not_deletable_jet + with self.assertRaises(ValidationError) as context: + jets.unlink() + + # Verify error message contains non-deletable jet display name + self.assertIn( + "cannot be deleted", + str(context.exception), + "Error message should mention deletion restriction", + ) + self.assertIn( + not_deletable_jet.display_name, + str(context.exception), + "Error message should include non-deletable jet display name", + ) + + # Verify both jets still exist + self.assertTrue(deletable_jet.exists(), "Deletable jet should not be deleted") + self.assertTrue( + not_deletable_jet.exists(), "Non-deletable jet should not be deleted" + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # create_waypoint Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_create_waypoint_with_record_template(self): + """ + Test create_waypoint with waypoint template record + """ + # Get the default name from the helper function + default_vals = self.jet_test._prepare_waypoint_values( + self.waypoint_template, name=None + ) + expected_default_name = default_vals["name"] + + # Create waypoint using template record + waypoint = self.jet_test.create_waypoint(self.waypoint_template) + + # Should return a waypoint record + self.assertTrue(waypoint, "Should return a waypoint record") + self.assertTrue(waypoint.exists(), "Waypoint should exist") + self.assertEqual( + waypoint.jet_id.id, + self.jet_test.id, + "Waypoint should belong to the jet", + ) + self.assertEqual( + waypoint.waypoint_template_id.id, + self.waypoint_template.id, + "Waypoint should use the correct template", + ) + self.assertEqual( + waypoint.name, + expected_default_name, + "Waypoint should have default name from helper function", + ) + # Reference is auto-generated, so just verify it exists and is not empty + self.assertTrue( + waypoint.reference, + "Waypoint should have an auto-generated reference", + ) + + def test_create_waypoint_with_string_reference(self): + """ + Test create_waypoint with waypoint template string reference + """ + # Use the template's reference (mandatory field, always present) + template_reference = self.waypoint_template.reference + + # Create waypoint using string reference + waypoint = self.jet_test.create_waypoint(template_reference) + + # Should return a waypoint record + self.assertTrue(waypoint, "Should return a waypoint record") + self.assertTrue(waypoint.exists(), "Waypoint should exist") + self.assertEqual( + waypoint.waypoint_template_id.id, + self.waypoint_template.id, + "Waypoint should use the correct template from reference", + ) + # Reference is auto-generated, so just verify it exists and is not empty + self.assertTrue( + waypoint.reference, + "Waypoint should have an auto-generated reference", + ) + + def test_create_waypoint_with_name(self): + """ + Test create_waypoint with custom name + """ + # Create waypoint with custom name + waypoint = self.jet_test.create_waypoint( + self.waypoint_template, name="Custom Waypoint Name" + ) + + # Should return a waypoint record with custom name + self.assertTrue(waypoint, "Should return a waypoint record") + self.assertEqual( + waypoint.name, + "Custom Waypoint Name", + "Waypoint should have the custom name", + ) + # Reference is auto-generated, so just verify it exists and is not empty + self.assertTrue( + waypoint.reference, + "Waypoint should have an auto-generated reference", + ) + + def test_create_waypoint_with_fly_here(self): + """ + Test create_waypoint with fly_here parameter + Note: fly_here should set is_destination=True, and after prepare() + the waypoint should automatically fly to if is_destination is True + """ + # Create waypoint with fly_here=True + waypoint = self.jet_test.create_waypoint(self.waypoint_template, fly_here=True) + + # Should return a waypoint record + self.assertTrue(waypoint, "Should return a waypoint record") + self.assertTrue(waypoint.exists(), "Waypoint should exist") + + # Verify that the waypoint flew to + # (state should be "current" in synchronous tests) + self.assertEqual( + waypoint.state, + "current", + "Waypoint should have flown to and " + "become current (tests run synchronously)", + ) + + # Verify jet's waypoint_id was updated + self.assertEqual( + self.jet_test.waypoint_id.id, + waypoint.id, + "Jet's waypoint_id should be updated to the flown-to waypoint", + ) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet") + def test_create_waypoint_jet_busy(self): + """ + Test create_waypoint when jet is busy (has target_state_id) + """ + # Set jet to busy state (has target_state_id) + self.jet_test.target_state_id = self.state_running + + # Try to create waypoint + with self.assertRaises(ValidationError): + self.jet_test.create_waypoint(self.waypoint_template) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet") + def test_create_waypoint_template_not_found(self): + """ + Test create_waypoint with non-existent template reference + """ + # Mute logger error for this test + with self.assertRaises(ValidationError): + self.jet_test.create_waypoint("non_existent_reference") + + @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet") + def test_create_waypoint_template_wrong_jet_template(self): + """ + Test create_waypoint with template from different jet template + """ + # Create a waypoint template for a different jet template + other_jet_template = self.JetTemplate.create( + { + "name": "Other Jet Template", + "reference": "other_jet_template", + } + ) + other_waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Other Waypoint Template", + "jet_template_id": other_jet_template.id, + } + ) + + # Mute logger error for this test + with self.assertRaises(ValidationError): + # Try to create waypoint with template from different jet template + self.jet_test.create_waypoint(other_waypoint_template) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Create a Waypoint command (flight plan) tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_create_waypoint_command_success_fly_here_false(self): + """Create a Waypoint command from flight plan: waypoint created, log + finished by callback.""" + command = self.Command.create( + { + "name": "Create waypoint command", + "action": "create_waypoint", + "waypoint_template_id": self.waypoint_template.id, + "fly_here": False, + } + ) + plan = self.Plan.create({"name": "Plan create waypoint"}) + self.plan_line.create( + { + "plan_id": plan.id, + "sequence": 10, + "command_id": command.id, + } + ) + initial_waypoint_count = len(self.jet_test.waypoint_ids) + plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) + self.assertTrue(plan_log, "Plan log should be created") + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + self.assertEqual( + len(command_logs), 1, "Exactly one command log for create_waypoint" + ) + log_record = command_logs[0] + self.assertTrue( + log_record.finish_date, + "Command log should be finished by waypoint callback", + ) + self.assertEqual( + log_record.command_status, + 0, + "Command should finish with success", + ) + self.assertEqual( + len(self.jet_test.waypoint_ids), + initial_waypoint_count + 1, + "One new waypoint should be created", + ) + new_waypoint = self.jet_test.waypoint_ids.filtered( + lambda w: w.created_from_command_log_id == log_record + ) + self.assertEqual(len(new_waypoint), 1, "One waypoint linked to command log") + new_waypoint = new_waypoint[0] + self.assertEqual( + new_waypoint.state, + "ready", + "Waypoint should be in ready state (fly_here=False)", + ) + self.assertEqual( + new_waypoint.created_from_command_log_id, + log_record, + "Waypoint should reference the command log", + ) + + def test_create_waypoint_command_success_fly_here_true(self): + """Create a Waypoint command with fly_here: waypoint becomes current.""" + command = self.Command.create( + { + "name": "Create waypoint fly here", + "action": "create_waypoint", + "waypoint_template_id": self.waypoint_template.id, + "fly_here": True, + } + ) + plan = self.Plan.create({"name": "Plan create waypoint fly here"}) + self.plan_line.create( + { + "plan_id": plan.id, + "sequence": 10, + "command_id": command.id, + } + ) + plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + log_record = command_logs[0] + self.assertTrue(log_record.finish_date, "Command log should be finished") + self.assertEqual(log_record.command_status, 0, "Command should succeed") + waypoints_with_log = self.jet_test.waypoint_ids.filtered( + lambda w: w.created_from_command_log_id == log_record + ) + self.assertEqual( + len(waypoints_with_log), + 1, + "One waypoint created from command", + ) + self.assertEqual( + waypoints_with_log.state, + "current", + "Waypoint should be current when fly_here=True", + ) + self.assertEqual( + self.jet_test.waypoint_id, + waypoints_with_log, + "Jet waypoint_id should point to the new waypoint", + ) + + def test_create_waypoint_command_no_jet(self): + """Create a Waypoint command run without jet: command log finished + with JET_NOT_FOUND.""" + from ..models.constants import JET_NOT_FOUND + + command = self.Command.create( + { + "name": "Create waypoint no jet", + "action": "create_waypoint", + "waypoint_template_id": self.waypoint_template.id, + "fly_here": False, + } + ) + plan = self.Plan.create({"name": "Plan no jet"}) + self.plan_line.create( + {"plan_id": plan.id, "sequence": 10, "command_id": command.id} + ) + plan_log = self.server_test_1.sudo().run_flight_plan(plan) + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + self.assertEqual(len(command_logs), 1) + self.assertEqual( + command_logs.command_status, + JET_NOT_FOUND, + "Should finish with JET_NOT_FOUND when no jet in plan", + ) + self.assertTrue(command_logs.finish_date) + + def test_create_waypoint_command_no_template(self): + """Create a Waypoint command without waypoint template: + WAYPOINT_TEMPLATE_NOT_FOUND.""" + from ..models.constants import WAYPOINT_TEMPLATE_NOT_FOUND + + command = self.Command.create( + { + "name": "Create waypoint no template", + "action": "create_waypoint", + "fly_here": False, + } + ) + plan = self.Plan.create({"name": "Plan no template"}) + self.plan_line.create( + {"plan_id": plan.id, "sequence": 10, "command_id": command.id} + ) + plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + self.assertEqual(len(command_logs), 1) + self.assertEqual( + command_logs.command_status, + WAYPOINT_TEMPLATE_NOT_FOUND, + "Should finish with WAYPOINT_TEMPLATE_NOT_FOUND", + ) + + def test_create_waypoint_command_jet_busy(self): + """ + Create a Waypoint when jet is busy (e.g. from flight plan): + ignore_busy=True, waypoint created, log success. + """ + self.jet_test.target_state_id = self.state_running + command = self.Command.create( + { + "name": "Create waypoint jet busy", + "action": "create_waypoint", + "waypoint_template_id": self.waypoint_template.id, + "fly_here": False, + } + ) + plan = self.Plan.create({"name": "Plan jet busy"}) + self.plan_line.create( + {"plan_id": plan.id, "sequence": 10, "command_id": command.id} + ) + initial_waypoint_count = len(self.jet_test.waypoint_ids) + with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet"): + plan_log = self.server_test_1.sudo().run_flight_plan( + plan, jet=self.jet_test + ) + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + self.assertEqual(len(command_logs), 1) + self.assertTrue( + command_logs.finish_date, + "Command log should be finished by waypoint callback when jet busy", + ) + self.assertEqual( + command_logs.command_status, + 0, + "Create waypoint command should succeed when jet is busy " + "(ignore_busy=True)", + ) + self.assertEqual( + len(self.jet_test.waypoint_ids), + initial_waypoint_count + 1, + "One new waypoint should be created despite jet busy", + ) + self.jet_test.target_state_id = False + + def test_create_waypoint_command_wrong_jet_template(self): + """Create a Waypoint with template for another jet template: False + and WAYPOINT_CREATE_FAILED.""" + from ..models.constants import WAYPOINT_CREATE_FAILED + + other_jet_template = self.JetTemplate.create( + { + "name": "Other Jet Template", + "reference": "other_jet_template_cmd", + } + ) + other_waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Other Waypoint Template", + "jet_template_id": other_jet_template.id, + } + ) + command = self.Command.create( + { + "name": "Create waypoint wrong template", + "action": "create_waypoint", + "waypoint_template_id": other_waypoint_template.id, + "fly_here": False, + } + ) + plan = self.Plan.create({"name": "Plan wrong template"}) + self.plan_line.create( + {"plan_id": plan.id, "sequence": 10, "command_id": command.id} + ) + with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet"): + plan_log = self.server_test_1.sudo().run_flight_plan( + plan, jet=self.jet_test + ) + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + self.assertEqual(len(command_logs), 1) + self.assertEqual( + command_logs.command_status, + WAYPOINT_CREATE_FAILED, + "Should finish with WAYPOINT_CREATE_FAILED when template is " + "for another jet template", + ) + self.assertTrue(command_logs.finish_date) + + def test_create_waypoint_command_waypoint_reaches_error(self): + """Create plan fails: waypoint goes to error, callback finishes + command log with error.""" + from ..models.constants import WAYPOINT_CREATE_FAILED + + fail_command = self.Command.create( + { + "name": "Fail command", + "action": "python_code", + "code": "result = {'exit_code': 1, 'message': 'fail'}", + } + ) + fail_plan = self.Plan.create({"name": "Plan that fails"}) + self.plan_line.create( + { + "plan_id": fail_plan.id, + "sequence": 10, + "command_id": fail_command.id, + } + ) + waypoint_template_with_failing_plan = self.JetWaypointTemplate.create( + { + "name": "Waypoint template with failing create plan", + "jet_template_id": self.jet_template_test.id, + "plan_create_id": fail_plan.id, + } + ) + command = self.Command.create( + { + "name": "Create waypoint with failing plan", + "action": "create_waypoint", + "waypoint_template_id": waypoint_template_with_failing_plan.id, + "fly_here": False, + } + ) + plan = self.Plan.create({"name": "Plan create waypoint error"}) + self.plan_line.create( + {"plan_id": plan.id, "sequence": 10, "command_id": command.id} + ) + plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) + command_logs = plan_log.command_log_ids.filtered( + lambda log: log.command_id == command + ) + self.assertEqual(len(command_logs), 1) + log_record = command_logs[0] + self.assertTrue( + log_record.finish_date, + "Command log should be finished by waypoint callback when " + "waypoint reaches error", + ) + self.assertNotEqual( + log_record.command_status, + 0, + "Command should finish with error status", + ) + self.assertEqual( + log_record.command_status, + WAYPOINT_CREATE_FAILED, + "Callback should use WAYPOINT_CREATE_FAILED when plan fails", + ) + waypoints_with_log = self.jet_test.waypoint_ids.filtered( + lambda w: w.created_from_command_log_id == log_record + ) + self.assertEqual(len(waypoints_with_log), 1) + self.assertEqual( + waypoints_with_log.state, + "error", + "Waypoint should be in error state after create plan fails", + ) + + def test_finalize_create_waypoint_command_log_double_finish_guard(self): + """Calling _finalize_create_waypoint_command_log twice does not + double-finish.""" + waypoint = self.jet_test.create_waypoint( + self.waypoint_template, + created_from_command_log=None, + ) + log_record = self.CommandLog.create( + { + "server_id": self.server_test_1.id, + "command_id": self.Command.create( + { + "name": "Dummy create waypoint", + "action": "create_waypoint", + "waypoint_template_id": self.waypoint_template.id, + } + ).id, + "start_date": fields.Datetime.now(), + } + ) + waypoint.created_from_command_log_id = log_record + self.assertFalse(log_record.finish_date, "Log should not be finished yet") + waypoint._finalize_create_waypoint_command_log(success=True) + self.assertTrue(log_record.finish_date, "Log should be finished once") + finish_date_first = log_record.finish_date + waypoint._finalize_create_waypoint_command_log(success=True) + self.assertEqual( + log_record.finish_date, + finish_date_first, + "Second call should not change finish_date (guard)", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_access.py b/addons/cetmix_tower_server/tests/test_jet_access.py new file mode 100644 index 0000000..942f0ba --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_access.py @@ -0,0 +1,442 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetAccess(TestTowerJetsCommon): + """ + Test access rules for Jet model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional manager for multi-manager tests + cls.manager2 = cls.Users.create( + { + "name": "Test Manager 2", + "login": "test_manager_2", + "email": "test_manager_2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + # Create additional server for testing + cls.server_test_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "127.0.0.3", + "ssh_username": "test", + "ssh_password": "test", + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + } + ) + + # ====================== + # User Read Access Tests + # ====================== + + def test_user_read_access_jet_user_server_user(self): + """Test User: Read when user in jet user_ids AND server user_ids""" + jet = self._create_jet( + "User Jet", + "user_jet", + user_ids=[(4, self.user.id)], + server_user_ids=[(4, self.user.id)], + ) + + records = self.Jet.with_user(self.user).search([("id", "=", jet.id)]) + self.assertIn( + jet, + records, + "User should read when in jet user_ids AND server user_ids", + ) + + def test_user_read_no_access_jet_user_only(self): + """Test User: No read when user in jet user_ids but NOT in server user_ids""" + jet = self._create_jet( + "User Jet No Server", + "user_jet_no_server", + user_ids=[(4, self.user.id)], + server_user_ids=[(5, 0, 0)], + ) + + records = self.Jet.with_user(self.user).search([("id", "=", jet.id)]) + self.assertEqual( + len(records), + 0, + "User should not read when not in server user_ids", + ) + + def test_user_read_no_access_server_user_only(self): + """Test User: No read when user in server user_ids but NOT in jet user_ids""" + jet = self._create_jet( + "Server User No Jet", + "server_user_no_jet", + user_ids=[(5, 0, 0)], + server_user_ids=[(4, self.user.id)], + ) + + records = self.Jet.with_user(self.user).search([("id", "=", jet.id)]) + self.assertEqual( + len(records), + 0, + "User should not read when not in jet user_ids", + ) + + def test_user_write_forbidden(self): + """Test User: Cannot write/create/delete records""" + jet = self._create_jet( + "User Jet", + "user_jet", + user_ids=[(4, self.user.id)], + server_user_ids=[(4, self.user.id)], + ) + + # User should not be able to write + with self.assertRaises(AccessError): + jet.with_user(self.user).write({"name": "Updated Name"}) + + # User should not be able to create + with self.assertRaises(AccessError): + self.Jet.with_user(self.user).create( + { + "name": "New Jet", + "reference": "new_jet", + "jet_template_id": self.jet_template_test.id, + "server_id": self.server_test_1.id, + } + ) + + # User should not be able to delete + # Jet is deletable by default, so this tests access control + with self.assertRaises(AccessError): + jet.with_user(self.user).unlink() + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_access_jet_user_server_user(self): + """Test Manager: Read when in jet user_ids AND server user_ids""" + jet = self._create_jet( + "Manager Jet User", + "manager_jet_user", + user_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) + self.assertIn( + jet, + records, + "Manager should read when in jet user_ids AND server user_ids", + ) + + def test_manager_read_access_jet_manager_server_manager(self): + """Test Manager: Read when in jet manager_ids AND server manager_ids""" + jet = self._create_jet( + "Manager Jet Manager", + "manager_jet_manager", + manager_ids=[(4, self.manager.id)], + server_manager_ids=[(4, self.manager.id)], + ) + + records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) + self.assertIn( + jet, + records, + "Manager should read when in jet manager_ids AND server manager_ids", + ) + + def test_manager_read_access_jet_user_server_manager(self): + """Test Manager: Read when in jet user_ids AND server manager_ids""" + jet = self._create_jet( + "Manager Jet User Server Manager", + "manager_jet_user_server_manager", + user_ids=[(4, self.manager.id)], + server_manager_ids=[(4, self.manager.id)], + ) + + records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) + self.assertIn( + jet, + records, + "Manager should read when in jet user_ids AND server manager_ids", + ) + + def test_manager_read_access_jet_manager_server_user(self): + """Test Manager: Read when in jet manager_ids AND server user_ids""" + jet = self._create_jet( + "Manager Jet Manager Server User", + "manager_jet_manager_server_user", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) + self.assertIn( + jet, + records, + "Manager should read when in jet manager_ids AND server user_ids", + ) + + def test_manager_read_no_access_jet_only(self): + """Test Manager: No read when in jet but NOT in server""" + jet = self._create_jet( + "Manager Jet No Server", + "manager_jet_no_server", + user_ids=[(4, self.manager.id)], + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in server user_ids or manager_ids", + ) + + def test_manager_read_no_access_server_only(self): + """Test Manager: No read when in server but NOT in jet""" + jet = self._create_jet( + "Manager Server No Jet", + "manager_server_no_jet", + user_ids=[(5, 0, 0)], + manager_ids=[(5, 0, 0)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in jet user_ids or manager_ids", + ) + + # ====================== + # Manager Write/Create Access Tests + # ====================== + + def test_manager_write_access_jet_manager_server_user(self): + """Test Manager: Write when in jet manager_ids AND server user_ids""" + jet = self._create_jet( + "Manager Write Jet", + "manager_write_jet", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + try: + jet.with_user(self.manager).write({"name": "Updated Name"}) + jet.invalidate_recordset() + self.assertEqual( + jet.name, "Updated Name", "Manager should be able to update" + ) + except AccessError: + self.fail( + "Manager should be able to update when in jet" + " manager_ids AND server user_ids.", + ) + + def test_manager_write_access_jet_manager_server_manager(self): + """Test Manager: Write when in jet manager_ids AND server manager_ids""" + jet = self._create_jet( + "Manager Write Jet Manager", + "manager_write_jet_manager", + manager_ids=[(4, self.manager.id)], + server_manager_ids=[(4, self.manager.id)], + ) + + try: + jet.with_user(self.manager).write({"name": "Updated"}) + except AccessError: + self.fail( + "Manager should be able to write when in jet" + " manager_ids AND server manager_ids.", + ) + + def test_manager_write_forbidden_not_in_jet_manager_ids(self): + """Test Manager: No write when NOT in jet manager_ids""" + jet = self._create_jet( + "Manager No Write Jet", + "manager_no_write_jet", + user_ids=[(4, self.manager.id)], # Only in user_ids, not manager_ids + server_user_ids=[(4, self.manager.id)], + ) + + with self.assertRaises(AccessError): + jet.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_write_forbidden_not_in_server(self): + """Test Manager: No write when in jet manager_ids but NOT in server""" + jet = self._create_jet( + "Manager No Write Server", + "manager_no_write_server", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + with self.assertRaises(AccessError): + jet.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_create_access(self): + """ + Test Manager: + Create when in jet manager_ids AND server user_ids or manager_ids. + """ + # Create with manager in jet manager_ids and server user_ids - should succeed + try: + jet = self._create_jet( + "Create Success", + "create_success", + user_ids=[(5, 0, 0)], + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + ) + records = self.Jet.search([("id", "=", jet.id)]) + self.assertIn(jet, records, "Manager should be able to create") + except AccessError: + self.fail("Manager should be able to create when in jet manager_ids") + + def test_manager_create_forbidden_not_in_manager_ids(self): + """Test Manager: Cannot create when not in jet manager_ids""" + # Configure server access first (required, but jet manager_ids check will fail) + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + + with self.assertRaises(AccessError): + self.Jet.with_user(self.manager).create( + { + "name": "Create Fail", + "reference": "create_fail", + "jet_template_id": self.jet_template_test.id, + "server_id": self.server_test_1.id, + "user_ids": [ + (4, self.manager.id) + ], # Only user_ids, not manager_ids + "manager_ids": [(5, 0, 0)], + } + ) + + # ====================== + # Manager Delete Access Tests + # ====================== + + def test_manager_delete_own_record(self): + """Test Manager: Delete own record when in jet manager_ids AND server""" + # Create as manager to ensure create_uid is set correctly + jet = self._create_jet( + "My Jet", + "my_jet", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + ) + # Jet is deletable by default, so manager can delete it + try: + jet.with_user(self.manager).unlink() + records = self.Jet.search([("id", "=", jet.id)]) + self.assertEqual( + len(records), 0, "Manager should be able to delete own record" + ) + except AccessError: + self.fail("Manager should be able to delete own record") + + def test_manager_delete_not_creator(self): + """Test Manager: Cannot delete record created by another user""" + jet = self._create_jet( + "Other's Jet", + "others_jet", + manager_ids=[(4, self.manager.id), (4, self.manager2.id)], + server_user_ids=[(4, self.manager.id), (4, self.manager2.id)], + with_user=self.manager2, + ) + + # Manager1 cannot delete Manager2's record + # Jet is deletable by default, so this tests access control + with self.assertRaises(AccessError): + jet.with_user(self.manager).unlink() + + def test_manager_delete_not_in_manager_ids(self): + """Test Manager: Cannot delete when not in jet manager_ids""" + jet = self._create_jet( + "Removed Manager", + "removed_manager", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + ) + # Remove from manager_ids + jet.write({"manager_ids": [(5, 0, 0)]}) + + # Cannot delete anymore + # Jet is deletable by default, so this tests access control + with self.assertRaises(AccessError): + jet.with_user(self.manager).unlink() + + def test_manager_delete_not_in_server(self): + """Test Manager: Cannot delete when in jet manager_ids but NOT in server""" + jet = self._create_jet( + "Manager Jet", + "manager_jet", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + ) + # Remove server access + self.server_test_1.write({"user_ids": [(5, 0, 0)], "manager_ids": [(5, 0, 0)]}) + + # Cannot delete anymore + # Jet is deletable by default, so this tests access control + with self.assertRaises(AccessError): + jet.with_user(self.manager).unlink() + + # ====================== + # Root Access Tests + # ====================== + + def test_root_full_access(self): + """Test Root: Full CRUD access regardless of access restrictions""" + # Test Root can create + jet = self.Jet.create( + { + "name": "Root Jet", + "reference": "root_jet", + "jet_template_id": self.jet_template_test.id, + "server_id": self.server_test_1.id, + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + } + ) + + # Root can read + records = self.Jet.search([("id", "=", jet.id)]) + self.assertIn(jet, records, "Root should be able to read") + + # Root can write + jet.write({"name": "Root Updated Jet"}) + jet.invalidate_recordset() + self.assertEqual(jet.name, "Root Updated Jet", "Root should be able to update") + + # Test Root can delete records created by other users + manager_jet = self._create_jet( + "Manager's Jet", + "managers_jet", + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + ) + # Jet is deletable by default, so root can delete it + manager_jet.unlink() + records = self.Jet.search([("id", "=", manager_jet.id)]) + self.assertEqual( + len(records), 0, "Root should be able to delete records from any creator" + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_action_access.py b/addons/cetmix_tower_server/tests/test_jet_action_access.py new file mode 100644 index 0000000..87942c6 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_action_access.py @@ -0,0 +1,647 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetActionAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Action model (cx.tower.jet.action) + """ + + # ====================== + # User Read Access + # ====================== + + def test_user_read_access_level_user_and_template_user(self): + """ + User: can read when action access_level is User + (1) AND template access_level is User (1) + """ + template = self.JetTemplate.create( + { + "name": "User Level Template", + "reference": "user_level_template", + "access_level": "1", # User level + "user_ids": False, + "manager_ids": False, + } + ) + action = self.JetAction.create( + { + "name": "Action U", + "reference": "action_u", + "access_level": "1", # User level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) + self.assertEqual( + len(records), + 1, + "User should read when action and template access_level are User", + ) + + def test_user_read_when_in_template_users(self): + """ + User: can read when action access_level is User (1) + AND user is added to template Users + """ + template = self.JetTemplate.create( + { + "name": "Manager Level Template (user granted)", + "reference": "manager_level_template_user", + "access_level": "2", # Manager level + "user_ids": [(4, self.user.id)], + "manager_ids": False, + } + ) + action = self.JetAction.create( + { + "name": "Action TU", + "reference": "action_tu", + "access_level": "1", # User level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) + self.assertEqual( + len(records), + 1, + "User should read when action access_level is" + " User and user in template Users", + ) + + def test_user_read_when_in_jet_users(self): + """ + User: can read when action access_level is + User (1) AND user is added to Jet Users + """ + template = self.JetTemplate.create( + { + "name": "Manager Level Template", + "reference": "manager_level_template_jet", + "access_level": "2", # Manager level + "user_ids": False, + "manager_ids": False, + } + ) + # Add server to template's server_ids for jet creation + template.write({"server_ids": [(4, self.server_test_1.id)]}) + self._create_jet( + name="Test Jet from Template", + reference="test_jet_from_template", + template=template, + server=self.server_test_1, + user_ids=[(4, self.user.id)], # Add user to Jet's user_ids + server_user_ids=[(4, self.user.id)], # Also add to server for jet access + ) + action = self.JetAction.create( + { + "name": "Action JU", + "reference": "action_ju", + "access_level": "1", # User level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) + self.assertEqual( + len(records), + 1, + "User should read when action access_level is User and user in Jet Users", + ) + + def test_user_read_no_access_action_not_user_level(self): + """User: cannot read when action access_level is NOT User (1)""" + template = self.JetTemplate.create( + { + "name": "User Level Template", + "reference": "user_level_template_no_access", + "access_level": "1", # User level + "user_ids": False, + "manager_ids": False, + } + ) + action = self.JetAction.create( + { + "name": "Action M", + "reference": "action_m", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) + self.assertEqual( + len(records), + 0, + "User should not read when action access_level is not User", + ) + + def test_user_read_no_access_template_conditions_not_met(self): + """ + User: cannot read when action access_level is User (1) + and template conditions not met + """ + template = self.JetTemplate.create( + { + "name": "Manager Level Template", + "reference": "manager_level_template_no_access", + "access_level": "2", # Manager level + "user_ids": False, # User not in template Users + "manager_ids": False, + } + ) + # Don't create any jets with user in user_ids + action = self.JetAction.create( + { + "name": "Action NA", + "reference": "action_na", + "access_level": "1", # User level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) + self.assertEqual( + len(records), + 0, + "User should not read when action is User level" + " and template conditions not met", + ) + + def test_user_write_forbidden(self): + """User: cannot write/create/delete records""" + template = self.JetTemplate.create( + { + "name": "User Level Template", + "reference": "user_level_template_write", + "access_level": "1", + "user_ids": [(4, self.user.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action W", + "reference": "action_w_user", + "access_level": "1", + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + # Write forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.user).browse(action.id).write({"priority": 5}) + + # Create forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.user).create( + { + "name": "Action Created", + "reference": "action_created_user", + "access_level": "1", + "jet_template_id": template.id, + "state_from_id": self.state_stopped.id, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + } + ) + + # Delete forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.user).browse(action.id).unlink() + + # ====================== + # Manager Read Access + # ====================== + + def test_manager_read_access_level_manager_or_less(self): + """ + Manager: can read when action access_level <= Manager (2) + AND template access_level <= Manager (2) + """ + template = self.JetTemplate.create( + { + "name": "Manager Level Template", + "reference": "manager_level_template", + "access_level": "2", + } + ) + action = self.JetAction.create( + { + "name": "Action R", + "reference": "action_r", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.manager).search( + [("id", "=", action.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when action and template level <= Manager", + ) + + def test_manager_read_when_in_template_users(self): + """ + Manager: can read when action access_level <= Manager (2) + AND user is added to template Users + even if template access_level is Root (3) + """ + template = self.JetTemplate.create( + { + "name": "Root Level Template (user granted)", + "reference": "root_level_template_user", + "access_level": "3", + "user_ids": [(4, self.manager.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action RU", + "reference": "action_ru", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.manager).search( + [("id", "=", action.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when action level <= Manager and in template Users", + ) + + def test_manager_read_when_in_template_managers(self): + """ + Manager: can read when action access_level <= Manager (2) + AND user is added to template Managers + even if template access_level is Root (3) + """ + template = self.JetTemplate.create( + { + "name": "Root Level Template (manager)", + "reference": "root_level_template_manager", + "access_level": "3", + "manager_ids": [(4, self.manager.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action RM", + "reference": "action_rm", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.manager).search( + [("id", "=", action.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when action level <= Manager and in template Managers", + ) + + def test_manager_read_no_access_action_root_level(self): + """ + Manager: cannot read when action access_level is Root (3) + even if template conditions are met + """ + template = self.JetTemplate.create( + { + "name": "Manager Level Template", + "reference": "manager_level_template_no_access", + "access_level": "2", + "manager_ids": [(4, self.manager.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action Root", + "reference": "action_root", + "access_level": "3", # Root level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + records = self.JetAction.with_user(self.manager).search( + [("id", "=", action.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when action access_level is Root", + ) + + # ====================== + # Manager Write/Create/Delete + # ====================== + + def test_manager_write_when_in_template_managers(self): + """ + Manager: can write when action access_level <= Manager (2) + AND user is in template Managers + """ + template = self.JetTemplate.create( + { + "name": "Template For Write", + "reference": "template_for_write", + "manager_ids": [(4, self.manager.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action W", + "reference": "action_w", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + # Write + self.JetAction.with_user(self.manager).browse(action.id).write({"priority": 99}) + action.invalidate_recordset() + self.assertEqual( + action.priority, + 99, + "Manager should be able to write when action level" + " <= Manager and in Managers", + ) + + # Create + created = self.JetAction.with_user(self.manager).create( + { + "name": "Action W Created", + "reference": "action_w_created", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_stopped.id, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + } + ) + self.assertTrue( + created, + "Manager should be able to create when action level " + "<= Manager and in Managers", + ) + + # Delete + self.JetAction.with_user(self.manager).browse(created.id).unlink() + after = self.JetAction.search([("id", "=", created.id)]) + self.assertEqual( + len(after), + 0, + "Manager should be able to delete when action level " + "<= Manager and in Managers", + ) + + def test_manager_write_forbidden_when_not_in_template_managers(self): + """ + Manager: cannot write/create/delete if NOT in template Managers + even if action access_level <= Manager (2) + """ + template = self.JetTemplate.create( + { + "name": "Template No Write", + "reference": "template_no_write", + } + ) + action = self.JetAction.create( + { + "name": "Action NW", + "reference": "action_nw", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + # Write forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.manager).browse(action.id).write( + {"priority": 5} + ) + + # Create forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.manager).create( + { + "name": "Action NW Created", + "reference": "action_nw_created", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_stopped.id, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + } + ) + + # Delete forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.manager).browse(action.id).unlink() + + def test_manager_write_forbidden_when_action_root_level(self): + """ + Manager: cannot write/create/delete when action access_level is Root (3) + even if user is in template Managers + """ + template = self.JetTemplate.create( + { + "name": "Template For Write", + "reference": "template_for_write_root", + "manager_ids": [(4, self.manager.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action Root W", + "reference": "action_root_w", + "access_level": "3", # Root level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + # Write forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.manager).browse(action.id).write( + {"priority": 5} + ) + + # Create forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.manager).create( + { + "name": "Action Root Created", + "reference": "action_root_created", + "access_level": "3", # Root level + "jet_template_id": template.id, + "state_from_id": self.state_stopped.id, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + } + ) + + # Delete forbidden + with self.assertRaises(AccessError): + self.JetAction.with_user(self.manager).browse(action.id).unlink() + + def test_manager_write_on_root_level_template_when_in_managers(self): + """ + Manager: can write/create/delete on Root-level template + when action access_level <= Manager (2) AND user is in Managers + """ + template = self.JetTemplate.create( + { + "name": "Root Level Template For Write", + "reference": "root_level_template_for_write", + "access_level": "3", + "manager_ids": [(4, self.manager.id)], + } + ) + action = self.JetAction.create( + { + "name": "Action RW", + "reference": "action_rw", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, + "state_transit_id": self.state_stopping.id, + } + ) + + # Write + self.JetAction.with_user(self.manager).browse(action.id).write({"priority": 42}) + action.invalidate_recordset() + self.assertEqual( + action.priority, + 42, + "Manager should write on Root-level template when action level " + "<= Manager and in Managers", + ) + + # Create + created = self.JetAction.with_user(self.manager).create( + { + "name": "Action RW Created", + "reference": "action_rw_created", + "access_level": "2", # Manager level + "jet_template_id": template.id, + "state_from_id": self.state_stopped.id, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + } + ) + self.assertTrue( + created, + "Manager should create on Root-level template when action level " + "<= Manager and in Managers", + ) + + # Delete + self.JetAction.with_user(self.manager).browse(created.id).unlink() + after = self.JetAction.search([("id", "=", created.id)]) + self.assertEqual( + len(after), + 0, + "Manager should delete on Root-level template when action level " + "<= Manager and in Managers", + ) + + # ====================== + # Root Access + # ====================== + + def test_root_full_access(self): + """Root: full CRUD access for any record""" + template = self.JetTemplate.with_user(self.root).create( + { + "name": "Root Template", + "reference": "root_template", + "access_level": "3", + } + ) + + # Create + action = self.JetAction.with_user(self.root).create( + { + "name": "Root Action", + "reference": "root_action", + "jet_template_id": template.id, + "state_from_id": self.state_initial.id, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + } + ) + + # Read + records = self.JetAction.with_user(self.root).search([("id", "=", action.id)]) + self.assertEqual(len(records), 1, "Root should read any record") + + # Write + action.with_user(self.root).write({"priority": 7}) + action.invalidate_recordset() + self.assertEqual(action.priority, 7, "Root should update any record") + + # Delete + action.with_user(self.root).unlink() + self.assertEqual( + len( + self.JetAction.with_user(self.root).search( + [("reference", "=", "root_action")] + ) + ), + 0, + "Root should delete any record", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_create_wizard.py b/addons/cetmix_tower_server/tests/test_jet_create_wizard.py new file mode 100644 index 0000000..98646d8 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_create_wizard.py @@ -0,0 +1,81 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common_jets import TestTowerJetsCommon + + +class TestJetCreateWizard(TestTowerJetsCommon): + """Tests for `cx.tower.jet.create.wizard`""" + + def test_action_confirm_creates_jet(self): + """ + Ensure that the wizard creates a new jet using the selected template. + """ + wizard_model = self.env["cx.tower.jet.create.wizard"] + + wizard = wizard_model.create( + { + "name_type": "m", + "name": "Wizard Jet", + "jet_template_id": self.jet_template_test.id, + "server_id": self.server_test_1.id, + } + ) + + action = wizard.action_confirm() + + jet = self.Jet.browse(action["res_id"]) + self.assertTrue(jet.exists(), "Wizard action should return the created jet") + self.assertEqual(jet.name, "Wizard Jet") + self.assertEqual(jet.server_id, self.server_test_1) + self.assertEqual(jet.jet_template_id, self.jet_template_test) + + def test_action_confirm_sets_custom_variables(self): + """ + Ensure custom variable values from the wizard are stored on the created jet. + """ + wizard_model = self.env["cx.tower.jet.create.wizard"] + custom_variable = self.Variable.create( + { + "name": "Wizard Custom Variable", + } + ) + custom_value = "custom value" + + wizard = wizard_model.create( + { + "name_type": "m", + "name": "Wizard Jet With Variables", + "jet_template_id": self.jet_template_test.id, + "server_id": self.server_test_1.id, + "use_custom_variables": "y", + "line_ids": [ + ( + 0, + 0, + { + "variable_id": custom_variable.id, + "value_char": custom_value, + }, + ) + ], + } + ) + + action = wizard.action_confirm() + jet = self.Jet.browse(action["res_id"]) + custom_lines = jet.variable_value_ids.filtered( + lambda line: line.variable_id == custom_variable + ) + + self.assertEqual(len(custom_lines), 1, "Custom variable should be stored once") + self.assertEqual( + custom_lines.variable_id, + custom_variable, + "Custom variable record should be linked to the expected variable", + ) + self.assertEqual( + custom_lines.value_char, + custom_value, + "Created jet should keep custom variable values from the wizard", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_dependency_access.py b/addons/cetmix_tower_server/tests/test_jet_dependency_access.py new file mode 100644 index 0000000..43c5489 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_dependency_access.py @@ -0,0 +1,420 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetDependencyAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Dependency model + """ + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_access_both_user_ids(self): + """Test Manager: Read when in user_ids of both jets""" + _, _, dependency = self._create_jet_dependency( + "Jet 1", + "jet_1", + "Jet 2", + "jet_2", + jet_user_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in user_ids of both jets", + ) + self.assertIn( + dependency, + records, + "Manager should get exactly the dependency record we searched for", + ) + + def test_manager_read_access_both_manager_ids(self): + """Test Manager: Read when in manager_ids of both jets""" + _, _, dependency = self._create_jet_dependency( + "Jet Manager 1", + "jet_manager_1", + "Jet Manager 2", + "jet_manager_2", + jet_manager_ids=[(4, self.manager.id)], + depends_on_manager_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in manager_ids of both jets", + ) + self.assertIn( + dependency, + records, + "Manager should get exactly the dependency record we searched for", + ) + + def test_manager_read_access_jet_user_depends_manager(self): + """Test Manager: Read when in user_ids of jet and manager_ids of depends""" + _, _, dependency = self._create_jet_dependency( + "Jet User", + "jet_user", + "Depends Manager", + "depends_manager", + jet_user_ids=[(4, self.manager.id)], + depends_on_manager_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in user_ids of jet and manager_ids of depends", + ) + self.assertIn( + dependency, + records, + "Manager should get exactly the dependency record we searched for", + ) + + def test_manager_read_access_jet_manager_depends_user(self): + """Test Manager: Read when in manager_ids of jet and user_ids of depends""" + _, _, dependency = self._create_jet_dependency( + "Jet Manager", + "jet_manager", + "Depends User", + "depends_user", + jet_manager_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in manager_ids of jet and user_ids of depends", + ) + self.assertIn( + dependency, + records, + "Manager should get exactly the dependency record we searched for", + ) + + def test_manager_read_no_access_jet_only(self): + """Test Manager: No read when in jet but NOT in depends on jet""" + _, _, dependency = self._create_jet_dependency( + "Jet Has Access", + "jet_has_access", + "Depends No Access", + "depends_no_access", + jet_user_ids=[(4, self.manager.id)], + depends_on_user_ids=[(5, 0, 0)], + depends_on_manager_ids=[(5, 0, 0)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in depends_on" + " jet user_ids or manager_ids", + ) + + def test_manager_read_no_access_depends_only(self): + """Test Manager: No read when in depends on jet but NOT in jet""" + _, _, dependency = self._create_jet_dependency( + "Jet No Access", + "jet_no_access", + "Depends Has Access", + "depends_has_access", + jet_user_ids=[(5, 0, 0)], + jet_manager_ids=[(5, 0, 0)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in jet user_ids or manager_ids", + ) + + # ====================== + # Manager CRUD Access Tests + # ====================== + + def test_manager_write_access(self): + """ + Test Manager: + Write access when in manager_ids of jet AND user_ids + or manager_ids of depends. + """ + # Test with depends_on user_ids (same conditions as create test, + # but tests write access on existing record) + _, _, dependency1 = self._create_jet_dependency( + "Write Jet Manager", + "write_jet_manager", + "Depends User", + "depends_user", + jet_manager_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + # Verify manager can access the dependency (write permissions allow read access) + try: + dependency1.invalidate_recordset() + dependency1.with_user(self.manager).read(["jet_id", "jet_depends_on_id"]) + # Perform an actual write: switch to an alternative valid depends_on jet + depends_on_jet_alt = self._create_jet( + "Depends User Alt", + "depends_user_alt", + template=self.jet_template_tower_core, + user_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + dependency1.with_user(self.manager).write( + {"jet_depends_on_id": depends_on_jet_alt.id} + ) + except AccessError: + self.fail( + "Manager should be able to write when in jet manager_ids " + "AND depends_on user_ids" + ) + + # Test with depends_on manager_ids - use different templates + # to avoid duplicate template dependency + _, _, dependency2 = self._create_jet_dependency( + "Write Jet Manager 2", + "write_jet_manager_2", + "Depends Manager", + "depends_manager", + jet_manager_ids=[(4, self.manager.id)], + depends_on_manager_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + jet_template=self.jet_template_nginx, + # Use different template to avoid duplicate + depends_on_template=self.jet_template_docker, + ) + + try: + dependency2.invalidate_recordset() + dependency2.with_user(self.manager).read(["jet_id", "jet_depends_on_id"]) + # Perform an actual write: switch to an alternative valid depends_on jet + depends_on_jet_alt2 = self._create_jet( + "Depends Manager Alt", + "depends_manager_alt", + template=self.jet_template_docker, + manager_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + dependency2.with_user(self.manager).write( + {"jet_depends_on_id": depends_on_jet_alt2.id} + ) + except AccessError: + self.fail( + "Manager should be able to write when in jet manager_ids" + " AND depends_on manager_ids" + ) + + def test_manager_create_access(self): + """ + Test Manager: Create when in manager_ids of jet AND user_ids + or manager_ids of depends. + """ + # Try to create dependency as manager + # (helper ensures proper template dependency) + try: + _, _, dependency = self._create_jet_dependency( + "Create Jet", + "create_jet", + "Create Depends", + "create_depends", + jet_manager_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + jet_template=self.jet_template_test, + depends_on_template=self.jet_template_tower_core, + ) + records = self.JetDependency.search([("id", "=", dependency.id)]) + self.assertIn( + dependency, + records, + "Manager should be able to create dependency", + ) + except AccessError: + self.fail("Manager should be able to create when in jet manager_ids") + + def test_manager_create_forbidden_not_in_jet_manager_ids(self): + """Test Manager: Cannot create when not in jet manager_ids""" + # Should not be able to create (manager not in jet manager_ids) + self.assertRaises( + AccessError, + lambda: self._create_jet_dependency( + "No Create Jet", + "no_create_jet", + "No Create Depends", + "no_create_depends", + jet_user_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + jet_template=self.jet_template_test, + depends_on_template=self.jet_template_tower_core, + ), + ) + + def test_manager_create_forbidden_not_in_depends(self): + """ + Test Manager: Cannot create when in jet manager_ids but NOT in depends. + """ + # Should not be able to create (manager has no access to depends) + self.assertRaises( + AccessError, + lambda: self._create_jet_dependency( + "Create Jet", + "create_jet", + "No Depends Access", + "no_depends_access", + jet_manager_ids=[(4, self.manager.id)], + depends_on_user_ids=[(5, 0, 0)], + depends_on_manager_ids=[(5, 0, 0)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + jet_template=self.jet_template_test, + depends_on_template=self.jet_template_tower_core, + ), + ) + + def test_manager_unlink_access(self): + """ + Test Manager: Delete when in manager_ids of jet AND user_ids + or manager_ids of depends. + """ + _, _, dependency = self._create_jet_dependency( + "Delete Jet", + "delete_jet", + "Delete Depends", + "delete_depends", + jet_manager_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + with_user=self.manager, + ) + + # Refresh dependency in manager context to ensure access + dependency.invalidate_recordset() + dependency = dependency.with_user(self.manager) + + try: + dependency.unlink() + records = self.JetDependency.search([("id", "=", dependency.id)]) + self.assertEqual( + len(records), + 0, + "Manager should be able to delete dependency", + ) + except AccessError: + self.fail("Manager should be able to delete dependency") + + def test_manager_unlink_forbidden_not_in_jet_manager_ids(self): + """Test Manager: Cannot delete when not in jet manager_ids""" + _, _, dependency = self._create_jet_dependency( + "No Delete Jet", + "no_delete_jet", + "No Delete Depends", + "no_delete_depends", + jet_user_ids=[(4, self.manager.id)], + depends_on_user_ids=[(4, self.manager.id)], + jet_server_user_ids=[(4, self.manager.id)], + depends_on_server_user_ids=[(4, self.manager.id)], + ) + + self.assertRaises(AccessError, dependency.with_user(self.manager).unlink) + + # ====================== + # Root Access Tests + # ====================== + + def test_root_full_access(self): + """Test Root: Full CRUD access regardless of access restrictions""" + # Root can create dependency via helper regardless of access + _, _, dependency = self._create_jet_dependency( + "Root Jet", + "root_jet", + "Root Depends", + "root_depends", + jet_user_ids=[(5, 0, 0)], + jet_manager_ids=[(5, 0, 0)], + depends_on_user_ids=[(5, 0, 0)], + depends_on_manager_ids=[(5, 0, 0)], + with_user=self.root, + jet_template=self.jet_template_test, + depends_on_template=self.jet_template_tower_core, + ) + + # Root can read + records = self.JetDependency.with_user(self.root).search( + [("id", "=", dependency.id)] + ) + self.assertIn(dependency, records, "Root should be able to read") + + # Root can write: switch depends_on to another valid jet + depends_on_jet_alt = self._create_jet( + "Root Depends Alt", + "root_depends_alt", + template=self.jet_template_tower_core, + ) + dependency.invalidate_recordset() + dependency.with_user(self.root).write( + {"jet_depends_on_id": depends_on_jet_alt.id} + ) + + # Root can delete + dependency.with_user(self.root).unlink() + records = self.JetDependency.with_user(self.root).search( + [("id", "=", dependency.id)] + ) + self.assertEqual( + len(records), + 0, + "Root should be able to delete dependency", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_state.py b/addons/cetmix_tower_server/tests/test_jet_state.py new file mode 100644 index 0000000..bb3e1fc --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_state.py @@ -0,0 +1,522 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, ValidationError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetState(TestTowerJetsCommon): + """ + Test the Jet State model functionality + """ + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # set_state Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_set_state_success_user_level(self): + """ + Test set_state succeeds when user has sufficient access level. + User (level 1) can set state with level 1. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet and server + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to initial state + self.jet_test.state_id = self.state_initial + + # User should be able to set state + self.state_running.with_user(self.user).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Jet should be set to user-level state by user", + ) + + def test_set_state_success_manager_level(self): + """ + Test set_state succeeds when manager has sufficient access level. + Manager (level 2) can set state with level 2. + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet and server + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.state_id = self.state_running + + # Manager should be able to set state + self.state_stopped.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + self.assertEqual( + self.jet_test.state_id, + self.state_stopped, + "Jet should be set to manager-level state by manager", + ) + + def test_set_state_success_root_level(self): + """ + Test set_state succeeds when root has sufficient access level. + Root (level 3) can set state with level 3. + """ + # Use existing state and set it to Root access level (3) + self.state_error.access_level = "3" + self.state_error.invalidate_recordset(["access_level"]) + + # Set jet to running state (which has action to error) + self.jet_test.state_id = self.state_running + + # Root should be able to set state + self.state_error.with_user(self.root).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + self.assertEqual( + self.jet_test.state_id, + self.state_error, + "Jet should be set to root-level state by root", + ) + + def test_set_state_access_error_user_to_manager(self): + """ + Test set_state raises AccessError when user (level 1) + tries to set manager-level state (level 2). + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet and server (for the access check to work) + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.state_id = self.state_running + + # User should not be able to set manager-level state + with self.assertRaises(AccessError) as context: + self.state_stopped.with_user(self.user).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_stopped.name, + str(context.exception), + "Error message should include state name", + ) + + def test_set_state_access_error_user_to_root(self): + """ + Test set_state raises AccessError when user (level 1) + tries to set root-level state (level 3). + """ + # Use existing state and set it to Root access level (3) + self.state_error.access_level = "3" + self.state_error.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet and server (for the access check to work) + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to running state (which has action to error) + self.jet_test.state_id = self.state_running + + # User should not be able to set root-level state + with self.assertRaises(AccessError) as context: + self.state_error.with_user(self.user).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_error.name, + str(context.exception), + "Error message should include state name", + ) + + def test_set_state_access_error_manager_to_root(self): + """ + Test set_state raises AccessError when manager (level 2) + tries to set root-level state (level 3). + """ + # Use existing state and set it to Root access level (3) + self.state_error.access_level = "3" + self.state_error.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet and server (for the access check to work) + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Set jet to running state (which has action to error) + self.jet_test.state_id = self.state_running + + # Manager should not be able to set root-level state + with self.assertRaises(AccessError) as context: + self.state_error.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_error.name, + str(context.exception), + "Error message should include state name", + ) + + def test_set_state_manager_can_access_user_level(self): + """ + Test set_state succeeds when manager (level 2) who IS in manager_ids + accesses user-level state (level 1). + Higher access levels can access lower level states. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet and server + # Manager IS in manager_ids, so they keep their manager access level (2) + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + + # Set jet to initial state + self.jet_test.state_id = self.state_initial + + # Manager should be able to set user-level state + self.state_running.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Manager should be able to set user-level state", + ) + + def test_set_state_manager_not_in_manager_ids_treated_as_user(self): + """ + Test set_state treats manager (level 2) who is NOT in manager_ids + as user (level 1). + Manager should be able to set user-level state but not manager-level state. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet and server via user_ids + # but NOT via manager_ids + self.jet_test.write({"user_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + # Explicitly ensure manager is NOT in manager_ids + self.jet_test.write({"manager_ids": [(5, 0, 0)]}) + + # Set jet to initial state + self.jet_test.state_id = self.state_initial + + # Manager (treated as user) should be able to set user-level state + self.state_running.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Manager not in manager_ids should be able to set user-level state", + ) + + def test_set_state_manager_not_in_manager_ids_cannot_access_manager_level(self): + """ + Test set_state raises AccessError when manager (level 2) who is NOT + in manager_ids tries to set manager-level state (level 2). + Manager should be treated as user (level 1) and cannot access level 2. + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Ensure manager has access to the jet and server via user_ids + # but NOT via manager_ids + self.jet_test.write({"user_ids": [(4, self.manager.id)]}) + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + # Explicitly ensure manager is NOT in manager_ids + self.jet_test.write({"manager_ids": [(5, 0, 0)]}) + + # Set jet to running state (which has action to stopped) + self.jet_test.state_id = self.state_running + + # Manager (treated as user) should not be able to set manager-level state + with self.assertRaises(AccessError) as context: + self.state_stopped.with_user(self.manager).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + + self.assertIn( + "You are not allowed to set the", + str(context.exception), + "Should raise AccessError with appropriate message", + ) + self.assertIn( + self.state_stopped.name, + str(context.exception), + "Error message should include state name", + ) + + def test_set_state_root_can_access_manager_level(self): + """ + Test set_state succeeds when root (level 3) + accesses manager-level state (level 2). + Higher access levels can access lower level states. + """ + # Use existing state and set it to Manager access level (2) + self.state_stopped.access_level = "2" + self.state_stopped.invalidate_recordset(["access_level"]) + + # Set jet to running state (which has action to stopped) + self.jet_test.state_id = self.state_running + + # Root should be able to set manager-level state + self.state_stopped.with_user(self.root).with_context( + cetmix_tower_no_commit=True + ).set_state(self.jet_test) + self.assertEqual( + self.jet_test.state_id, + self.state_stopped, + "Root should be able to set manager-level state", + ) + + def test_set_state_with_context_jet_id(self): + """ + Test set_state retrieves jet from context when jet parameter is None. + """ + # Use existing state and set it to User access level (1) + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Ensure user has access to the jet and server + self.jet_test.write({"user_ids": [(4, self.user.id)]}) + self.server_test_1.write({"user_ids": [(4, self.user.id)]}) + + # Set jet to initial state + self.jet_test.state_id = self.state_initial + + # Set state using context instead of direct parameter + self.state_running.with_user(self.user).with_context( + jet_id=self.jet_test.id, + cetmix_tower_no_commit=True, + ).set_state() + self.assertEqual( + self.jet_test.state_id, + self.state_running, + "Jet should be set to state using context jet_id", + ) + + def test_set_state_no_jet_in_context_returns_silently(self): + """ + Test set_state returns silently when no jet_id in context + and jet parameter is None. + """ + # Use existing state + self.state_running.access_level = "1" + self.state_running.invalidate_recordset(["access_level"]) + + # Call set_state without jet parameter and without context + # Should return silently without raising exception + result = ( + self.state_running.with_user(self.user) + .with_context(cetmix_tower_no_commit=True) + .set_state() + ) + self.assertIsNone(result, "Should return None when no jet in context") + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # unlink Tests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def test_unlink_success_when_not_used_in_action(self): + """ + Test unlink succeeds when state is not used in any action. + """ + # Create a state that is not used in any action + unused_state = self.JetState.create( + { + "name": "Unused State", + "reference": "unused_state", + "sequence": 100, + } + ) + state_id = unused_state.id + + # Unlink should succeed + unused_state.unlink() + + # Verify state is deleted + self.assertFalse( + self.JetState.search([("id", "=", state_id)]), + "State should be deleted when not used in any action", + ) + + def test_unlink_fails_when_used_as_state_from(self): + """ + Test unlink raises ValidationError when state is used as state_from_id + in an action. + """ + # state_running is used as state_from_id in action_running_to_stopped + with self.assertRaises(ValidationError) as context: + self.state_running.unlink() + + error_message = str(context.exception) + self.assertIn( + "Some states are still used in the following actions", + error_message, + "Should raise ValidationError with appropriate message", + ) + self.assertIn( + self.action_running_to_stopped.name, + error_message, + "Error message should include action name", + ) + self.assertIn( + self.jet_template_test.name, + error_message, + "Error message should include template name", + ) + + def test_unlink_fails_when_used_as_state_to(self): + """ + Test unlink raises ValidationError when state is used as state_to_id + in an action. + """ + # state_stopped is used as state_to_id in action_running_to_stopped + with self.assertRaises(ValidationError) as context: + self.state_stopped.unlink() + + error_message = str(context.exception) + self.assertIn( + "Some states are still used in the following actions", + error_message, + "Should raise ValidationError with appropriate message", + ) + self.assertIn( + self.action_running_to_stopped.name, + error_message, + "Error message should include action name", + ) + self.assertIn( + self.jet_template_test.name, + error_message, + "Error message should include template name", + ) + + def test_unlink_fails_when_used_as_state_transit(self): + """ + Test unlink raises ValidationError when state is used as state_transit_id + in an action. + """ + # state_stopping is used as state_transit_id in action_running_to_stopped + with self.assertRaises(ValidationError) as context: + self.state_stopping.unlink() + + error_message = str(context.exception) + self.assertIn( + "Some states are still used in the following actions", + error_message, + "Should raise ValidationError with appropriate message", + ) + self.assertIn( + self.action_running_to_stopped.name, + error_message, + "Error message should include action name", + ) + self.assertIn( + self.jet_template_test.name, + error_message, + "Error message should include template name", + ) + + def test_unlink_fails_with_multiple_actions(self): + """ + Test unlink raises ValidationError with multiple actions when state + is used in multiple actions. + """ + # state_running is used in multiple actions: + # - action_running_to_stopped (state_from_id) + # - action_stopped_to_running (state_to_id) + # - action_running_to_error (state_from_id) + # - action_initial_to_running (state_to_id) + with self.assertRaises(ValidationError) as context: + self.state_running.unlink() + + error_message = str(context.exception) + self.assertIn( + "Some states are still used in the following actions", + error_message, + "Should raise ValidationError with appropriate message", + ) + # Verify multiple actions are mentioned + self.assertIn( + self.action_running_to_stopped.name, + error_message, + "Error message should include first action name", + ) + self.assertIn( + self.jet_template_test.name, + error_message, + "Error message should include template name", + ) + + def test_unlink_fails_with_multiple_states(self): + """ + Test unlink raises ValidationError when trying to unlink multiple states + where at least one is used in an action. + """ + # Create an unused state + unused_state = self.JetState.create( + { + "name": "Another Unused State", + "reference": "another_unused_state", + "sequence": 101, + } + ) + + # Try to unlink both unused_state and state_running (which is used) + states_to_unlink = unused_state | self.state_running + with self.assertRaises(ValidationError) as context: + states_to_unlink.unlink() + + error_message = str(context.exception) + self.assertIn( + "Some states are still used in the following actions", + error_message, + "Should raise ValidationError with appropriate message", + ) + # Verify that neither state was deleted + self.assertTrue( + unused_state.exists(), + "Unused state should not be deleted when another state fails", + ) + self.assertTrue( + self.state_running.exists(), + "Used state should not be deleted", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_template.py b/addons/cetmix_tower_server/tests/test_jet_template.py new file mode 100644 index 0000000..e82929d --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_template.py @@ -0,0 +1,3226 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetTemplate(TestTowerJetsCommon): + """ + Test the jet template model + """ + + # All jet-related test data is now inherited from TestTowerJetsCommon + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional servers for multi-server tests + cls.server_test_2 = cls.Server.create( + { + "name": "Test Server 2", + "reference": "test_server_2", + "ip_v4_address": "192.168.1.102", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + cls.server_test_3 = cls.Server.create( + { + "name": "Test Server 3", + "reference": "test_server_3", + "ip_v4_address": "192.168.1.103", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + + def test_compute_border_actions_no_actions(self): + """ + Test _compute_border_actions with no actions defined + """ + # Create a jet template with no actions + template = self.JetTemplate.create( + { + "name": "No Actions Template", + "reference": "no_actions_template", + "server_ids": [(4, self.server_test_1.id)], + } + ) + + # Both border actions should be False + self.assertFalse( + template.action_create_id, + "Create action should be False when no actions exist", + ) + self.assertFalse( + template.action_destroy_id, + "Destroy action should be False when no actions exist", + ) + + def test_compute_border_actions_both_valid_actions(self): + """ + Test _compute_border_actions with both valid create and destroy actions + """ + # Use common actions from class setup + create_action = self.action_create + destroy_action = self.action_destroy + + # Both actions should be set + self.assertEqual( + self.jet_template_test.action_create_id, + create_action, + "Create action should be set to the valid action", + ) + self.assertEqual( + self.jet_template_test.action_destroy_id, + destroy_action, + "Destroy action should be set to the valid action", + ) + + def test_compute_border_actions_invalid_create_action_with_initial_state(self): + """ + Test _compute_border_actions with invalid create action (has initial state) + """ + # Create an invalid create action (has state_from_id) + invalid_create_action = self.JetAction.create( + { + "name": "Invalid Create Action", + "reference": "invalid_create_action", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_initial.id, # Invalid for create + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Since action_create_id is readonly=False, we can set it directly + # but the compute method won't be triggered automatically + self.jet_template_test.action_create_id = invalid_create_action + + # The action should remain set because compute method wasn't triggered + self.assertEqual( + self.jet_template_test.action_create_id, + invalid_create_action, + "Create action should remain set when directly assigned (readonly=False)", + ) + + # Now trigger the compute method manually to test the logic + self.jet_template_test._compute_border_actions() + + # Create action should be cleared because it's invalid + self.assertFalse( + self.jet_template_test.action_create_id, + "Create action should be cleared when it has an initial state", + ) + + def test_compute_border_actions_invalid_create_action_no_final_state(self): + """ + Test _compute_border_actions with invalid create action (no final state) + """ + # Create an invalid create action (no state_to_id) + invalid_create_action = self.JetAction.create( + { + "name": "Invalid Create Action", + "reference": "invalid_create_action", + "jet_template_id": self.jet_template_test.id, + "state_from_id": False, + "state_to_id": False, # No final state - invalid for create + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Since action_create_id is readonly=False, we can set it directly + # but the compute method won't be triggered automatically + self.jet_template_test.action_create_id = invalid_create_action + + # The action should remain set because compute method wasn't triggered + self.assertEqual( + self.jet_template_test.action_create_id, + invalid_create_action, + "Create action should remain set when directly assigned (readonly=False)", + ) + + # Now trigger the compute method manually to test the logic + self.jet_template_test._compute_border_actions() + + # Create action should be cleared because it's invalid + self.assertFalse( + self.jet_template_test.action_create_id, + "Create action should be cleared when it has no final state", + ) + + def test_compute_border_actions_invalid_destroy_action_with_final_state(self): + """ + Test _compute_border_actions with invalid destroy action (has final state) + """ + # Create an invalid destroy action (has state_to_id) + invalid_destroy_action = self.JetAction.create( + { + "name": "Invalid Destroy Action", + "reference": "invalid_destroy_action", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_running.id, + "state_to_id": self.state_stopped.id, # Invalid for destroy + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Since action_destroy_id is readonly=False, we can set it directly + # but the compute method won't be triggered automatically + self.jet_template_test.action_destroy_id = invalid_destroy_action + + # The action should remain set because compute method wasn't triggered + self.assertEqual( + self.jet_template_test.action_destroy_id, + invalid_destroy_action, + "Destroy action should remain set when directly assigned (readonly=False)", + ) + + # Now trigger the compute method manually to test the logic + self.jet_template_test._compute_border_actions() + + # Destroy action should be cleared because it's invalid + self.assertFalse( + self.jet_template_test.action_destroy_id, + "Destroy action should be cleared when it has a final state", + ) + + def test_compute_border_actions_multiple_actions_priority(self): + """ + Test _compute_border_actions with multiple actions, checking priority order + """ + # Clear existing border actions to force recomputation + self.jet_template_test.action_create_id = False + self.jet_template_test.action_destroy_id = False + + # Create multiple create actions with different priorities + # Use priority 0 to ensure they have higher priority + # than common actions (priority 1) + self.JetAction.create( + { + "name": "Create Action 1", + "reference": "create_action_1", + "jet_template_id": self.jet_template_test.id, + "state_from_id": False, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + "priority": 2, # Higher priority number (lower priority) + } + ) + + create_action_2 = self.JetAction.create( + { + "name": "Create Action 2", + "reference": "create_action_2", + "jet_template_id": self.jet_template_test.id, + "state_from_id": False, + "state_to_id": self.state_running.id, + "state_transit_id": self.state_starting.id, + "priority": 0, # Lower priority number (higher priority) + } + ) + + # Create multiple destroy actions with different priorities + self.JetAction.create( + { + "name": "Destroy Action 1", + "reference": "destroy_action_1", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_running.id, + "state_to_id": False, + "state_transit_id": self.state_stopping.id, + "priority": 2, # Higher priority number (lower priority) + } + ) + + destroy_action_2 = self.JetAction.create( + { + "name": "Destroy Action 2", + "reference": "destroy_action_2", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_running.id, + "state_to_id": False, + "state_transit_id": self.state_stopping.id, + "priority": 0, # Lower priority number (higher priority) + } + ) + + # Trigger recomputation of border actions to ensure + # the new actions are considered + self.jet_template_test._compute_border_actions() + + # Should select the actions with higher priority (lower priority number) + self.assertEqual( + self.jet_template_test.action_create_id, + create_action_2, + "Create action should be the one with higher priority", + ) + self.assertEqual( + self.jet_template_test.action_destroy_id, + destroy_action_2, + "Destroy action should be the one with higher priority", + ) + + def test_compute_border_actions_action_updates(self): + """ + Test _compute_border_actions when actions are updated + """ + # Use common actions from class setup + create_action = self.action_create + destroy_action = self.action_destroy + + # Both actions should be set initially + self.assertEqual(self.jet_template_test.action_create_id, create_action) + self.assertEqual(self.jet_template_test.action_destroy_id, destroy_action) + + # Update create action to make it invalid (add initial state) + create_action.write({"state_from_id": self.state_initial.id}) + + # Create action should be cleared, destroy action should remain + self.assertFalse( + self.jet_template_test.action_create_id, + "Create action should be cleared after becoming invalid", + ) + self.assertEqual( + self.jet_template_test.action_destroy_id, + destroy_action, + "Destroy action should remain unchanged", + ) + + # Update destroy action to make it invalid (add final state) + destroy_action.write({"state_to_id": self.state_stopped.id}) + + # Both actions should be cleared + self.assertFalse( + self.jet_template_test.action_create_id, + "Create action should remain cleared", + ) + self.assertFalse( + self.jet_template_test.action_destroy_id, + "Destroy action should be cleared after becoming invalid", + ) + + def test_find_action_path_bfs_multiple_paths_shortest(self): + """ + Test _find_action_path_bfs finds the shortest path when multiple paths exist + """ + # Create actions for multiple paths + # Short path: A -> C + action_ac = self.JetAction.create( + { + "name": "Action A to C (short)", + "reference": "action_ac", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + # Long path: A -> B -> D -> C + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_bd = self.JetAction.create( + { + "name": "Action B to D", + "reference": "action_bd", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_d.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + action_dc = self.JetAction.create( + { + "name": "Action D to C", + "reference": "action_dc", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_d.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Create adjacency with multiple paths + adjacency = { + self.state_a: [ + (self.state_c, action_ac), + (self.state_b, action_ab), + ], # Short and long path + self.state_b: [(self.state_d, action_bd)], + self.state_d: [(self.state_c, action_dc)], + } + + # Test that shortest path is found + result = self.jet_template_test._find_action_path_bfs( + self.state_a, self.state_c, adjacency + ) + expected_path = [action_ac] # Shortest path + self.assertEqual( + result, + expected_path, + "Should return shortest path when multiple paths exist", + ) + + def test_find_action_path_bfs_empty_adjacency(self): + """ + Test _find_action_path_bfs with empty adjacency list + """ + # Empty adjacency + adjacency = {} + + # Test with empty adjacency + result = self.jet_template_test._find_action_path_bfs( + self.state_a, self.state_b, adjacency + ) + self.assertIsNone(result, "Should return None with empty adjacency") + + def test_find_action_path_bfs_cyclic_graph(self): + """ + Test _find_action_path_bfs with cyclic graph + """ + # Create actions for cyclic graph + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_bc = self.JetAction.create( + { + "name": "Action B to C", + "reference": "action_bc", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + action_ca = self.JetAction.create( + { + "name": "Action C to A", + "reference": "action_ca", + "jet_template_id": self.jet_template_test.id, + "state_from_id": self.state_c.id, + "state_to_id": self.state_a.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Create cyclic adjacency: A -> B -> C -> A + adjacency = { + self.state_a: [(self.state_b, action_ab)], + self.state_b: [(self.state_c, action_bc)], + self.state_c: [(self.state_a, action_ca)], + } + + # Test path from A to C (should find path despite cycle) + result = self.jet_template_test._find_action_path_bfs( + self.state_a, self.state_c, adjacency + ) + expected_path = [action_ab, action_bc] + self.assertEqual(result, expected_path, "Should find path in cyclic graph") + + def test_find_action_path_bfs_disconnected_states(self): + """ + Test _find_action_path_bfs with disconnected states + """ + # Create adjacency with disconnected components + adjacency = { + self.state_a: [(self.state_b, "action_ab")], # A and B connected + # state_c is isolated + } + + # Test path from A to C (disconnected) + result = self.jet_template_test._find_action_path_bfs( + self.state_a, self.state_c, adjacency + ) + self.assertIsNone(result, "Should return None for disconnected states") + + def test_find_action_path_bfs_with_get_action_adjacency(self): + """ + Test _find_action_path_bfs using the actual _get_action_adjacency method + """ + # Create actions that will be used by _get_action_adjacency + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_bc = self.JetAction.create( + { + "name": "Action B to C", + "reference": "action_bc", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Get adjacency using the actual method + adjacency = self.clean_template._get_action_adjacency() + + # Test path from A to C + result = self.clean_template._find_action_path_bfs( + self.state_a, self.state_c, adjacency + ) + expected_path = [action_ab, action_bc] + self.assertEqual( + result, expected_path, "Should work with _get_action_adjacency method" + ) + + def test_get_action_adjacency_no_actions(self): + """ + Test _get_action_adjacency with no actions + """ + # Create a template with no actions + template = self.JetTemplate.create( + { + "name": "No Actions Template", + "reference": "no_actions_template", + "server_ids": [(4, self.server_test_1.id)], + } + ) + + # Get adjacency + adjacency = template._get_action_adjacency() + + # Should return empty dict + self.assertEqual( + adjacency, {}, "Should return empty dict when no actions exist" + ) + + def test_get_action_adjacency_single_action(self): + """ + Test _get_action_adjacency with a single valid action + """ + # Create action + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Should have one entry + self.assertIn(self.state_a, adjacency, "Should include state_a in adjacency") + self.assertEqual( + len(adjacency[self.state_a]), 1, "Should have one transition from state_a" + ) + self.assertEqual( + adjacency[self.state_a][0], + (self.state_b, action_ab), + "Should map to state_b with action_ab", + ) + + def test_get_action_adjacency_multiple_actions_from_same_state(self): + """ + Test _get_action_adjacency with multiple actions from the same state + """ + # Create multiple actions from state_a + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_ac = self.JetAction.create( + { + "name": "Action A to C", + "reference": "action_ac", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 20, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Should have multiple transitions from state_a + self.assertIn(self.state_a, adjacency, "Should include state_a in adjacency") + self.assertEqual( + len(adjacency[self.state_a]), 2, "Should have two transitions from state_a" + ) + + # Check that both transitions are present + transitions = adjacency[self.state_a] + expected_transitions = [(self.state_b, action_ab), (self.state_c, action_ac)] + for expected in expected_transitions: + self.assertIn( + expected, transitions, f"Should include transition {expected}" + ) + + def test_get_action_adjacency_actions_without_from_state(self): + """ + Test _get_action_adjacency with actions that have no state_from_id + """ + # Create action without state_from_id (create action) + self.JetAction.create( + { + "name": "Create Action", + "reference": "create_action", + "jet_template_id": self.clean_template.id, + "state_from_id": False, # No initial state + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Should be empty because action has no state_from_id + self.assertEqual( + adjacency, {}, "Should return empty dict for actions without state_from_id" + ) + + def test_get_action_adjacency_actions_without_to_state(self): + """ + Test _get_action_adjacency with actions that have no state_to_id + """ + # Create action without state_to_id (destroy action) + self.JetAction.create( + { + "name": "Destroy Action", + "reference": "destroy_action", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": False, # No final state + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Should be empty because action has no state_to_id + self.assertEqual( + adjacency, {}, "Should return empty dict for actions without state_to_id" + ) + + def test_get_action_adjacency_complex_graph(self): + """ + Test _get_action_adjacency with a complex graph structure + """ + # Create complex action graph + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_ac = self.JetAction.create( + { + "name": "Action A to C", + "reference": "action_ac", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 20, + } + ) + action_bd = self.JetAction.create( + { + "name": "Action B to D", + "reference": "action_bd", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_d.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + action_cd = self.JetAction.create( + { + "name": "Action C to D", + "reference": "action_cd", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_c.id, + "state_to_id": self.state_d.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Check structure + self.assertIn(self.state_a, adjacency, "Should include state_a") + self.assertIn(self.state_b, adjacency, "Should include state_b") + self.assertIn(self.state_c, adjacency, "Should include state_c") + self.assertNotIn( + self.state_d, adjacency, "Should not include state_d (no outgoing edges)" + ) + + # Check transitions from state_a + self.assertEqual( + len(adjacency[self.state_a]), + 2, + "State A should have 2 outgoing transitions", + ) + expected_from_a = [(self.state_b, action_ab), (self.state_c, action_ac)] + for expected in expected_from_a: + self.assertIn( + expected, + adjacency[self.state_a], + f"State A should have transition {expected}", + ) + + # Check transitions from state_b + self.assertEqual( + len(adjacency[self.state_b]), 1, "State B should have 1 outgoing transition" + ) + self.assertEqual( + adjacency[self.state_b][0], + (self.state_d, action_bd), + "State B should transition to D", + ) + + # Check transitions from state_c + self.assertEqual( + len(adjacency[self.state_c]), 1, "State C should have 1 outgoing transition" + ) + self.assertEqual( + adjacency[self.state_c][0], + (self.state_d, action_cd), + "State C should transition to D", + ) + + def test_get_action_adjacency_mixed_valid_invalid_actions(self): + """ + Test _get_action_adjacency with mix of valid and invalid actions + """ + # Create valid action + valid_action = self.JetAction.create( + { + "name": "Valid Action", + "reference": "valid_action", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Create invalid actions (should be ignored) + self.JetAction.create( + { + "name": "Invalid Action 1", + "reference": "invalid_action_1", + "jet_template_id": self.clean_template.id, + "state_from_id": False, # No initial state + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + self.JetAction.create( + { + "name": "Invalid Action 2", + "reference": "invalid_action_2", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": False, # No final state + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Should only include the valid action + self.assertIn(self.state_a, adjacency, "Should include state_a") + self.assertEqual( + len(adjacency[self.state_a]), 1, "Should have only one valid transition" + ) + self.assertEqual( + adjacency[self.state_a][0], + (self.state_b, valid_action), + "Should include only valid action", + ) + + def test_get_action_adjacency_self_loop(self): + """ + Test _get_action_adjacency with self-loop actions + """ + # Create self-loop action + self_loop_action = self.JetAction.create( + { + "name": "Self Loop Action", + "reference": "self_loop_action", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_a.id, # Same state + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Get adjacency + adjacency = self.clean_template._get_action_adjacency() + + # Should include self-loop + self.assertIn(self.state_a, adjacency, "Should include state_a") + self.assertEqual( + len(adjacency[self.state_a]), 1, "Should have one self-loop transition" + ) + self.assertEqual( + adjacency[self.state_a][0], + (self.state_a, self_loop_action), + "Should include self-loop action", + ) + + def test_get_action_path_no_create_destroy_actions(self): + """ + Test _get_action_path when no create or destroy actions are set + """ + # Create a template with no border actions + template = self.JetTemplate.create( + { + "name": "No Border Actions Template", + "reference": "no_border_actions_template", + "server_ids": [(4, self.server_test_1.id)], + } + ) + + # Test path without state_from and state_to + result = template._get_action_path() + self.assertEqual( + result, [], "Should return empty list when no create action exists" + ) + + # Test path with state_from but no state_to + result = template._get_action_path(state_from=self.state_a) + self.assertEqual( + result, [], "Should return empty list when no destroy action exists" + ) + + def test_get_action_path_both_parameters_provided(self): + """ + Test _get_action_path when both state_from and state_to are provided + """ + # Create action + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Test path with both parameters provided + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=self.state_b + ) + self.assertEqual( + result, + [action_ab], + "Should return action path when both parameters provided", + ) + + def test_get_action_path_requires_at_least_one_parameter(self): + """ + Test _get_action_path behavior when no parameters are provided + """ + # Create a template with no border actions + template = self.JetTemplate.create( + { + "name": "No Border Actions Template", + "reference": "no_border_actions_template", + "server_ids": [(4, self.server_test_1.id)], + } + ) + + # Test with no parameters - should return empty list + result = template._get_action_path() + self.assertEqual( + result, + [], + "Should return empty list when no parameters and no border actions", + ) + + # Test with only state_from + result = template._get_action_path(state_from=self.state_a) + self.assertEqual( + result, + [], + "Should return empty list when only state_from provided", + ) + + # Test with only state_to + result = template._get_action_path(state_to=self.state_b) + self.assertEqual( + result, + [], + "Should return empty list when only state_to provided and no create action", + ) + + def test_get_action_path_with_create_action_only(self): + """ + Test _get_action_path with only create action set + """ + # Create create action + create_action = self.JetAction.create( + { + "name": "Create Action", + "reference": "create_action", + "jet_template_id": self.clean_template.id, + "state_from_id": False, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Set create action + self.clean_template.action_create_id = create_action + + # Test path without state_from (should return empty because no destroy action) + result = self.clean_template._get_action_path() + self.assertEqual( + result, [], "Should return empty list when no destroy action provided" + ) + + # Test path with state_from (should not use create action) + result = self.clean_template._get_action_path(state_from=self.state_b) + self.assertEqual( + result, + [], + "Should return empty list when state_from provided and no path exists", + ) + + def test_build_dependency_graph_simple_dependency(self): + """Test _build_dependency_graph with simple dependency chain""" + # Use the existing dependency hierarchy + + graph = self.jet_template_odoo._build_dependency_graph() + + # Verify all templates are in the graph + expected_template_ids = [ + self.jet_template_odoo.id, + self.jet_template_postgres.id, + self.jet_template_nginx.id, + self.jet_template_docker.id, + self.jet_template_tower_core.id, + ] + self.assertEqual( + set(graph.keys()), + set(expected_template_ids), + "All templates should be in the graph", + ) + + # Verify Odoo template info + odoo_info = graph[self.jet_template_odoo.id] + self.assertEqual(odoo_info["template"], self.jet_template_odoo) + self.assertEqual(odoo_info["name"], "Odoo") + self.assertEqual(odoo_info["reference"], "odoo") + self.assertEqual(odoo_info["level"], 0) # Root template + self.assertEqual( + len(odoo_info["dependencies"]), 2 + ) # Depends on Postgres and Nginx + + # Verify Odoo dependencies + odoo_dep_ids = [dep["template_id"] for dep in odoo_info["dependencies"]] + self.assertIn(self.jet_template_postgres.id, odoo_dep_ids) + self.assertIn(self.jet_template_nginx.id, odoo_dep_ids) + + # Verify Postgres template info + postgres_info = graph[self.jet_template_postgres.id] + self.assertEqual(postgres_info["template"], self.jet_template_postgres) + self.assertEqual(postgres_info["name"], "Postgres") + self.assertEqual(postgres_info["reference"], "postgres") + self.assertEqual(postgres_info["level"], 1) # One level from root + self.assertEqual(len(postgres_info["dependencies"]), 1) # Depends on Docker + + # Verify Postgres dependencies + postgres_dep_ids = [dep["template_id"] for dep in postgres_info["dependencies"]] + self.assertIn(self.jet_template_docker.id, postgres_dep_ids) + + # Verify Nginx template info + nginx_info = graph[self.jet_template_nginx.id] + self.assertEqual(nginx_info["template"], self.jet_template_nginx) + self.assertEqual(nginx_info["name"], "Nginx") + self.assertEqual(nginx_info["reference"], "nginx") + self.assertEqual(nginx_info["level"], 1) # One level from root + self.assertEqual(len(nginx_info["dependencies"]), 1) # Depends on Docker + + # Verify Nginx dependencies + nginx_dep_ids = [dep["template_id"] for dep in nginx_info["dependencies"]] + self.assertIn(self.jet_template_docker.id, nginx_dep_ids) + + # Verify Docker template info + docker_info = graph[self.jet_template_docker.id] + self.assertEqual(docker_info["template"], self.jet_template_docker) + self.assertEqual(docker_info["name"], "Docker") + self.assertEqual(docker_info["reference"], "docker") + self.assertEqual(docker_info["level"], 2) # Two levels from root + self.assertEqual(len(docker_info["dependencies"]), 1) # Depends on Tower Core + + # Verify Docker dependencies + docker_dep_ids = [dep["template_id"] for dep in docker_info["dependencies"]] + self.assertIn(self.jet_template_tower_core.id, docker_dep_ids) + + # Verify Tower Core template info + tower_core_info = graph[self.jet_template_tower_core.id] + self.assertEqual(tower_core_info["template"], self.jet_template_tower_core) + self.assertEqual(tower_core_info["name"], "Tower Core") + self.assertEqual(tower_core_info["reference"], "tower_core") + self.assertEqual(tower_core_info["level"], 3) # Three levels from root + self.assertEqual(len(tower_core_info["dependencies"]), 0) # No dependencies + + def test_build_dependency_graph_circular_dependency(self): + """ + Test circular dependency detection in constraint validation. + + This test verifies that circular dependency detection correctly includes + the new dependency being created, not just existing ones from the database. + + Scenario: + A->B, B->C exist, trying to create C->A should be detected as circular. + """ + + # Create a circular dependency: A -> B -> C -> A + template_a = self.JetTemplate.create( + { + "name": "Template A", + "reference": "template_a", + } + ) + template_b = self.JetTemplate.create( + { + "name": "Template B", + "reference": "template_b", + } + ) + template_c = self.JetTemplate.create( + { + "name": "Template C", + "reference": "template_c", + } + ) + + # Create first two dependencies (A -> B -> C) + self.JetTemplateDependency.create( + { + "template_id": template_a.id, + "template_required_id": template_b.id, + "state_required_id": self.state_running.id, + } + ) + self.JetTemplateDependency.create( + { + "template_id": template_b.id, + "template_required_id": template_c.id, + "state_required_id": self.state_running.id, + } + ) + + # The third dependency (C -> A) should raise a ValidationError + with self.assertRaises(ValidationError) as context: + self.JetTemplateDependency.create( + { + "template_id": template_c.id, + "template_required_id": template_a.id, + "state_required_id": self.state_running.id, + } + ) + + # Verify the error message mentions circular reference + error_message = str(context.exception) + self.assertIn("circular reference", error_message.lower()) + self.assertIn("Template C", error_message) + + def test_build_dependency_graph_with_state_requirements(self): + """Test _build_dependency_graph with state requirements""" + # pylint: disable=protected-access + # Create a template with state requirements + template_with_state = self.JetTemplate.create( + { + "name": "Template With State", + "reference": "template_with_state", + } + ) + + # Create dependency with state requirement + self.JetTemplateDependency.create( + { + "template_id": template_with_state.id, + "template_required_id": self.jet_template_tower_core.id, + "state_required_id": self.state_running.id, + } + ) + + graph = template_with_state._build_dependency_graph() + + # Verify the dependency includes state information + template_info = graph[template_with_state.id] + self.assertEqual(len(template_info["dependencies"]), 1) + + dep_info = template_info["dependencies"][0] + self.assertEqual(dep_info["template_id"], self.jet_template_tower_core.id) + self.assertEqual(dep_info["template_name"], "Tower Core") + self.assertEqual(dep_info["template_reference"], "tower_core") + self.assertEqual(dep_info["required_state_id"], self.state_running.id) + self.assertEqual(dep_info["required_state_name"], "Test Running") + + def test_build_dependency_graph_complex_hierarchy(self): + """Test _build_dependency_graph with complex dependency hierarchy""" + # pylint: disable=protected-access + # Create a more complex hierarchy: E -> D, C -> B -> A + template_a = self.JetTemplate.create( + { + "name": "Template A", + "reference": "template_a", + } + ) + template_b = self.JetTemplate.create( + { + "name": "Template B", + "reference": "template_b", + } + ) + template_c = self.JetTemplate.create( + { + "name": "Template C", + "reference": "template_c", + } + ) + template_d = self.JetTemplate.create( + { + "name": "Template D", + "reference": "template_d", + } + ) + template_e = self.JetTemplate.create( + { + "name": "Template E", + "reference": "template_e", + } + ) + + # Create dependencies: E -> D, A -> B -> C + self.JetTemplateDependency.create( + { + "template_id": template_e.id, + "template_required_id": template_d.id, + "state_required_id": self.state_running.id, + } + ) + self.JetTemplateDependency.create( + { + "template_id": template_a.id, + "template_required_id": template_b.id, + "state_required_id": self.state_running.id, + } + ) + self.JetTemplateDependency.create( + { + "template_id": template_b.id, + "template_required_id": template_c.id, + "state_required_id": self.state_running.id, + } + ) + + # Test from template E + graph = template_e._build_dependency_graph() + + # Should contain E and D + expected_template_ids = [template_e.id, template_d.id] + self.assertEqual( + set(graph.keys()), + set(expected_template_ids), + "Should contain E and its dependencies", + ) + + # Verify levels + self.assertEqual(graph[template_e.id]["level"], 0) # Root + self.assertEqual(graph[template_d.id]["level"], 1) # One level down + + # Test from template C + graph = template_c._build_dependency_graph() + + # Should contain only C (C has no dependencies) + expected_template_ids = [template_c.id] + self.assertEqual( + set(graph.keys()), set(expected_template_ids), "Should contain only C" + ) + + # Verify levels + self.assertEqual(graph[template_c.id]["level"], 0) # Root + self.assertEqual( + len(graph[template_c.id]["dependencies"]), 0 + ) # No dependencies + + # Test from template A - should include A, B, and C + # because A depends on B, and B depends on C + graph = template_a._build_dependency_graph() + + # Should contain A, B, and C (A needs B, B needs C) + expected_template_ids = [template_a.id, template_b.id, template_c.id] + + # Check that all expected templates are in the graph + for expected_id in expected_template_ids: + self.assertIn( + expected_id, graph, f"Template {expected_id} should be in the graph" + ) + + # Check that the graph contains at least the expected templates + # (it might contain more due to other templates in the test database) + self.assertTrue( + all(template_id in graph for template_id in expected_template_ids), + f"Graph should contain at least {expected_template_ids}", + ) + + # Verify levels for the expected templates + self.assertEqual(graph[template_a.id]["level"], 0) # Root + self.assertEqual(graph[template_b.id]["level"], 1) # One level down + self.assertEqual(graph[template_c.id]["level"], 2) # Two levels down + + def test_build_dependency_graph_self_dependency(self): + """Test _build_dependency_graph with self-dependency""" + + # Create a template that depends on itself + template_self = self.JetTemplate.create( + { + "name": "Self Dependent Template", + "reference": "self_dependent_template", + } + ) + + # Creating self-dependency should raise a ValidationError + with self.assertRaises(ValidationError) as context: + self.JetTemplateDependency.create( + { + "template_id": template_self.id, + "template_required_id": template_self.id, + "state_required_id": self.state_running.id, + } + ) + + # Verify the error message mentions self-dependency + error_message = str(context.exception) + self.assertIn("cannot depend on itself", error_message.lower()) + + def test_calculate_dependency_levels_simple_chain(self): + """Test _calculate_dependency_levels with simple dependency chain""" + # pylint: disable=protected-access + # Use existing dependency chain: Odoo -> Postgres -> Docker -> Tower Core + + # Build the graph manually to test _calculate_dependency_levels + graph = { + self.jet_template_odoo.id: { + "template": self.jet_template_odoo, + "name": self.jet_template_odoo.name, + "reference": self.jet_template_odoo.reference, + "dependencies": [ + {"template_id": self.jet_template_postgres.id}, + {"template_id": self.jet_template_nginx.id}, + ], + "level": 0, # Will be calculated + }, + self.jet_template_postgres.id: { + "template": self.jet_template_postgres, + "name": self.jet_template_postgres.name, + "reference": self.jet_template_postgres.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, # Will be calculated + }, + self.jet_template_docker.id: { + "template": self.jet_template_docker, + "name": self.jet_template_docker.name, + "reference": self.jet_template_docker.reference, + "dependencies": [{"template_id": self.jet_template_tower_core.id}], + "level": 0, # Will be calculated + }, + self.jet_template_tower_core.id: { + "template": self.jet_template_tower_core, + "name": self.jet_template_tower_core.name, + "reference": self.jet_template_tower_core.reference, + "dependencies": [], + "level": 0, # Will be calculated + }, + } + + # Call _calculate_dependency_levels + self.jet_template_odoo._calculate_dependency_levels(graph) + + # Verify levels + self.assertEqual( + graph[self.jet_template_odoo.id]["level"], + 0, + "Odoo should be level 0 (root)", + ) + self.assertEqual( + graph[self.jet_template_postgres.id]["level"], + 1, + "Postgres should be level 1", + ) + self.assertEqual( + graph[self.jet_template_docker.id]["level"], 2, "Docker should be level 2" + ) + self.assertEqual( + graph[self.jet_template_tower_core.id]["level"], + 3, + "Tower Core should be level 3", + ) + + def test_calculate_dependency_levels_branching_dependencies(self): + """Test _calculate_dependency_levels with branching dependencies""" + # Use existing WordPress template with branching dependencies: + # WordPress -> MariaDB/Nginx -> Docker + + # Build the graph manually + graph = { + self.jet_template_wordpress.id: { + "template": self.jet_template_wordpress, + "name": self.jet_template_wordpress.name, + "reference": self.jet_template_wordpress.reference, + "dependencies": [ + {"template_id": self.jet_template_mariadb.id}, + {"template_id": self.jet_template_nginx.id}, + ], + "level": 0, + }, + self.jet_template_mariadb.id: { + "template": self.jet_template_mariadb, + "name": self.jet_template_mariadb.name, + "reference": self.jet_template_mariadb.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, + }, + self.jet_template_nginx.id: { + "template": self.jet_template_nginx, + "name": self.jet_template_nginx.name, + "reference": self.jet_template_nginx.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, + }, + self.jet_template_docker.id: { + "template": self.jet_template_docker, + "name": self.jet_template_docker.name, + "reference": self.jet_template_docker.reference, + "dependencies": [{"template_id": self.jet_template_tower_core.id}], + "level": 0, + }, + self.jet_template_tower_core.id: { + "template": self.jet_template_tower_core, + "name": self.jet_template_tower_core.name, + "reference": self.jet_template_tower_core.reference, + "dependencies": [], + "level": 0, + }, + } + + # Call _calculate_dependency_levels + self.jet_template_wordpress._calculate_dependency_levels(graph) + + # Verify levels + self.assertEqual( + graph[self.jet_template_wordpress.id]["level"], + 0, + "WordPress should be level 0 (root)", + ) + self.assertEqual( + graph[self.jet_template_mariadb.id]["level"], 1, "MariaDB should be level 1" + ) + self.assertEqual( + graph[self.jet_template_nginx.id]["level"], 1, "Nginx should be level 1" + ) + self.assertEqual( + graph[self.jet_template_docker.id]["level"], + 2, + "Docker should be level 2 (shortest path from WordPress)", + ) + self.assertEqual( + graph[self.jet_template_tower_core.id]["level"], + 3, + "Tower Core should be level 3", + ) + + def test_calculate_dependency_levels_multiple_paths(self): + """Test _calculate_dependency_levels with multiple paths to same template""" + # Use existing WordPress template with multiple paths + + # Build the graph manually + graph = { + self.jet_template_wordpress.id: { + "template": self.jet_template_wordpress, + "name": self.jet_template_wordpress.name, + "reference": self.jet_template_wordpress.reference, + "dependencies": [ + {"template_id": self.jet_template_mariadb.id}, + {"template_id": self.jet_template_nginx.id}, + ], + "level": 0, + }, + self.jet_template_mariadb.id: { + "template": self.jet_template_mariadb, + "name": self.jet_template_mariadb.name, + "reference": self.jet_template_mariadb.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, + }, + self.jet_template_nginx.id: { + "template": self.jet_template_nginx, + "name": self.jet_template_nginx.name, + "reference": self.jet_template_nginx.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, + }, + self.jet_template_docker.id: { + "template": self.jet_template_docker, + "name": self.jet_template_docker.name, + "reference": self.jet_template_docker.reference, + "dependencies": [{"template_id": self.jet_template_tower_core.id}], + "level": 0, + }, + self.jet_template_tower_core.id: { + "template": self.jet_template_tower_core, + "name": self.jet_template_tower_core.name, + "reference": self.jet_template_tower_core.reference, + "dependencies": [], + "level": 0, + }, + } + + # Call _calculate_dependency_levels + self.jet_template_wordpress._calculate_dependency_levels(graph) + + # Verify levels - Docker should have level 2 (shortest path from WordPress) + self.assertEqual( + graph[self.jet_template_wordpress.id]["level"], + 0, + "WordPress should be level 0 (root)", + ) + self.assertEqual( + graph[self.jet_template_mariadb.id]["level"], 1, "MariaDB should be level 1" + ) + self.assertEqual( + graph[self.jet_template_nginx.id]["level"], 1, "Nginx should be level 1" + ) + self.assertEqual( + graph[self.jet_template_docker.id]["level"], + 2, + "Docker should be level 2 (shortest path)", + ) + self.assertEqual( + graph[self.jet_template_tower_core.id]["level"], + 3, + "Tower Core should be level 3", + ) + + def test_calculate_dependency_levels_empty_graph(self): + """Test _calculate_dependency_levels with empty graph""" + # pylint: disable=protected-access + # Use existing Tower Core template + + # Empty graph + graph = {} + + # Call _calculate_dependency_levels - should not raise error + self.jet_template_tower_core._calculate_dependency_levels(graph) + + # Graph should remain empty + self.assertEqual(len(graph), 0, "Empty graph should remain empty") + + def test_calculate_dependency_levels_single_template(self): + """Test _calculate_dependency_levels with single template""" + # pylint: disable=protected-access + # Use existing Tower Core template (has no dependencies) + + # Single template graph + graph = { + self.jet_template_tower_core.id: { + "template": self.jet_template_tower_core, + "name": self.jet_template_tower_core.name, + "reference": self.jet_template_tower_core.reference, + "dependencies": [], + "level": 0, + } + } + + # Call _calculate_dependency_levels + self.jet_template_tower_core._calculate_dependency_levels(graph) + + # Tower Core should be level 0 + self.assertEqual( + graph[self.jet_template_tower_core.id]["level"], + 0, + "Single template should be level 0", + ) + + def test_calculate_dependency_levels_missing_template_in_graph(self): + """Test _calculate_dependency_levels with template not in graph""" + # pylint: disable=protected-access + # Use existing Odoo template but reference a non-existent template + + # Graph with Odoo but not the referenced template + graph = { + self.jet_template_odoo.id: { + "template": self.jet_template_odoo, + "name": self.jet_template_odoo.name, + "reference": self.jet_template_odoo.reference, + "dependencies": [{"template_id": 99999}], # Non-existent template ID + "level": 0, + } + } + + # Call _calculate_dependency_levels - should handle missing template gracefully + self.jet_template_odoo._calculate_dependency_levels(graph) + + # Odoo should be level 0 + self.assertEqual( + graph[self.jet_template_odoo.id]["level"], 0, "Odoo should be level 0" + ) + + def test_calculate_dependency_levels_complex_hierarchy(self): + """Test _calculate_dependency_levels with complex hierarchy""" + # pylint: disable=protected-access + # Use existing templates with complex hierarchy + # This creates a complex hierarchy + + # Build the graph manually - only include Odoo's actual dependencies + graph = { + self.jet_template_odoo.id: { + "template": self.jet_template_odoo, + "name": self.jet_template_odoo.name, + "reference": self.jet_template_odoo.reference, + "dependencies": [ + {"template_id": self.jet_template_postgres.id}, + {"template_id": self.jet_template_nginx.id}, + ], + "level": 0, + }, + self.jet_template_postgres.id: { + "template": self.jet_template_postgres, + "name": self.jet_template_postgres.name, + "reference": self.jet_template_postgres.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, + }, + self.jet_template_nginx.id: { + "template": self.jet_template_nginx, + "name": self.jet_template_nginx.name, + "reference": self.jet_template_nginx.reference, + "dependencies": [{"template_id": self.jet_template_docker.id}], + "level": 0, + }, + self.jet_template_docker.id: { + "template": self.jet_template_docker, + "name": self.jet_template_docker.name, + "reference": self.jet_template_docker.reference, + "dependencies": [{"template_id": self.jet_template_tower_core.id}], + "level": 0, + }, + self.jet_template_tower_core.id: { + "template": self.jet_template_tower_core, + "name": self.jet_template_tower_core.name, + "reference": self.jet_template_tower_core.reference, + "dependencies": [], + "level": 0, + }, + } + + # Call _calculate_dependency_levels from Odoo + self.jet_template_odoo._calculate_dependency_levels(graph) + + # Verify levels + self.assertEqual( + graph[self.jet_template_odoo.id]["level"], + 0, + "Odoo should be level 0 (root)", + ) + self.assertEqual( + graph[self.jet_template_postgres.id]["level"], + 1, + "Postgres should be level 1", + ) + self.assertEqual( + graph[self.jet_template_nginx.id]["level"], 1, "Nginx should be level 1" + ) + self.assertEqual( + graph[self.jet_template_docker.id]["level"], 2, "Docker should be level 2" + ) + self.assertEqual( + graph[self.jet_template_tower_core.id]["level"], + 3, + "Tower Core should be level 3", + ) + + # Verify that only Odoo's dependencies are in the graph + expected_template_ids = [ + self.jet_template_odoo.id, + self.jet_template_postgres.id, + self.jet_template_nginx.id, + self.jet_template_docker.id, + self.jet_template_tower_core.id, + ] + self.assertEqual( + set(graph.keys()), + set(expected_template_ids), + "Graph should only contain Odoo's dependencies", + ) + + def test_get_all_dependencies_simple_chain(self): + """Test _get_all_dependencies with simple dependency chain""" + # pylint: disable=protected-access + # Use existing Odoo dependency chain: + # Odoo -> Postgres/Nginx -> Docker -> Tower Core + + dependencies = self.jet_template_odoo._get_all_dependencies() + + # Should return all dependencies in level order (closest first) + expected_dependencies = { + self.jet_template_postgres, + self.jet_template_nginx, + self.jet_template_docker, + self.jet_template_tower_core, + } + self.assertEqual( + set(dependencies), + expected_dependencies, + "Should return all expected dependencies", + ) + + # Verify the order is correct (level 1, then level 2, then level 3) + # Postgres and Nginx should be first (level 1) + self.assertIn( + self.jet_template_postgres, + dependencies[:2], + "Postgres should be in first two dependencies", + ) + self.assertIn( + self.jet_template_nginx, + dependencies[:2], + "Nginx should be in first two dependencies", + ) + + # Docker should be third (level 2) + self.assertEqual( + dependencies[2], self.jet_template_docker, "Docker should be third" + ) + + # Tower Core should be last (level 3) + self.assertEqual( + dependencies[3], self.jet_template_tower_core, "Tower Core should be last" + ) + + def test_get_all_dependencies_no_dependencies(self): + """Test _get_all_dependencies with template that has no dependencies""" + # pylint: disable=protected-access + # Use Tower Core which has no dependencies + + dependencies = self.jet_template_tower_core._get_all_dependencies() + + # Should return empty list + self.assertEqual( + dependencies, + [], + "Should return empty list for template with no dependencies", + ) + + def test_get_all_dependencies_wordpress_chain(self): + """Test _get_all_dependencies with WordPress dependency chain""" + # pylint: disable=protected-access + # Use WordPress dependency chain: + # WordPress -> MariaDB/Nginx -> Docker -> Tower Core + + dependencies = self.jet_template_wordpress._get_all_dependencies() + + # Should return all dependencies in level order + expected_dependencies = { + self.jet_template_mariadb, + self.jet_template_nginx, + self.jet_template_docker, + self.jet_template_tower_core, + } + self.assertEqual( + set(dependencies), + expected_dependencies, + "Should return all expected dependencies", + ) + + # Verify the order is correct + # MariaDB and Nginx should be first (level 1) + self.assertIn( + self.jet_template_mariadb, + dependencies[:2], + "MariaDB should be in first two dependencies", + ) + self.assertIn( + self.jet_template_nginx, + dependencies[:2], + "Nginx should be in first two dependencies", + ) + + # Docker should be third (level 2) + self.assertEqual( + dependencies[2], self.jet_template_docker, "Docker should be third" + ) + + # Tower Core should be last (level 3) + self.assertEqual( + dependencies[3], self.jet_template_tower_core, "Tower Core should be last" + ) + + def test_get_all_dependencies_docker_chain(self): + """Test _get_all_dependencies with Docker dependency chain""" + # pylint: disable=protected-access + # Use Docker dependency chain: Docker -> Tower Core + + dependencies = self.jet_template_docker._get_all_dependencies() + + # Should return only Tower Core + expected_dependencies = [self.jet_template_tower_core] + self.assertEqual( + dependencies, expected_dependencies, "Should return only Tower Core" + ) + + def test_get_all_dependencies_nginx_chain(self): + """Test _get_all_dependencies with Nginx dependency chain""" + # pylint: disable=protected-access + # Use Nginx dependency chain: Nginx -> Docker -> Tower Core + + dependencies = self.jet_template_nginx._get_all_dependencies() + + # Should return Docker and Tower Core + expected_dependencies = [self.jet_template_docker, self.jet_template_tower_core] + self.assertEqual( + dependencies, expected_dependencies, "Should return Docker and Tower Core" + ) + + def test_get_all_dependencies_complex_scenario(self): + """Test _get_all_dependencies with complex dependency scenario""" + # pylint: disable=protected-access + # Use existing WooCommerce with Odoo template + # This tests the scenario where a template has multiple dependency paths + + dependencies = self.jet_template_woocommerce_odoo._get_all_dependencies() + + # Should include all dependencies from both Odoo and WordPress + # Expected: Odoo, WordPress, Postgres, MariaDB, Nginx, Docker, Tower Core + expected_template_ids = [ + self.jet_template_odoo.id, + self.jet_template_wordpress.id, + self.jet_template_postgres.id, + self.jet_template_mariadb.id, + self.jet_template_nginx.id, + self.jet_template_docker.id, + self.jet_template_tower_core.id, + ] + + actual_template_ids = [dep.id for dep in dependencies] + self.assertEqual( + set(actual_template_ids), + set(expected_template_ids), + "Should include all dependencies from both Odoo and WordPress", + ) + + # Verify that dependencies are ordered by level + # Level 1: Odoo, WordPress + # Level 2: Postgres, MariaDB, Nginx + # Level 3: Docker + # Level 4: Tower Core + + # Check that Odoo and WordPress are in the first two positions + self.assertIn( + self.jet_template_odoo, + dependencies[:2], + "Odoo should be in first two dependencies", + ) + self.assertIn( + self.jet_template_wordpress, + dependencies[:2], + "WordPress should be in first two dependencies", + ) + + # Check that Tower Core is last + self.assertEqual( + dependencies[-1], self.jet_template_tower_core, "Tower Core should be last" + ) + + def test_get_all_dependencies_excludes_self(self): + """Test _get_all_dependencies excludes the template itself""" + # pylint: disable=protected-access + # Use Odoo template + + dependencies = self.jet_template_odoo._get_all_dependencies() + + # Should not include Odoo itself + self.assertNotIn( + self.jet_template_odoo, + dependencies, + "Should not include the template itself", + ) + + # Verify all returned dependencies are different from the root template + for dependency in dependencies: + self.assertNotEqual( + dependency.id, + self.jet_template_odoo.id, + f"Should not include template with ID {dependency.id}", + ) + + def test_get_all_dependencies_same_level_must_order_transitive_edges(self): + """ + If root A depends on B and C directly, and C also depends on B, then B and + C share the same shortest-path level. Install lines use reverse order by + ``order``; the dependency list must place C before B so B gets a higher + line order and is installed before C. + """ + # pylint: disable=protected-access + tpl_b = self.JetTemplate.create( + { + "name": "Topo Base B", + "reference": "topo_base_b", + } + ) + tpl_c = self.JetTemplate.create( + { + "name": "Topo Mid C", + "reference": "topo_mid_c", + } + ) + tpl_a = self.JetTemplate.create( + { + "name": "Topo Root A", + "reference": "topo_root_a", + } + ) + # C depends on B + self.JetTemplateDependency.create( + { + "template_id": tpl_c.id, + "template_required_id": tpl_b.id, + "state_required_id": self.state_running.id, + } + ) + # A depends on C first, then B so graph traversal tends to visit B before C + # in ``graph.items()`` while both stay at level 1. + self.JetTemplateDependency.create( + { + "template_id": tpl_a.id, + "template_required_id": tpl_c.id, + "state_required_id": self.state_running.id, + } + ) + self.JetTemplateDependency.create( + { + "template_id": tpl_a.id, + "template_required_id": tpl_b.id, + "state_required_id": self.state_running.id, + } + ) + + dependencies = tpl_a._get_all_dependencies() + idx_b = next(i for i, t in enumerate(dependencies) if t.id == tpl_b.id) + idx_c = next(i for i, t in enumerate(dependencies) if t.id == tpl_c.id) + + self.assertLess( + idx_c, + idx_b, + "C must appear before B in dependency order so install (reverse order)" + " runs B before C when C depends on B", + ) + + def test_get_all_dependencies_consistency_with_build_graph(self): + """ + _get_all_dependencies must return dependents before their prerequisites. + + Correctness is verified against the graph edges directly (the topological + invariant) rather than re-running _topological_sort_dependency_graph, which + would create a circular check where a bug in the sort masks itself. + """ + # pylint: disable=protected-access + graph = self.jet_template_odoo._build_dependency_graph() + dependencies = self.jet_template_odoo._get_all_dependencies() + + self.assertTrue(dependencies, "Expected a non-empty dependency list") + + index = {tmpl.id: i for i, tmpl in enumerate(dependencies)} + + for u_id, info in graph.items(): + if u_id not in index: + continue + for dep in info["dependencies"]: + v_id = dep["template_id"] + if v_id not in index: + continue + self.assertLess( + index[u_id], + index[v_id], + f"{graph[u_id]['name']} (dependent) must appear before " + f"{graph[v_id]['name']} (prerequisite)", + ) + + def test_get_all_dependencies_woocommerce_odoo_chain(self): + """Test _get_all_dependencies with WooCommerce with Odoo dependency chain""" + # pylint: disable=protected-access + # Use WooCommerce with Odoo dependency chain: + # WooCommerce -> WordPress/Odoo -> + # MariaDB/Postgres/Nginx -> Docker -> Tower Core + + dependencies = self.jet_template_woocommerce_odoo._get_all_dependencies() + + # Should include all dependencies from both WordPress and Odoo + # Expected: WordPress, Odoo, MariaDB, Postgres, Nginx, Docker, Tower Core + expected_template_ids = [ + self.jet_template_wordpress.id, + self.jet_template_odoo.id, + self.jet_template_mariadb.id, + self.jet_template_postgres.id, + self.jet_template_nginx.id, + self.jet_template_docker.id, + self.jet_template_tower_core.id, + ] + + actual_template_ids = [dep.id for dep in dependencies] + self.assertEqual( + set(actual_template_ids), + set(expected_template_ids), + "Should include all dependencies from both WordPress and Odoo", + ) + + # Verify that dependencies are ordered by level + # Level 1: WordPress, Odoo + # Level 2: MariaDB, Postgres, Nginx + # Level 3: Docker + # Level 4: Tower Core + + # Check that WordPress and Odoo are in the first two positions + self.assertIn( + self.jet_template_wordpress, + dependencies[:2], + "WordPress should be in first two dependencies", + ) + self.assertIn( + self.jet_template_odoo, + dependencies[:2], + "Odoo should be in first two dependencies", + ) + + # Check that Tower Core is last + self.assertEqual( + dependencies[-1], self.jet_template_tower_core, "Tower Core should be last" + ) + + # Verify that all level 2 dependencies are present + level_2_deps = [ + self.jet_template_mariadb, + self.jet_template_postgres, + self.jet_template_nginx, + ] + for dep in level_2_deps: + self.assertIn(dep, dependencies, f"{dep.name} should be in dependencies") + + # Verify that Docker is present + self.assertIn( + self.jet_template_docker, dependencies, "Docker should be in dependencies" + ) + + def test_get_action_path_with_destroy_action_only(self): + """ + Test _get_action_path with only destroy action set + """ + # Create states + state_running = self.JetState.create( + { + "name": "Running", + "reference": "running", + "sequence": 20, + } + ) + state_stopped = self.JetState.create( + { + "name": "Stopped", + "reference": "stopped", + "sequence": 30, + } + ) + + # Create destroy action + destroy_action = self.JetAction.create( + { + "name": "Destroy Action", + "reference": "destroy_action", + "jet_template_id": self.clean_template.id, + "state_from_id": state_running.id, + "state_to_id": False, + "state_transit_id": state_stopped.id, + "priority": 10, + } + ) + + # Set destroy action + self.clean_template.action_destroy_id = destroy_action + + # Test path without state_to (should use destroy action) + result = self.clean_template._get_action_path(state_from=state_running) + self.assertEqual( + result, + [destroy_action], + "Should return destroy action when no state_to provided", + ) + + # Test path with state_to (should not use destroy action) + result = self.clean_template._get_action_path( + state_from=state_running, state_to=state_stopped + ) + self.assertEqual( + result, + [], + "Should return empty list when state_to provided and no path exists", + ) + + def test_get_action_path_same_state(self): + """ + Test _get_action_path when start and end states are the same + """ + # Test same state without destroy action + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=self.state_a + ) + self.assertEqual( + result, [], "Should return empty list for same start and end state" + ) + + # Create destroy action + destroy_action = self.JetAction.create( + { + "name": "Destroy Action", + "reference": "destroy_action", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": False, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + self.clean_template.action_destroy_id = destroy_action + + # Test same state with destroy action (no state_to provided) + result = self.clean_template._get_action_path(state_from=self.state_a) + self.assertEqual( + result, + [destroy_action], + "Should return destroy action for same state when no state_to provided", + ) + + def test_get_action_path_direct_path(self): + """ + Test _get_action_path with direct path between states + """ + # Create direct action + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + + # Test direct path + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=self.state_b + ) + self.assertEqual(result, [action_ab], "Should return direct action path") + + def test_get_action_path_multi_step_path(self): + """ + Test _get_action_path with multi-step path + """ + # Create multi-step actions + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_bc = self.JetAction.create( + { + "name": "Action B to C", + "reference": "action_bc", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Test multi-step path + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=self.state_c + ) + expected_path = [action_ab, action_bc] + self.assertEqual(result, expected_path, "Should return multi-step action path") + + def test_get_action_path_with_create_and_multi_step(self): + """ + Test _get_action_path with create action and multi-step path + """ + # Create create action + create_action = self.JetAction.create( + { + "name": "Create Action", + "reference": "create_action", + "jet_template_id": self.clean_template.id, + "state_from_id": False, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_a.id, + "priority": 10, + } + ) + + # Create transition action + action_rs = self.JetAction.create( + { + "name": "Action Running to Stopped", + "reference": "action_rs", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_c.id, + "priority": 10, + } + ) + + # Set create action + self.clean_template.action_create_id = create_action + + # Test path from create to final state + result = self.clean_template._get_action_path(state_to=self.state_c) + expected_path = [create_action, action_rs] + self.assertEqual( + result, expected_path, "Should return create action + transition path" + ) + + def test_get_action_path_with_multi_step_and_destroy(self): + """ + Test _get_action_path with multi-step path and destroy action + """ + # Create multi-step actions + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_bc = self.JetAction.create( + { + "name": "Action B to C", + "reference": "action_bc", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Create destroy action + destroy_action = self.JetAction.create( + { + "name": "Destroy Action", + "reference": "destroy_action", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_c.id, + "state_to_id": False, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Set destroy action + self.clean_template.action_destroy_id = destroy_action + + # Test path from A to destroy + result = self.clean_template._get_action_path(state_from=self.state_a) + expected_path = [action_ab, action_bc, destroy_action] + self.assertEqual( + result, expected_path, "Should return multi-step path + destroy action" + ) + + def test_get_action_path_complete_lifecycle(self): + """ + Test _get_action_path with complete lifecycle (create -> multi-step -> destroy) + """ + # Create create action + create_action = self.JetAction.create( + { + "name": "Create Action", + "reference": "create_action", + "jet_template_id": self.clean_template.id, + "state_from_id": False, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_a.id, + "priority": 10, + } + ) + + # Create transition action + action_rs = self.JetAction.create( + { + "name": "Action Running to Stopped", + "reference": "action_rs", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_c.id, + "priority": 10, + } + ) + + # Create destroy action + destroy_action = self.JetAction.create( + { + "name": "Destroy Action", + "reference": "destroy_action", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_c.id, + "state_to_id": False, + "state_transit_id": self.state_c.id, + "priority": 10, + } + ) + + # Set border actions + self.clean_template.action_create_id = create_action + self.clean_template.action_destroy_id = destroy_action + + # Test complete lifecycle + result = self.clean_template._get_action_path() + expected_path = [create_action, action_rs, destroy_action] + self.assertEqual(result, expected_path, "Should return complete lifecycle path") + + def test_get_action_path_no_path_exists(self): + """ + Test _get_action_path when no path exists between states + """ + # Create action that doesn't connect A to C + self.JetAction.create( + { + "name": "Action B to C", + "reference": "action_bc", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Test path from A to C (no path exists) + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=self.state_c + ) + self.assertEqual(result, [], "Should return empty list when no path exists") + + def test_get_action_path_complex_multi_level_path(self): + """ + Test _get_action_path with complex multi-level path + """ + # Create additional states for this test + state_e = self.JetState.create( + { + "name": "State E", + "reference": "state_e", + "sequence": 50, + } + ) + + # Create complex multi-level actions + action_ab = self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + action_bc = self.JetAction.create( + { + "name": "Action B to C", + "reference": "action_bc", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + action_cd = self.JetAction.create( + { + "name": "Action C to D", + "reference": "action_cd", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_c.id, + "state_to_id": self.state_d.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + action_de = self.JetAction.create( + { + "name": "Action D to E", + "reference": "action_de", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_d.id, + "state_to_id": state_e.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Test complex multi-level path + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=state_e + ) + expected_path = [action_ab, action_bc, action_cd, action_de] + self.assertEqual( + result, expected_path, "Should return complex multi-level path" + ) + + def test_get_action_path_shortest_path_selection(self): + """ + Test _get_action_path selects shortest path when multiple paths exist + """ + # Create short path: A -> C + action_ac = self.JetAction.create( + { + "name": "Action A to C (short)", + "reference": "action_ac", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Create long path: A -> B -> D -> C + self.JetAction.create( + { + "name": "Action A to B", + "reference": "action_ab", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_a.id, + "state_to_id": self.state_b.id, + "state_transit_id": self.state_starting.id, + "priority": 10, + } + ) + self.JetAction.create( + { + "name": "Action B to D", + "reference": "action_bd", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_b.id, + "state_to_id": self.state_d.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + self.JetAction.create( + { + "name": "Action D to C", + "reference": "action_dc", + "jet_template_id": self.clean_template.id, + "state_from_id": self.state_d.id, + "state_to_id": self.state_c.id, + "state_transit_id": self.state_stopping.id, + "priority": 10, + } + ) + + # Test that shortest path is selected + result = self.clean_template._get_action_path( + state_from=self.state_a, state_to=self.state_c + ) + expected_path = [action_ac] # Shortest path + self.assertEqual( + result, + expected_path, + "Should select shortest path when multiple paths exist", + ) + + def test_check_dependency_satisfaction_no_dependencies(self): + """Test _check_dependency_satisfaction when template has no dependencies""" + # pylint: disable=protected-access + server = self.server_test_1 + + # Test with template that has no dependencies + missing_templates = self.jet_template_tower_core._check_dependency_satisfaction( + server + ) + + # Should return empty list since tower_core has no dependencies + self.assertEqual( + len(missing_templates), + 0, + "Should return empty list when no dependencies exist", + ) + + def test_check_dependency_satisfaction_all_missing(self): + """Test _check_dependency_satisfaction when all dependencies are missing""" + # pylint: disable=protected-access + server = self.server_test_1 + + # Test with different templates that have dependencies + templates_to_test = [ + self.jet_template_nginx, + self.jet_template_odoo, + self.jet_template_woocommerce_odoo, + ] + + for template in templates_to_test: + # Get actual dependencies for template + all_deps = template._get_all_dependencies() + + # Test - should return all missing dependencies + missing_templates = template._check_dependency_satisfaction(server) + + # Should return all dependencies since none are installed + expected_dependencies = set(all_deps) + actual_dependencies = set(missing_templates) + self.assertEqual( + actual_dependencies, + expected_dependencies, + f"Should return all missing dependencies for {template.name}", + ) + + def test_check_dependency_satisfaction_all_satisfied(self): + """Test _check_dependency_satisfaction when all dependencies are satisfied""" + # pylint: disable=protected-access + server = self.server_test_1 + + # Test with different templates that have dependencies + templates_to_test = [ + self.jet_template_nginx, + self.jet_template_odoo, + self.jet_template_woocommerce_odoo, + ] + + for template in templates_to_test: + # Install all dependencies for this template + all_deps = template._get_all_dependencies() + for dep_template in all_deps: + dep_template.server_ids = [(4, server.id)] + + # Test - should return empty list + missing_templates = template._check_dependency_satisfaction(server) + + # Should return empty list since all dependencies are now installed + self.assertEqual( + len(missing_templates), + 0, + f"Should return empty list for {template.name}", + ) + + def test_check_dependency_satisfaction_partial_installation(self): + """Test _check_dependency_satisfaction with partial installation""" + # pylint: disable=protected-access + server = self.server_test_1 + + # Get all dependencies for odoo + all_deps = self.jet_template_odoo._get_all_dependencies() + + # Install some dependencies but not all (install first half) + half_count = len(all_deps) // 2 + for i, dep in enumerate(all_deps): + if i < half_count: + dep.server_ids = [(4, server.id)] + + # Test with odoo + missing_templates = self.jet_template_odoo._check_dependency_satisfaction( + server + ) + + # Should return the remaining uninstalled dependencies + expected_missing = set(all_deps[half_count:]) + actual_missing = set(missing_templates) + self.assertEqual( + actual_missing, + expected_missing, + "Should return only the missing dependencies", + ) + + def test_check_dependency_satisfaction_no_server(self): + """Test _check_dependency_satisfaction when server is None""" + # pylint: disable=protected-access + + # Test with odoo and None server + missing_templates = self.jet_template_odoo._check_dependency_satisfaction(None) + + # Should return empty list when server is None (no server to check against) + self.assertEqual( + len(missing_templates), 0, "Should return empty list when server is None" + ) + + def test_check_dependency_satisfaction_multiple_servers(self): + """Test _check_dependency_satisfaction with different server states""" + # pylint: disable=protected-access + server1 = self.server_test_1 + server2 = self.server_test_2 + + # Get actual dependencies for nginx + all_deps = self.jet_template_nginx._get_all_dependencies() + + # Install all dependencies on server1 + for dep in all_deps: + dep.server_ids = [(4, server1.id)] + + # Test with nginx on both servers + missing_templates_server1 = ( + self.jet_template_nginx._check_dependency_satisfaction(server1) + ) + missing_templates_server2 = ( + self.jet_template_nginx._check_dependency_satisfaction(server2) + ) + + # Server1 should have no missing dependencies + self.assertEqual( + len(missing_templates_server1), + 0, + "Server1 should have no missing dependencies", + ) + self.assertEqual( + len(missing_templates_server2), + len(all_deps), + "Server2 should have all dependencies missing", + ) + + # Verify server2 has all the expected missing dependencies + expected_missing_server2 = set(all_deps) + actual_missing_server2 = set(missing_templates_server2) + self.assertEqual( + actual_missing_server2, + expected_missing_server2, + "Server2 should be missing all dependencies", + ) + + def test_check_dependency_satisfaction_self_dependency(self): + """Test _check_dependency_satisfaction with template that depends on itself""" + # pylint: disable=protected-access + server = self.server_test_1 + + # Create a template that depends on itself + # But let's test the method behavior anyway + self_loop_template = self.JetTemplate.create( + { + "name": "Self Loop Template", + "reference": "self_loop_template", + } + ) + + # Manually create a dependency record (this would normally be prevented) + # We'll test the method's behavior when it encounters this situation + missing_templates = self_loop_template._check_dependency_satisfaction(server) + + # Should return empty list since template has no dependencies + self.assertEqual( + len(missing_templates), + 0, + "Should return empty list for template with no dependencies", + ) + + def test_get_all_depend_on_this_no_dependents(self): + """Test _get_all_depend_on_this when template has no dependents""" + # pylint: disable=protected-access + + # Test with woocommerce_odoo which should have no dependents + dependents = self.jet_template_woocommerce_odoo._get_all_depend_on_this() + + # Should return empty recordset since no templates depend on woocommerce_odoo + self.assertEqual( + len(dependents), + 0, + "Should return empty recordset when no templates depend on this one", + ) + + def test_get_all_depend_on_this_docker_dependents(self): + """Test _get_all_depend_on_this with docker's dependents""" + # pylint: disable=protected-access + + # Test with docker - should have all dependents (direct and indirect) + dependents = self.jet_template_docker._get_all_depend_on_this() + + # Should return all templates that depend on docker (directly or indirectly) + # docker -> nginx/postgres/mariadb -> odoo/wordpress -> woocommerce_odoo + expected_dependents = { + self.jet_template_nginx, + self.jet_template_postgres, + self.jet_template_mariadb, + self.jet_template_odoo, + self.jet_template_wordpress, + self.jet_template_woocommerce_odoo, + } + actual_dependents = set(dependents) + + # Filter out any templates that aren't in the expected set + # (some tests might have created additional dependencies) + actual_dependents_filtered = { + t for t in actual_dependents if t in expected_dependents + } + + self.assertEqual( + actual_dependents_filtered, + expected_dependents, + "Should return all dependents of docker", + ) + + def test_get_all_depend_on_this_indirect_dependents(self): + """Test _get_all_depend_on_this with indirect dependents""" + # pylint: disable=protected-access + + # Test with tower_core - should have many indirect dependents + dependents = self.jet_template_tower_core._get_all_depend_on_this() + + # Should return all templates that depend on tower_core (directly or indirectly) + # tower_core -> docker -> nginx/postgres -> odoo/wordpress -> woocommerce_odoo + expected_dependents = { + self.jet_template_docker, + self.jet_template_nginx, + self.jet_template_postgres, + self.jet_template_mariadb, + self.jet_template_odoo, + self.jet_template_wordpress, + self.jet_template_woocommerce_odoo, + } + actual_dependents = set(dependents) + + # Filter out any templates that aren't in the expected set + # (some tests might have created additional dependencies) + actual_dependents_filtered = { + t for t in actual_dependents if t in expected_dependents + } + + self.assertEqual( + actual_dependents_filtered, + expected_dependents, + "Should return all dependents including indirect ones", + ) + + def test_get_all_depend_on_this_complex_hierarchy(self): + """Test _get_all_depend_on_this with complex dependency hierarchy""" + # pylint: disable=protected-access + + # Test with nginx - should have odoo, wordpress, and woocommerce_odoo + dependents = self.jet_template_nginx._get_all_depend_on_this() + + # Should return odoo, wordpress, and woocommerce_odoo + expected_dependents = { + self.jet_template_odoo, + self.jet_template_wordpress, + self.jet_template_woocommerce_odoo, + } + actual_dependents = set(dependents) + + # Filter out any templates that aren't in the expected set + # (some tests might have created additional dependencies) + actual_dependents_filtered = { + t for t in actual_dependents if t in expected_dependents + } + + self.assertEqual( + actual_dependents_filtered, + expected_dependents, + "Should return all dependents in complex hierarchy", + ) + + def test_get_all_depend_on_this_multiple_levels(self): + """Test _get_all_depend_on_this with multiple dependency levels""" + # pylint: disable=protected-access + + # Test with postgres - should have odoo and woocommerce_odoo as dependents + dependents = self.jet_template_postgres._get_all_depend_on_this() + + # Should return odoo and woocommerce_odoo + expected_dependents = { + self.jet_template_odoo, + self.jet_template_woocommerce_odoo, + } + actual_dependents = set(dependents) + + # Filter out any templates that aren't in the expected set + # (some tests might have created additional dependencies) + actual_dependents_filtered = { + t for t in actual_dependents if t in expected_dependents + } + + self.assertEqual( + actual_dependents_filtered, + expected_dependents, + "Should return dependents across multiple levels", + ) + + def test_get_all_depend_on_this_self_dependency(self): + """Test _get_all_depend_on_this with template that has no dependents""" + # pylint: disable=protected-access + + # Test with a template that has no dependents + dependents = self.jet_template_woocommerce_odoo._get_all_depend_on_this() + + # Should return empty recordset + self.assertEqual( + len(dependents), + 0, + "Should return empty recordset for template with no dependents", + ) + + def test_get_all_depend_on_this_consistency_with_dependencies(self): + """Test that _get_all_depend_on_this is consistent with _get_all_dependencies""" + # pylint: disable=protected-access + + # For each template, check that its dependents are consistent + templates_to_test = [ + self.jet_template_tower_core, + self.jet_template_docker, + self.jet_template_nginx, + self.jet_template_postgres, + self.jet_template_odoo, + ] + + for template in templates_to_test: + # Get templates that depend on this template + dependents = template._get_all_depend_on_this() + + # For each dependent, check that this template is in its dependencies + for dependent in dependents: + dependent_deps = dependent._get_all_dependencies() + self.assertIn( + template, + dependent_deps, + f"{dependent.name} should have {template.name} in its dependencies", + ) + + def test_get_all_depend_on_this_circular_dependency_handling(self): + """Test _get_all_depend_on_this handles circular dependencies correctly""" + # pylint: disable=protected-access + + # Test with templates that might have circular dependencies + # This test ensures the method doesn't get stuck in infinite loops + templates_to_test = [ + self.jet_template_tower_core, + self.jet_template_docker, + self.jet_template_nginx, + self.jet_template_postgres, + self.jet_template_odoo, + ] + + for template in templates_to_test: + # This should not raise an exception or get stuck + dependents = template._get_all_depend_on_this() + + # Should return a valid recordset + self.assertIsInstance( + dependents, self.env["cx.tower.jet.template"].__class__ + ) + + # Should not include the template itself + self.assertNotIn( + template, dependents, "Template should not depend on itself" + ) + + def test_create_jet_with_server_logs(self): + """Test create_jet creates server logs correctly""" + # Create a file template for server logs + file_template = self.FileTemplate.create( + { + "name": "Test Log File Template", + "file_name": "test_log.txt", + "source": "tower", + "server_dir": "/var/log", + "code": "Test log content", + } + ) + + # Create server logs on the template + server_log_file = self.ServerLog.create( + { + "name": "Test File Log", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "file_template_id": file_template.id, + "access_level": "1", + } + ) + + server_log_command = self.ServerLog.create( + { + "name": "Test Command Log", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "command", + "command_id": self.command_list_dir.id, + "access_level": "1", + } + ) + + # Ensure template is installed on server + self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) + + # Create jet from template + jet = self.jet_template_test.create_jet( + server=self.server_test_1, name="Test Jet with Logs" + ) + + # Verify jet was created + self.assertTrue(jet, "Jet should be created") + self.assertEqual(jet.name, "Test Jet with Logs") + self.assertEqual(jet.server_id, self.server_test_1) + self.assertEqual(jet.jet_template_id, self.jet_template_test) + + # Verify server logs were created for the jet + jet_logs = self.ServerLog.search([("jet_id", "=", jet.id)]) + self.assertEqual( + len(jet_logs), + 2, + "Should create 2 server logs (one file, one command)", + ) + + # Verify file-type log + jet_log_file = jet_logs.filtered(lambda log: log.log_type == "file") + self.assertEqual( + len(jet_log_file), + 1, + "Should have exactly one file-type log", + ) + jet_log_file = jet_log_file[0] # Get single record + self.assertEqual( + jet_log_file.jet_id, + jet, + "File log should be linked to the jet", + ) + self.assertEqual( + jet_log_file.server_id, + self.server_test_1, + "File log should be linked to the server", + ) + self.assertFalse( + jet_log_file.jet_template_id, + "File log should not be linked to template", + ) + self.assertTrue( + jet_log_file.file_id, + "File log should have a file created", + ) + self.assertEqual( + jet_log_file.file_template_id, + server_log_file.file_template_id, + "File log should reference the same file template as template log", + ) + self.assertEqual( + jet_log_file.name, + server_log_file.name, + "File log should have the same name as template log", + ) + self.assertEqual( + jet_log_file.file_id.jet_id, + jet, + "Created file should be linked to the jet", + ) + self.assertEqual( + jet_log_file.file_id.server_id, + self.server_test_1, + "Created file should be linked to the server", + ) + + # Verify command-type log + jet_log_command = jet_logs.filtered(lambda log: log.log_type == "command") + self.assertEqual( + len(jet_log_command), + 1, + "Should have exactly one command-type log", + ) + jet_log_command = jet_log_command[0] # Get single record + self.assertEqual( + jet_log_command.jet_id, + jet, + "Command log should be linked to the jet", + ) + self.assertEqual( + jet_log_command.server_id, + self.server_test_1, + "Command log should be linked to the server", + ) + self.assertFalse( + jet_log_command.jet_template_id, + "Command log should not be linked to template", + ) + self.assertFalse( + jet_log_command.file_id, + "Command log should not have a file", + ) + self.assertEqual( + jet_log_command.command_id, + server_log_command.command_id, + "Command log should reference the same command as template log", + ) + self.assertEqual( + jet_log_command.name, + server_log_command.name, + "Command log should have the same name as template log", + ) + + # Verify original template logs are unchanged + template_logs = self.ServerLog.search( + [("jet_template_id", "=", self.jet_template_test.id)] + ) + self.assertIn( + server_log_file, + template_logs, + "Template file log should still exist", + ) + self.assertIn( + server_log_command, + template_logs, + "Template command log should still exist", + ) + self.assertFalse( + server_log_file.jet_id, + "Template file log should not be linked to any jet", + ) + self.assertFalse( + server_log_command.jet_id, + "Template command log should not be linked to any jet", + ) + + def test_create_jet_with_multiple_file_logs(self): + """Test create_jet creates multiple file logs correctly""" + # Create multiple file templates + file_template_1 = self.FileTemplate.create( + { + "name": "Log File Template 1", + "file_name": "log1.txt", + "source": "tower", + "server_dir": "/var/log", + "code": "Log 1 content", + } + ) + + file_template_2 = self.FileTemplate.create( + { + "name": "Log File Template 2", + "file_name": "log2.txt", + "source": "tower", + "server_dir": "/var/log", + "code": "Log 2 content", + } + ) + + # Create multiple server logs on the template + self.ServerLog.create( + { + "name": "File Log 1", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "file_template_id": file_template_1.id, + "access_level": "1", + } + ) + + self.ServerLog.create( + { + "name": "File Log 2", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "file_template_id": file_template_2.id, + "access_level": "2", + } + ) + + # Ensure template is installed on server + self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) + + # Create jet from template + jet = self.jet_template_test.create_jet( + server=self.server_test_1, name="Test Jet Multiple Files" + ) + + # Verify all file logs were created + jet_logs = self.ServerLog.search([("jet_id", "=", jet.id)]) + file_logs = jet_logs.filtered(lambda log: log.log_type == "file") + self.assertEqual( + len(file_logs), + 2, + "Should create 2 file logs", + ) + + # Verify each file log has its own file + files = file_logs.mapped("file_id") + self.assertEqual( + len(files), + 2, + "Should create 2 files", + ) + self.assertEqual( + len(set(files.ids)), + 2, + "Files should be different", + ) + + # Verify files are linked correctly + for log in file_logs: + self.assertTrue(log.file_id, "Each log should have a file") + self.assertEqual( + log.file_id.jet_id, + jet, + "File should be linked to the jet", + ) + self.assertEqual( + log.file_id.server_id, + self.server_test_1, + "File should be linked to the server", + ) + + def test_create_jet_with_no_server_logs(self): + """Test create_jet works correctly when template has no server logs""" + # Ensure template has no server logs + self.jet_template_test.server_log_ids.unlink() + + # Ensure template is installed on server + self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) + + # Create jet from template + jet = self.jet_template_test.create_jet( + server=self.server_test_1, name="Test Jet No Logs" + ) + + # Verify jet was created + self.assertTrue(jet, "Jet should be created") + + # Verify no server logs were created + jet_logs = self.ServerLog.search([("jet_id", "=", jet.id)]) + self.assertEqual( + len(jet_logs), + 0, + "Should not create any server logs when template has none", + ) + + def test_create_jet_server_logs_fields_copied(self): + """Test that server log fields are correctly copied from template""" + # Create a file template + file_template = self.FileTemplate.create( + { + "name": "Test Log File Template", + "file_name": "test_log.txt", + "source": "tower", + "server_dir": "/var/log", + "code": "Test log content", + } + ) + + # Create server log with various fields + server_log = self.ServerLog.create( + { + "name": "Test Log with Fields", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "file_template_id": file_template.id, + "access_level": "2", + "use_sudo": True, + "reference": "test_log_ref", + } + ) + + # Ensure template is installed on server + self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) + + # Create jet from template + jet = self.jet_template_test.create_jet( + server=self.server_test_1, name="Test Jet Fields" + ) + + # Find the created log + jet_log = self.ServerLog.search([("jet_id", "=", jet.id)], limit=1) + + # Verify fields are copied correctly + self.assertEqual( + jet_log.name, + server_log.name, + "Log name should be copied", + ) + self.assertEqual( + jet_log.log_type, + server_log.log_type, + "Log type should be copied", + ) + self.assertEqual( + jet_log.file_template_id, + server_log.file_template_id, + "File template should be copied", + ) + self.assertEqual( + jet_log.access_level, + server_log.access_level, + "Access level should be copied", + ) + self.assertEqual( + jet_log.use_sudo, + server_log.use_sudo, + "Use sudo should be copied", + ) + # Reference should be different (due to reference mixin) + self.assertNotEqual( + jet_log.reference, + server_log.reference, + "Reference should be different (unique)", + ) + # Verify file was created for file-type log + self.assertTrue( + jet_log.file_id, + "File should be created for file-type log", + ) + self.assertEqual( + jet_log.file_id.jet_id, + jet, + "Created file should be linked to the jet", + ) + + def test_create_jet_different_servers(self): + """Test create_jet creates logs with correct server_id for different servers""" + # Create a file template + file_template = self.FileTemplate.create( + { + "name": "Test Log File Template", + "file_name": "test_log.txt", + "source": "tower", + "server_dir": "/var/log", + "code": "Test log content", + } + ) + + # Create server log on template (linked to server_test_1) + self.ServerLog.create( + { + "name": "Test Log", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "file_template_id": file_template.id, + } + ) + + # Ensure template is installed on both servers + self.jet_template_test.write( + { + "server_ids": [ + (4, self.server_test_1.id), + (4, self.server_test_2.id), + ] + } + ) + + # Create jet on server_test_2 + jet = self.jet_template_test.create_jet( + server=self.server_test_2, name="Test Jet Server 2" + ) + + # Verify jet was created on correct server + self.assertEqual( + jet.server_id, + self.server_test_2, + "Jet should be on server_test_2", + ) + + # Verify server log is linked to server_test_2 + jet_log = self.ServerLog.search([("jet_id", "=", jet.id)], limit=1) + self.assertEqual( + jet_log.server_id, + self.server_test_2, + "Server log should be linked to server_test_2", + ) + self.assertEqual( + jet_log.file_id.server_id, + self.server_test_2, + "File should be linked to server_test_2", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_template_access.py b/addons/cetmix_tower_server/tests/test_jet_template_access.py new file mode 100644 index 0000000..107e9b3 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_template_access.py @@ -0,0 +1,551 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetTemplateAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Template model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Use existing users from common.py (cls.user, cls.manager, cls.root) + # Create additional manager for multi-manager tests + cls.manager2 = cls.Users.create( + { + "name": "Test Manager 2", + "login": "test_manager_2", + "email": "test_manager_2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + # ====================== + # User Access Tests + # ====================== + + def test_user_read_access_level_user(self): + """Test User: Read access when access_level is "User" (1)""" + record = self.JetTemplate.create( + { + "name": "User Level Template", + "reference": "user_level_template", + "access_level": "1", # User level + "user_ids": False, # No users initially + "manager_ids": False, # No managers initially + } + ) + + # User should be able to read when access_level is "User" + records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) + self.assertEqual( + len(records), + 1, + "User should be able to read record when access_level is 'User'", + ) + + def test_user_read_access_user_ids(self): + """Test User: Read access when user is added in user_ids""" + record = self.JetTemplate.create( + { + "name": "User Added Template", + "reference": "user_added_template", + "access_level": "2", # Manager level - normally not accessible + "user_ids": [(4, self.user.id)], # User added + "manager_ids": False, + } + ) + + # User should be able to read when added to user_ids + records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) + self.assertEqual( + len(records), + 1, + "User should be able to read record when added to user_ids", + ) + + def test_user_read_access_jet_user_ids(self): + """ + Test User: Read access when user is added in "Users" of any Jets + created from the template + """ + # Create template with Manager level - normally not accessible + # and user NOT in template's user_ids + template = self.JetTemplate.create( + { + "name": "Template with Jet Users", + "reference": "template_with_jet_users", + "access_level": "2", # Manager level - normally not accessible + "user_ids": False, # No users in template + "manager_ids": False, + } + ) + + # User should NOT be able to read initially + records = self.JetTemplate.with_user(self.user).search( + [("id", "=", template.id)] + ) + self.assertEqual( + len(records), + 0, + "User should not be able to read template without access", + ) + + # Create a Jet from this template + # Need to add server to template's server_ids for jet creation + template.write({"server_ids": [(4, self.server_test_1.id)]}) + self._create_jet( + name="Test Jet from Template", + reference="test_jet_from_template", + template=template, + server=self.server_test_1, + user_ids=[(4, self.user.id)], # Add user to Jet's user_ids + ) + + # User should now be able to read the template + records = self.JetTemplate.with_user(self.user).search( + [("id", "=", template.id)] + ) + self.assertEqual( + len(records), + 1, + "User should be able to read template when added to Jet's user_ids", + ) + + def test_user_read_no_access(self): + """ + Test User: No read access when access_level is higher, + user not in template's user_ids, and user not in any Jet's user_ids + """ + record = self.JetTemplate.create( + { + "name": "Manager Level Template", + "reference": "manager_level_template", + "access_level": "2", # Manager level + "user_ids": False, # No users + "manager_ids": False, + } + ) + + # User should not be able to read + # (no access via access_level, template user_ids, or jet user_ids) + records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) + self.assertEqual( + len(records), + 0, + "User should not see record with Manager level " + "when not in user_ids or jet user_ids", + ) + + def test_user_write_forbidden(self): + """Test User: Cannot write/create/delete records""" + record = self.JetTemplate.create( + { + "name": "User Template", + "reference": "user_template", + "access_level": "1", + "user_ids": [(4, self.user.id)], + } + ) + + # User should not be able to write + with self.assertRaises(AccessError): + record.with_user(self.user).write({"name": "Updated Name"}) + + # User should not be able to create + with self.assertRaises(AccessError): + self.JetTemplate.with_user(self.user).create( + {"name": "New Template", "reference": "new_template"} + ) + + # User should not be able to delete + with self.assertRaises(AccessError): + record.with_user(self.user).unlink() + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_access_level_user(self): + """Test Manager: Read when access_level is "User" (1)""" + record = self.JetTemplate.create( + { + "name": "User Level for Manager", + "reference": "user_level_manager", + "access_level": "1", + "user_ids": False, + "manager_ids": False, + } + ) + + records = self.JetTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 1, "Manager should read access_level='1'") + + def test_manager_read_access_level_manager(self): + """Test Manager: Read when access_level is "Manager" (2)""" + record = self.JetTemplate.create( + { + "name": "Manager Level", + "reference": "manager_level", + "access_level": "2", + "user_ids": False, + "manager_ids": False, + } + ) + + records = self.JetTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 1, "Manager should read access_level='2'") + + def test_manager_read_access_user_ids(self): + """Test Manager: Read when added to user_ids regardless of access_level""" + record = self.JetTemplate.create( + { + "name": "Manager in Users", + "reference": "manager_in_users", + "access_level": "3", # Root level - normally not accessible + "user_ids": [(4, self.manager.id)], # Manager added as user + "manager_ids": False, + } + ) + + records = self.JetTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 1, "Manager should read when in user_ids") + + def test_manager_read_no_access_root_level(self): + """Test Manager: No read access for Root level (3) without user_ids""" + record = self.JetTemplate.create( + { + "name": "Root Level", + "reference": "root_level", + "access_level": "3", + "user_ids": False, + "manager_ids": False, + } + ) + + records = self.JetTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 0, "Manager should not read access_level='3'") + + # ====================== + # Manager Write/Create Access Tests + # ====================== + + def test_manager_write_access_level_and_manager_ids(self): + """Test Manager: Write when access_level <= 2 AND in manager_ids""" + record = self.JetTemplate.create( + { + "name": "Manager Can Write", + "reference": "manager_can_write", + "access_level": "2", + "user_ids": False, + "manager_ids": [(4, self.manager.id)], # Manager added + } + ) + + # Manager should be able to write + try: + record.with_user(self.manager).write({"name": "Updated Name"}) + record.invalidate_recordset() + self.assertEqual( + record.name, "Updated Name", "Manager should be able to update" + ) + except AccessError: + self.fail("Manager should be able to update when in manager_ids") + + def test_manager_write_access_level_user(self): + """Test Manager: Write when access_level = 1 and in manager_ids""" + record = self.JetTemplate.create( + { + "name": "User Level Manager Write", + "reference": "user_level_manager_write", + "access_level": "1", + "user_ids": False, + "manager_ids": [(4, self.manager.id)], + } + ) + + try: + record.with_user(self.manager).write({"name": "Updated"}) + except AccessError: + self.fail("Manager should be able to write access_level='1'") + + def test_manager_write_forbidden_not_in_manager_ids(self): + """Test Manager: No write when not in manager_ids""" + record = self.JetTemplate.create( + { + "name": "No Write Access", + "reference": "no_write_access", + "access_level": "2", + "user_ids": [(4, self.manager.id)], # Only in user_ids, not manager_ids + "manager_ids": False, + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_write_forbidden_root_level(self): + """Test Manager: No write when access_level is Root (3)""" + record = self.JetTemplate.create( + { + "name": "Root Level No Write", + "reference": "root_level_no_write", + "access_level": "3", + "user_ids": [(4, self.manager.id)], + "manager_ids": [(4, self.manager.id)], # In manager_ids + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_create_access(self): + """Test Manager: Create when access_level <= 2 AND in manager_ids""" + # Try to create without adding to manager_ids - should fail + with self.assertRaises(AccessError): + self.JetTemplate.with_user(self.manager).create( + { + "name": "Create Fail", + "reference": "create_fail", + "access_level": "2", + "manager_ids": False, # Not in manager_ids + } + ) + + # Create with manager added - should succeed + try: + record = self.JetTemplate.with_user(self.manager).create( + { + "name": "Create Success", + "reference": "create_success", + "access_level": "2", + "manager_ids": [(4, self.manager.id)], # In manager_ids + } + ) + records = self.JetTemplate.search([("id", "=", record.id)]) + self.assertEqual(len(records), 1, "Manager should be able to create") + except AccessError: + self.fail("Manager should be able to create when in manager_ids") + + # ====================== + # Manager Delete Access Tests + # ====================== + + def test_manager_delete_own_record(self): + """Test Manager: Delete own record when in manager_ids""" + record = self.JetTemplate.with_user(self.manager).create( + { + "name": "My Record", + "reference": "my_record", + "access_level": "2", + "manager_ids": [(4, self.manager.id)], + } + ) + + try: + record.with_user(self.manager).unlink() + records = self.JetTemplate.search([("id", "=", record.id)]) + self.assertEqual( + len(records), 0, "Manager should be able to delete own record" + ) + except AccessError: + self.fail("Manager should be able to delete own record") + + def test_manager_delete_not_creator(self): + """Test Manager: Cannot delete record created by another user""" + record = self.JetTemplate.with_user(self.manager2).create( + { + "name": "Other's Record", + "reference": "others_record", + "access_level": "2", + "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], + } + ) + + # Manager1 cannot delete Manager2's record + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + def test_manager_delete_not_in_manager_ids(self): + """Test Manager: Cannot delete when not in manager_ids""" + record = self.JetTemplate.with_user(self.manager).create( + { + "name": "Removed Manager", + "reference": "removed_manager", + "access_level": "2", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Remove from manager_ids + record.write({"manager_ids": False}) + + # Cannot delete anymore + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + def test_manager_delete_root_level(self): + """Test Manager: Cannot delete Root level record""" + # Create record with Root level as root (default user) + record = self.JetTemplate.create( + { + "name": "Root Level Delete", + "reference": "root_level_delete", + "access_level": "3", # Root level + "manager_ids": [(4, self.manager.id)], + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + # ====================== + # Root Access Tests + # ====================== + + def test_root_full_access(self): + """ + Test Root: Full CRUD access regardless of access_level or creator. + + Root has unrestricted access to all records via security rule + [(1, '=', 1)], so we test: + - Create records with all access levels + - Read records with all access levels + - Write to records with all access levels + - Delete records regardless of creator + """ + # Test CRUD operations for all access levels + for access_level in ["1", "2", "3"]: + # Root can create any level + record = self.JetTemplate.with_user(self.root).create( + { + "name": f"Root Level {access_level}", + "reference": f"root_level_{access_level}", + "access_level": access_level, + "user_ids": False, + "manager_ids": False, + } + ) + + # Root can read any level + records = self.JetTemplate.with_user(self.root).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + f"Root should be able to read access_level={access_level}", + ) + + # Root can write any level + record.with_user(self.root).write( + {"name": f"Root Updated Level {access_level}"} + ) + record.invalidate_recordset() + self.assertEqual( + record.name, + f"Root Updated Level {access_level}", + f"Root should be able to update access_level={access_level}", + ) + + # Test Root can delete records created by other users + manager_record = self.JetTemplate.with_user(self.manager).create( + { + "name": "Manager's Record", + "reference": "managers_record", + "access_level": "2", + "manager_ids": [(4, self.manager.id)], + } + ) + manager_record.with_user(self.root).unlink() + records = self.JetTemplate.with_user(self.root).search( + [("id", "=", manager_record.id)] + ) + self.assertEqual( + len(records), 0, "Root should be able to delete records from any creator" + ) + + # ====================== + # Edge Cases + # ====================== + + def test_access_level_changes_visibility(self): + """Test that changing access_level affects visibility""" + # Create with User level + record = self.JetTemplate.create( + { + "name": "Changing Level", + "reference": "changing_level", + "access_level": "1", + "user_ids": False, + "manager_ids": False, + } + ) + + # User can read + records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) + self.assertEqual(len(records), 1, "User should read level 1") + + # Change to Root level + record.write({"access_level": "3"}) + + # User cannot read anymore + records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) + self.assertEqual(len(records), 0, "User should not read level 3") + + def test_multiple_managers_access(self): + """Test multiple managers accessing the same record""" + record = self.JetTemplate.with_user(self.manager).create( + { + "name": "Multi Manager", + "reference": "multi_manager", + "access_level": "2", + "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], + } + ) + + # Both managers should be able to read + records1 = self.JetTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + records2 = self.JetTemplate.with_user(self.manager2).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records1), 1, "Manager1 should read") + self.assertEqual(len(records2), 1, "Manager2 should read") + + # Both can write + record.with_user(self.manager).write({"name": "Manager1 Update"}) + record.with_user(self.manager2).write({"name": "Manager2 Update"}) + + # Only creator can delete + with self.assertRaises(AccessError): + record.with_user(self.manager2).unlink() + + # Creator can delete + record = self.JetTemplate.with_user(self.manager).create( + { + "name": "Creator Delete", + "reference": "creator_delete", + "access_level": "2", + "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], + } + ) + try: + record.with_user(self.manager).unlink() + except AccessError: + self.fail("Creator should be able to delete") diff --git a/addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py b/addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py new file mode 100644 index 0000000..bbc5769 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py @@ -0,0 +1,195 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetTemplateDependencyAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Template Dependency model + """ + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_access_level_manager(self): + """Test Manager: Read when template access_level is 'Manager' (2)""" + _, _, dependency = self._create_jet_template_dependency( + "Manager Level Template", "manager_level_template", access_level="2" + ) + + records = self.JetTemplateDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual(len(records), 1, "Manager should read when access_level='2'") + + def test_manager_read_access_user_ids(self): + """Test Manager: Read when added to template user_ids""" + _, _, dependency = self._create_jet_template_dependency( + "Manager in Users", + "manager_in_users", + access_level="3", + user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual(len(records), 1, "Manager should read when in user_ids") + + def test_manager_read_access_manager_ids(self): + """Test Manager: Read when added to template manager_ids""" + _, _, dependency = self._create_jet_template_dependency( + "Manager in Managers", + "manager_in_managers", + access_level="3", + manager_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual(len(records), 1, "Manager should read when in manager_ids") + + def test_manager_read_no_access_root_level(self): + """Test Manager: No read access for Root level (3) without user_ids""" + _, _, dependency = self._create_jet_template_dependency( + "Root Level Template", "root_level_template", access_level="3" + ) + + records = self.JetTemplateDependency.with_user(self.manager).search( + [("id", "=", dependency.id)] + ) + self.assertEqual(len(records), 0, "Manager should not read access_level='3'") + + # ====================== + # Manager CRUD Access Tests + # ====================== + + def test_manager_create_access(self): + """ + Test Manager: Create when template access_level <= '2' + AND manager is in template.manager_ids + """ + # Create a template dependency with manager access using helper + try: + _, _, dependency = self._create_jet_template_dependency( + template_name="Create Manager Template", + template_reference="create_manager_template", + access_level="2", + manager_ids=[(4, self.manager.id)], + template_required=self.jet_template_tower_core, + state_required_id=self.state_running.id, + with_user=self.manager, + ) + + # Ensure dependency was created + records = self.JetTemplateDependency.search([("id", "=", dependency.id)]) + self.assertIn( + dependency, records, "Manager should be able to create dependency" + ) + except AccessError: + self.fail("Manager should be able to create template dependency") + + def test_manager_create_forbidden_not_in_manager_ids(self): + """Test Manager: Cannot create when not in template.manager_ids""" + self.assertRaises( + AccessError, + lambda: self.JetTemplateDependency.with_user(self.manager).create( + { + "template_id": self.jet_template_test.id, + "template_required_id": self.jet_template_tower_core.id, + "state_required_id": self.state_running.id, + } + ), + ) + + def test_manager_write_access(self): + """ + Test Manager: Can write when template access_level <= '2' + AND manager is in template.manager_ids. Toggle state_required_id. + """ + # Create dependency with proper access + _, _, dependency = self._create_jet_template_dependency( + template_name="Write Manager Template", + template_reference="write_manager_template", + access_level="2", + manager_ids=[(4, self.manager.id)], + template_required=self.jet_template_tower_core, + state_required_id=self.state_running.id, + with_user=self.manager, + ) + + # Perform an actual write: change state_required_id + try: + dependency.invalidate_recordset() + dependency.with_user(self.manager).write( + {"state_required_id": self.state_stopped.id} + ) + except AccessError: + self.fail("Manager should be able to write state_required_id") + + def test_manager_unlink_access(self): + """ + Test Manager: Can unlink when template access_level <= '2' + AND manager is in template.manager_ids. + """ + # Create dependency with proper access + _, _, dependency = self._create_jet_template_dependency( + template_name="Unlink Manager Template", + template_reference="unlink_manager_template", + access_level="2", + manager_ids=[(4, self.manager.id)], + template_required=self.jet_template_tower_core, + state_required_id=self.state_running.id, + with_user=self.manager, + ) + + dependency.invalidate_recordset() + dependency = dependency.with_user(self.manager) + try: + dependency.unlink() + records = self.JetTemplateDependency.search([("id", "=", dependency.id)]) + self.assertEqual( + len(records), 0, "Manager should be able to unlink dependency" + ) + except AccessError: + self.fail("Manager should be able to unlink dependency") + + # ====================== + # Root Access Tests + # ====================== + + def test_root_full_access(self): + """Root: Full CRUD access regardless of access restrictions""" + # Root can create + _, _, dependency = self._create_jet_template_dependency( + template_name="Root Template", + template_reference="root_template", + access_level="3", + template_required=self.jet_template_tower_core, + state_required_id=self.state_running.id, + with_user=self.root, + ) + + # Root can read + records = self.JetTemplateDependency.with_user(self.root).search( + [("id", "=", dependency.id)] + ) + self.assertIn(dependency, records, "Root should be able to read") + + # Root can write allowed field + dependency.invalidate_recordset() + dependency.with_user(self.root).write( + {"state_required_id": self.state_running.id} + ) + + # Root can delete + dependency.with_user(self.root).unlink() + records = self.JetTemplateDependency.with_user(self.root).search( + [("id", "=", dependency.id)] + ) + self.assertEqual(len(records), 0, "Root should be able to delete dependency") diff --git a/addons/cetmix_tower_server/tests/test_jet_template_install.py b/addons/cetmix_tower_server/tests/test_jet_template_install.py new file mode 100644 index 0000000..2118e40 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_template_install.py @@ -0,0 +1,1777 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import ValidationError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetTemplateInstall(TestTowerJetsCommon): + """ + Test the cx.tower.jet.template.install model methods + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional servers for testing + cls.server_test_2 = cls.Server.create( + { + "name": "Test Server 2", + "reference": "test_server_2", + "ip_v4_address": "192.168.1.102", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + cls.server_test_3 = cls.Server.create( + { + "name": "Test Server 3", + "reference": "test_server_3", + "ip_v4_address": "192.168.1.103", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + + def test_uninstall_creates_install_record(self): + """Test that uninstall creates a new install record with correct data""" + server = self.server_test_1 + template = self.jet_template_test + + # Create a dummy record to satisfy ensure_one() + # Note: This is a workaround for ensure_one() in @api.model method + dummy_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + } + ) + + # Call uninstall on the dummy record + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall._process_install" + ) as mock_process: + install_record = dummy_record.uninstall(server, template) + + # Verify install record was created + self.assertTrue(install_record, "Should return an install record") + self.assertEqual( + install_record.jet_template_id, + template, + "Install record should reference the template", + ) + self.assertEqual( + install_record.server_id, + server, + "Install record should reference the server", + ) + self.assertEqual( + install_record.action, + "uninstall", + "Install record action should be 'uninstall'", + ) + self.assertEqual( + install_record.state, + "processing", + "Install record state should be 'processing'", + ) + + # Verify line_ids contains only the template (no dependencies) + self.assertEqual( + len(install_record.line_ids), + 1, + "Should have exactly one line for uninstall", + ) + line = install_record.line_ids[0] + self.assertEqual( + line.jet_template_id, + template, + "Line should reference the template", + ) + self.assertEqual(line.order, 0, "Line order should be 0") + + # Verify _process_install was called + mock_process.assert_called_once() + + def test_uninstall_creates_notification(self): + """Test that uninstall sends a notification to the user""" + server = self.server_test_1 + template = self.jet_template_test + + # Create a dummy record to satisfy ensure_one() + dummy_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + } + ) + + # Mock notify_info to verify it's called + with ( + patch.object(self.env.user.__class__, "notify_info") as mock_notify, + patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall._process_install" + ), + ): + dummy_record.uninstall(server, template) + + # Verify notify_info was called + self.assertEqual(mock_notify.call_count, 1, "Should call notify_info once") + + # Verify notification parameters + call_args = mock_notify.call_args + self.assertIn("message", call_args.kwargs, "Should have message") + self.assertIn("title", call_args.kwargs, "Should have title") + self.assertEqual( + call_args.kwargs["title"], + template.name, + "Notification title should be template name", + ) + self.assertEqual( + call_args.kwargs["sticky"], + False, + "Notification should not be sticky", + ) + self.assertIn("action", call_args.kwargs, "Should have action") + + def test_uninstall_different_template(self): + """Test uninstall with a different template""" + server = self.server_test_1 + template = self.jet_template_odoo + + # Create a dummy record to satisfy ensure_one() + dummy_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "action": "install", + } + ) + + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall._process_install" + ): + install_record = dummy_record.uninstall(server, template) + + self.assertEqual( + install_record.jet_template_id, + template, + "Should uninstall the specified template", + ) + self.assertEqual( + install_record.server_id, + server, + "Should uninstall on the specified server", + ) + + def test_uninstall_different_server(self): + """Test uninstall with a different server""" + server = self.server_test_2 + template = self.jet_template_test + + # Create a dummy record to satisfy ensure_one() + dummy_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": self.server_test_1.id, + "action": "install", + } + ) + + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall._process_install" + ): + install_record = dummy_record.uninstall(server, template) + + self.assertEqual( + install_record.server_id, + server, + "Should uninstall on the specified server", + ) + + def test_uninstall_removes_template_from_server_ids(self): + """Test that successful uninstallation removes template from server_ids""" + server = self.server_test_1 + template = self.jet_template_test + + # First, add template to server_ids to simulate installed state + template.write({"server_ids": [(4, server.id)]}) + self.assertIn( + server.id, + template.server_ids.ids, + "Template should be in server_ids before uninstall", + ) + + # Create uninstall record + uninstall_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "uninstall", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Process uninstallation (without flight plan - direct completion) + # This simulates the case where there's no flight plan + uninstall_record.line_ids[0].write({"state": "to_process"}) + uninstall_record.with_context(cetmix_tower_no_commit=True)._process_install() + + # Verify template was removed from server_ids + template.invalidate_recordset(["server_ids"]) + self.assertNotIn( + server.id, + template.server_ids.ids, + "Template should be removed from server_ids after successful uninstall", + ) + + def test_uninstall_does_not_remove_template_on_failure(self): + """Test that template is not removed from server_ids if uninstallation fails""" + server = self.server_test_1 + template = self.jet_template_test + + # First, add template to server_ids to simulate installed state + template.write({"server_ids": [(4, server.id)]}) + self.assertIn( + server.id, + template.server_ids.ids, + "Template should be in server_ids before uninstall", + ) + + # Create uninstall record with a line + uninstall_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "uninstall", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id to simulate flight plan execution + uninstall_record.write({"current_line_id": uninstall_record.line_ids[0].id}) + + # Simulate flight plan finishing with failure (exit code != 0) + uninstall_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(1) + + # Verify template is still in server_ids (not removed on failure) + template.invalidate_recordset(["server_ids"]) + self.assertIn( + server.id, + template.server_ids.ids, + "Template should remain in server_ids after uninstall failure", + ) + + # ====================== + # Tests for _flight_plan_finished + # ====================== + + def test_flight_plan_finished_success_install_adds_template_to_server_ids(self): + """Test that successful install flight plan adds template to server_ids""" + server = self.server_test_1 + template = self.jet_template_test + + # Ensure template is not in server_ids initially + template.write({"server_ids": [(5, 0, 0)]}) + self.assertNotIn( + server.id, + template.server_ids.ids, + "Template should not be in server_ids before install", + ) + + # Create install record with a line + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "processing", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id to simulate flight plan execution + current_line = install_record.line_ids[0] + install_record.write({"current_line_id": current_line.id}) + + # Simulate flight plan finishing successfully (exit code 0) + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall._process_install" + ) as mock_process: + install_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(0) + + # Verify template was added to server_ids + template.invalidate_recordset(["server_ids"]) + self.assertIn( + server.id, + template.server_ids.ids, + "Template should be added to server_ids after install success", + ) + + # Verify current line was marked as done (check before clearing) + current_line.invalidate_recordset(["state"]) + self.assertEqual( + current_line.state, + "done", + "Current line should be marked as done", + ) + + # Verify current_line_id was cleared + install_record.invalidate_recordset(["current_line_id"]) + self.assertFalse( + install_record.current_line_id, + "current_line_id should be cleared after success", + ) + + # Verify _process_install was called to continue processing + mock_process.assert_called_once() + + def test_flight_plan_finished_success_uninstall_removes_template_from_server_ids( + self, + ): + """ + Test that successful uninstall flight plan + removes template from server_ids + """ + server = self.server_test_1 + template = self.jet_template_test + + # Add template to server_ids to simulate installed state + template.write({"server_ids": [(4, server.id)]}) + self.assertIn( + server.id, + template.server_ids.ids, + "Template should be in server_ids before uninstall", + ) + + # Create uninstall record with a line + uninstall_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "uninstall", + "state": "processing", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id to simulate flight plan execution + current_line = uninstall_record.line_ids[0] + uninstall_record.write({"current_line_id": current_line.id}) + + # Simulate flight plan finishing successfully (exit code 0) + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall._process_install" + ) as mock_process: + uninstall_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(0) + + # Verify template was removed from server_ids + template.invalidate_recordset(["server_ids"]) + self.assertNotIn( + server.id, + template.server_ids.ids, + "Template should be removed from server_ids after uninstall success", + ) + + # Verify current line was marked as done (check before clearing) + current_line.invalidate_recordset(["state"]) + self.assertEqual( + current_line.state, + "done", + "Current line should be marked as done", + ) + + # Verify current_line_id was cleared + uninstall_record.invalidate_recordset(["current_line_id"]) + self.assertFalse( + uninstall_record.current_line_id, + "current_line_id should be cleared after success", + ) + + # Verify _process_install was called to continue processing + mock_process.assert_called_once() + + def test_flight_plan_finished_failure_marks_line_as_failed(self): + """Test that failed flight plan marks current line as failed""" + server = self.server_test_1 + template = self.jet_template_test + + # Create install record with a line + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "processing", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id to simulate flight plan execution + current_line = install_record.line_ids[0] + install_record.write({"current_line_id": current_line.id}) + + # Simulate flight plan finishing with failure (exit code != 0) + install_record.with_context(cetmix_tower_no_commit=True)._flight_plan_finished( + 1 + ) + + # Verify current line was marked as failed + self.assertEqual( + current_line.state, + "failed", + "Current line should be marked as failed", + ) + + # Verify install record state was set to failed + self.assertEqual( + install_record.state, + "failed", + "Install record state should be 'failed'", + ) + + # Verify date_done was set + self.assertTrue( + install_record.date_done, + "date_done should be set on failure", + ) + + # Verify current_line_id was cleared + self.assertFalse( + install_record.current_line_id, + "current_line_id should be cleared after failure", + ) + + def test_flight_plan_finished_failure_marks_all_to_process_lines_as_failed(self): + """Test that failed flight plan marks all 'to_process' lines as failed""" + server = self.server_test_1 + template = self.jet_template_test + + # Create install record with multiple lines + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "processing", + "line_ids": [ + (0, 0, {"jet_template_id": template.id, "order": 0}), + (0, 0, {"jet_template_id": template.id, "order": 1}), + (0, 0, {"jet_template_id": template.id, "order": 2}), + ], + } + ) + + # Set first line as current and mark others as to_process + current_line = install_record.line_ids[0] + other_lines = install_record.line_ids[1:] + install_record.write({"current_line_id": current_line.id}) + other_lines.write({"state": "to_process"}) + + # Simulate flight plan finishing with failure + install_record.with_context(cetmix_tower_no_commit=True)._flight_plan_finished( + 1 + ) + + # Verify all 'to_process' lines were marked as failed + for line in other_lines: + self.assertEqual( + line.state, + "failed", + "All 'to_process' lines should be marked as failed", + ) + + def test_flight_plan_finished_failure_sends_notification(self): + """Test that failed flight plan sends error notification when enabled""" + server = self.server_test_1 + template = self.jet_template_test + + # Enable error notifications + self.env["ir.config_parameter"].sudo().set_param( + "cetmix_tower_server.notification_type_error", "sticky" + ) + + # Create install record with a line + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "processing", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id to simulate flight plan execution + install_record.write({"current_line_id": install_record.line_ids[0].id}) + + # Mock notify_danger to verify it's called + with patch.object(self.env.user.__class__, "notify_danger") as mock_notify: + install_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(1) + + # Verify notify_danger was called + self.assertEqual( + mock_notify.call_count, 1, "Should call notify_danger once" + ) + + # Verify notification parameters + call_args = mock_notify.call_args + self.assertIn("message", call_args.kwargs, "Should have message") + self.assertIn("title", call_args.kwargs, "Should have title") + self.assertEqual( + call_args.kwargs["title"], + template.name, + "Notification title should be template name", + ) + self.assertEqual( + call_args.kwargs["sticky"], + True, + "Notification should be sticky when configured", + ) + self.assertIn("action", call_args.kwargs, "Should have action") + + def test_flight_plan_finished_no_notification_when_disabled(self): + """Test that failed flight plan doesn't send notification when disabled""" + server = self.server_test_1 + template = self.jet_template_test + + # Disable error notifications + self.env["ir.config_parameter"].sudo().set_param( + "cetmix_tower_server.notification_type_error", False + ) + + # Create install record with a line + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "processing", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id to simulate flight plan execution + install_record.write({"current_line_id": install_record.line_ids[0].id}) + + # Mock notify_danger to verify it's NOT called + with patch.object(self.env.user.__class__, "notify_danger") as mock_notify: + install_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(1) + + # Verify notify_danger was NOT called + mock_notify.assert_not_called() + + def test_flight_plan_finished_no_current_line_id_returns_early(self): + """Test that _flight_plan_finished returns early if no current_line_id""" + server = self.server_test_1 + template = self.jet_template_test + + # Create install record without current_line_id + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "processing", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Ensure current_line_id is False + self.assertFalse(install_record.current_line_id) + + # Mock logger to verify warning is logged + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install._logger.warning" + ) as mock_warning: + install_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(0) + + # Verify warning was logged + mock_warning.assert_called_once() + + # Verify template was not modified (early return) + template.invalidate_recordset(["server_ids"]) + self.assertNotIn( + server.id, + template.server_ids.ids, + "Template should not be modified when no current_line_id", + ) + + def test_flight_plan_finished_wrong_state_returns_early(self): + """Test that _flight_plan_finished returns early if state is not 'processing'""" + server = self.server_test_1 + template = self.jet_template_test + + # Create install record in 'done' state + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "action": "install", + "state": "done", + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + } + ) + + # Set current_line_id + install_record.write({"current_line_id": install_record.line_ids[0].id}) + + # Mock logger to verify warning is logged + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install._logger.warning" + ) as mock_warning: + install_record.with_context( + cetmix_tower_no_commit=True + )._flight_plan_finished(0) + + # Verify warning was logged + mock_warning.assert_called_once() + + # Verify template was not modified (early return) + template.invalidate_recordset(["server_ids"]) + self.assertNotIn( + server.id, + template.server_ids.ids, + "Template should not be modified when state is not 'processing'", + ) + + # ====================== + # Tests for _is_installation_needed (from JetTemplate model) + # ====================== + + def test_is_installation_needed_server_already_installed(self): + """Test _is_installation_needed when server is already installed""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Add server to template's installed servers + self.jet_template_test.server_ids = [(4, server.id)] + + result = self.jet_template_test._is_installation_needed(server) + self.assertFalse(result, "Should return False when server is already installed") + + def test_is_installation_needed_installation_in_progress_processing(self): + """Test _is_installation_needed when installation is in processing state""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Create an installation record in processing state + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "processing", + } + ) + + # Create install line + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "processing", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertFalse( + result, "Should return False when installation is in processing state" + ) + + def test_is_installation_needed_installation_in_progress_to_process(self): + """Test _is_installation_needed when installation is in to_process state""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Create an installation record in to_process state + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "processing", + } + ) + + # Create install line + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "to_process", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertFalse( + result, "Should return False when installation is in to_process state" + ) + + def test_is_installation_needed_installation_completed(self): + """Test _is_installation_needed when installation is completed""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Create an installation record in installed state + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "done", + } + ) + + # Create install line + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "done", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertTrue( + result, + "Should return True when installation is completed (not in progress)", + ) + + def test_is_installation_needed_installation_failed(self): + """Test _is_installation_needed when installation failed""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Create an installation record in failed state + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "failed", + } + ) + + # Create install line + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "failed", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertTrue(result, "Should return True when installation failed") + + def test_is_installation_needed_multiple_installations(self): + """Test _is_installation_needed with multiple installation records""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Create multiple installation records + install_record1 = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "done", + } + ) + + install_record2 = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "processing", + } + ) + + # Create install lines + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record1.id, + "jet_template_id": self.jet_template_test.id, + "state": "done", + } + ) + + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record2.id, + "jet_template_id": self.jet_template_test.id, + "state": "processing", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertFalse( + result, "Should return False when any installation is in progress" + ) + + def test_is_installation_needed_different_servers(self): + """Test _is_installation_needed with different servers""" + # pylint: disable=protected-access + # Create two servers + server1 = self.Server.create( + { + "name": "Test Server 1", + "reference": "test_server_1", + "ip_v4_address": "192.168.1.101", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + server2 = self.Server.create( + { + "name": "Test Server 2", + "reference": "test_server_2", + "ip_v4_address": "192.168.1.102", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Add server1 to template's installed servers + self.jet_template_test.server_ids = [(4, server1.id)] + + # Create installation record for server2 + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server2.id, + "state": "processing", + } + ) + + # Create install line + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "processing", + } + ) + + # Check server1 (already installed) + result1 = self.jet_template_test._is_installation_needed(server1) + self.assertFalse(result1, "Should return False for server1 (already installed)") + + # Check server2 (installation in progress) + result2 = self.jet_template_test._is_installation_needed(server2) + self.assertFalse( + result2, "Should return False for server2 (installation in progress)" + ) + + def test_is_installation_needed_no_installations(self): + """Test _is_installation_needed when no installation records exist""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertTrue(result, "Should return True when no installation records exist") + + def test_is_installation_needed_mixed_states(self): + """Test _is_installation_needed with mixed installation states""" + # pylint: disable=protected-access + # Create a server + server = self.Server.create( + { + "name": "Test Server", + "reference": "test_server", + "ip_v4_address": "192.168.1.100", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + + # Create installation records with different states + install_record1 = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "done", + } + ) + + install_record2 = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "failed", + } + ) + + # Create install lines + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record1.id, + "jet_template_id": self.jet_template_test.id, + "state": "done", + } + ) + + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record2.id, + "jet_template_id": self.jet_template_test.id, + "state": "failed", + } + ) + + result = self.jet_template_test._is_installation_needed(server) + self.assertTrue( + result, "Should return True when all installations are completed or failed" + ) + + # ====================== + # Tests for install_on_servers (from JetTemplate model) + # ====================== + + def test_install_on_servers_no_dependencies(self): + """Test install_on_servers with template that has no dependencies""" + # pylint: disable=protected-access + # Use existing server from common.py + server = self.server_test_1 + + # Call install method directly with cetmix_tower_no_commit context + self.jet_template_test.with_context( + cetmix_tower_no_commit=True + ).install_on_servers(server) + + # Verify installation record was created + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_test.id), + ("server_id", "=", server.id), + ] + ) + self.assertEqual( + len(install_records), 1, "Should create exactly one installation record" + ) + + def test_install_on_servers_already_installed(self): + """Test install_on_servers when template is already installed""" + # pylint: disable=protected-access + # Use existing server from common.py + server = self.server_test_1 + + # Add server to template's installed servers + self.jet_template_test.server_ids = [(4, server.id)] + + # Call install method - should skip since already installed + self.jet_template_test.with_context( + cetmix_tower_no_commit=True + ).install_on_servers(server) + + # Verify no new installation record was created + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_test.id), + ("server_id", "=", server.id), + ] + ) + self.assertEqual( + len(install_records), + 0, + "Should not create installation record when already installed", + ) + + def test_install_on_servers_installation_in_progress(self): + """Test install_on_servers when installation is already in progress""" + # pylint: disable=protected-access + # Use existing server from common.py + server = self.server_test_1 + + # Create installation record in progress + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server.id, + "state": "processing", + } + ) + + # Create install line + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "processing", + } + ) + + # Call install method - should skip since installation in progress + self.jet_template_test.with_context( + cetmix_tower_no_commit=True + ).install_on_servers(server) + + # Verify no additional installation record was created + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_test.id), + ("server_id", "=", server.id), + ] + ) + self.assertEqual( + len(install_records), + 1, + "Should not create additional installation record", + ) + + def test_install_on_servers_dependency_satisfaction(self): + """Test install_on_servers dependency satisfaction logic""" + # pylint: disable=protected-access + # Use class-level dependency hierarchy + # Use existing server from common.py + server = self.server_test_1 + + # Install Tower Core on server + self.jet_template_tower_core.server_ids = [(4, server.id)] + + # Call install method directly + self.jet_template_postgres.with_context( + cetmix_tower_no_commit=True + ).install_on_servers(server) + + # Verify installation record was created + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_postgres.id), + ("server_id", "=", server.id), + ] + ) + self.assertEqual( + len(install_records), 1, "Should create exactly one installation record" + ) + + def test_install_on_servers_multiple_servers(self): + """Test install_on_servers with multiple servers""" + # pylint: disable=protected-access + # Use existing servers from class setup + server1 = self.server_test_1 + server2 = self.server_test_2 + + # Add server1 to template's installed servers + self.jet_template_test.server_ids = [(4, server1.id)] + + # Call install method directly + self.jet_template_test.with_context( + cetmix_tower_no_commit=True + ).install_on_servers([server1, server2]) + + # Verify installation record was created only for server2 + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_test.id), + ("server_id", "=", server2.id), + ] + ) + self.assertEqual( + len(install_records), 1, "Should create installation record for server2" + ) + + # Verify no installation record for server1 (already installed) + install_records_server1 = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_test.id), + ("server_id", "=", server1.id), + ] + ) + self.assertEqual( + len(install_records_server1), + 0, + "Should not create installation record for server1 (already installed)", + ) + + def test_install_on_servers_empty_server_list(self): + """Test install_on_servers with empty server list""" + # pylint: disable=protected-access + # Call install method with empty list + self.jet_template_test.with_context( + cetmix_tower_no_commit=True + ).install_on_servers([]) + + # Verify no installation record was created + install_records = self.JetTemplateInstall.search( + [("jet_template_id", "=", self.jet_template_test.id)] + ) + self.assertEqual( + len(install_records), + 0, + "Should not create installation record with empty server list", + ) + + def test_install_on_servers_mixed_server_states(self): + """Test install_on_servers with mixed server states""" + # Use existing servers from class setup + server1 = self.server_test_1 + server2 = self.server_test_2 + server3 = self.server_test_3 + + # Server1: Already installed + self.jet_template_test.server_ids = [(4, server1.id)] + + # Server2: Installation in progress + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": self.jet_template_test.id, + "server_id": server2.id, + "state": "processing", + } + ) + self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": self.jet_template_test.id, + "state": "processing", + } + ) + + # Server3: Not installed (should trigger installation) + + # Call install method directly + self.jet_template_test.with_context( + cetmix_tower_no_commit=True + ).install_on_servers([server1, server2, server3]) + + # Verify installation record was created only for server3 + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_test.id), + ("server_id", "=", server3.id), + ] + ) + self.assertEqual( + len(install_records), 1, "Should create installation record for server3" + ) + + def test_install_on_servers_odoo_scenario_complete_installation(self): + """Test complete Odoo installation scenario""" + # Use class-level dependency hierarchy + # Use existing server from common.py + server = self.server_test_1 + + # Call install for Odoo template + self.jet_template_odoo.with_context( + cetmix_tower_no_commit=True + ).install_on_servers(server) + + # Verify installation log is created + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_odoo.id), + ("server_id", "=", server.id), + ] + ) + self.assertEqual( + len(install_records), 1, "Should create exactly one installation record" + ) + + install_record = install_records[0] + self.assertEqual( + install_record.jet_template_id, + self.jet_template_odoo, + "Installation should be for Odoo template", + ) + self.assertEqual( + install_record.server_id, server, "Installation should be on test server" + ) + + # Verify all dependencies are in installation log lines + install_lines = install_record.line_ids.sorted("order") + self.assertEqual( + len(install_lines), + 5, + "Should have 5 installation lines (Odoo + 4 dependencies)", + ) + + # Verify all expected templates are included + template_ids = install_lines.mapped("jet_template_id.id") + expected_template_ids = [ + self.jet_template_tower_core.id, + self.jet_template_docker.id, + self.jet_template_postgres.id, + self.jet_template_nginx.id, + self.jet_template_odoo.id, + ] + self.assertEqual( + set(template_ids), + set(expected_template_ids), + "All expected templates should be in installation lines", + ) + + # Verify correct order: Odoo first, then Nginx/Postgres (either order), + # then Docker, then Tower Core. + odoo_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_odoo + ) + self.assertEqual(odoo_line.order, 0, "Odoo should be first (order 0)") + + # Verify dependency relationships are correct + # Odoo should be first (main template) + odoo_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_odoo + ) + self.assertEqual(len(odoo_line), 1, "Should have exactly one Odoo line") + self.assertEqual(odoo_line.order, 0, "Odoo should be first (order 0)") + + # Nginx and Postgres should be second and third (direct dependencies of Odoo) + nginx_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_nginx + ) + postgres_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_postgres + ) + self.assertEqual(len(nginx_line), 1, "Should have exactly one Nginx line") + self.assertEqual(len(postgres_line), 1, "Should have exactly one Postgres line") + self.assertIn(nginx_line.order, [1, 2], "Nginx should be order 1 or 2") + self.assertIn(postgres_line.order, [1, 2], "Postgres should be order 1 or 2") + self.assertNotEqual( + nginx_line.order, + postgres_line.order, + "Nginx and Postgres should have different orders", + ) + + # Docker should be fourth (dependency of both Postgres and Nginx) + docker_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_docker + ) + self.assertEqual(len(docker_line), 1, "Should have exactly one Docker line") + self.assertEqual(docker_line.order, 3, "Docker should be fourth (order 3)") + + # Tower Core should be last (dependency of Docker) + tower_core_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_tower_core + ) + self.assertEqual( + len(tower_core_line), 1, "Should have exactly one Tower Core line" + ) + self.assertEqual( + tower_core_line.order, 4, "Tower Core should be last (order 4)" + ) + + def test_install_on_servers_woocommerce_odoo_scenario(self): + """Test install_on_servers with WooCommerce with Odoo scenario""" + # pylint: disable=protected-access + # Use existing server from common.py + server = self.server_test_1 + + # Call install for WooCommerce with Odoo template + self.jet_template_woocommerce_odoo.with_context( + cetmix_tower_no_commit=True + ).install_on_servers(server) + + # Verify installation log is created + install_records = self.JetTemplateInstall.search( + [ + ("jet_template_id", "=", self.jet_template_woocommerce_odoo.id), + ("server_id", "=", server.id), + ] + ) + self.assertEqual( + len(install_records), 1, "Should create exactly one installation record" + ) + + install_record = install_records[0] + self.assertEqual( + install_record.jet_template_id, + self.jet_template_woocommerce_odoo, + "Installation should be for WooCommerce with Odoo template", + ) + self.assertEqual( + install_record.server_id, server, "Installation should be on test server" + ) + + # Verify all dependencies are in installation log lines + install_lines = install_record.line_ids.sorted("order") + # Should have 8 installation lines: + # WooCommerce + 7 dependencies + # WordPress, Odoo, MariaDB, Postgres, Nginx, Docker, Tower Core + self.assertEqual( + len(install_lines), + 8, + "Should have 8 installation lines (WooCommerce + 7 dependencies)", + ) + + # Verify topological constraints: + # WooCommerce first (root), Tower Core last (deepest leaf), + # Docker before Nginx/Postgres/MariaDB, etc. + wc_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_woocommerce_odoo + ) + self.assertEqual(wc_line.order, 0, "WooCommerce should be first (order 0)") + + tc_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_tower_core + ) + self.assertEqual(tc_line.order, 7, "Tower Core should be last (order 7)") + + docker_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_docker + ) + nginx_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_nginx + ) + postgres_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_postgres + ) + mariadb_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_mariadb + ) + odoo_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_odoo + ) + wp_line = install_lines.filtered( + lambda line: line.jet_template_id == self.jet_template_wordpress + ) + + self.assertGreater( + tc_line.order, + docker_line.order, + "Tower Core must have higher order than Docker (installed first)", + ) + self.assertGreater( + docker_line.order, + nginx_line.order, + "Docker must have higher order than Nginx (installed first)", + ) + self.assertGreater( + docker_line.order, + postgres_line.order, + "Docker must have higher order than Postgres (installed first)", + ) + self.assertGreater( + docker_line.order, + mariadb_line.order, + "Docker must have higher order than MariaDB (installed first)", + ) + self.assertGreater( + nginx_line.order, + odoo_line.order, + "Nginx must have higher order than Odoo (installed first)", + ) + self.assertGreater( + postgres_line.order, + odoo_line.order, + "Postgres must have higher order than Odoo (installed first)", + ) + self.assertGreater( + nginx_line.order, + wp_line.order, + "Nginx must have higher order than WordPress (installed first)", + ) + self.assertGreater( + mariadb_line.order, + wp_line.order, + "MariaDB must have higher order than WordPress (installed first)", + ) + + # Verify all expected templates are included + template_ids = install_lines.mapped("jet_template_id.id") + expected_template_ids = [ + self.jet_template_tower_core.id, + self.jet_template_docker.id, + self.jet_template_mariadb.id, + self.jet_template_postgres.id, + self.jet_template_nginx.id, + self.jet_template_wordpress.id, + self.jet_template_odoo.id, + self.jet_template_woocommerce_odoo.id, + ] + self.assertEqual( + set(template_ids), + set(expected_template_ids), + "All expected templates should be in installation lines", + ) + + # ====================== + # Tests for uninstall_from_servers (from JetTemplate model) + # ====================== + + def test_uninstall_from_servers_template_not_installed(self): + """Test uninstall_from_servers when template is not installed""" + server = self.server_test_1 + template = self.jet_template_test + + # Ensure template is not installed + template.write({"server_ids": [(5, 0, 0)]}) + + # Should raise ValidationError when raise_if_not_possible=True + with self.assertRaises(ValidationError) as context: + template.uninstall_from_servers(server, raise_if_not_possible=True) + + error_message = str(context.exception) + self.assertIn("not installed", error_message.lower()) + self.assertIn(template.name, error_message) + self.assertIn(server.name, error_message) + + def test_uninstall_from_servers_template_not_installed_warning(self): + """Test uninstall_from_servers shows warning when template is not installed""" + server = self.server_test_1 + template = self.jet_template_test + + # Ensure template is not installed + template.write({"server_ids": [(5, 0, 0)]}) + + # Mock notify_warning to verify it's called + with patch.object(self.env.user.__class__, "notify_warning") as mock_notify: + template.uninstall_from_servers(server, raise_if_not_possible=False) + + # Verify notify_warning was called + mock_notify.assert_called_once() + call_args = mock_notify.call_args + self.assertIn("message", call_args.kwargs) + self.assertIn("not installed", call_args.kwargs["message"].lower()) + + def test_uninstall_from_servers_jets_still_exist(self): + """Test uninstall_from_servers when jets still exist on server""" + server = self.server_test_1 + template = self.jet_template_test + + # Install template on server + template.write({"server_ids": [(4, server.id)]}) + + # Create a jet on the server + self.Jet.create( + { + "name": "Test Jet Uninstall Still Exist", + "reference": "test_jet_uninstall_still_exist", + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + # Should raise ValidationError when raise_if_not_possible=True + with self.assertRaises(ValidationError) as context: + template.uninstall_from_servers(server, raise_if_not_possible=True) + + error_message = str(context.exception) + self.assertIn("still jets", error_message.lower()) + self.assertIn(template.name, error_message) + self.assertIn(server.name, error_message) + + def test_uninstall_from_servers_jets_still_exist_warning(self): + """Test uninstall_from_servers shows warning when jets still exist""" + server = self.server_test_1 + template = self.jet_template_test + + # Install template on server + template.write({"server_ids": [(4, server.id)]}) + + # Create a jet on the server + self.Jet.create( + { + "name": "Test Jet Uninstall Still Exist Warning", + "reference": "test_jet_uninstall_still_exist_warning", + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + # Mock notify_warning to verify it's called + with patch.object(self.env.user.__class__, "notify_warning") as mock_notify: + template.uninstall_from_servers(server, raise_if_not_possible=False) + + # Verify notify_warning was called + mock_notify.assert_called_once() + call_args = mock_notify.call_args + self.assertIn("message", call_args.kwargs) + self.assertIn("still jets", call_args.kwargs["message"].lower()) + + def test_uninstall_from_servers_dependent_templates_installed(self): + """Test uninstall_from_servers when dependent templates are installed""" + server = self.server_test_1 + # Use postgres template which depends on docker + base_template = self.jet_template_docker + dependent_template = self.jet_template_postgres + + # Install both templates on server + base_template.write({"server_ids": [(4, server.id)]}) + dependent_template.write({"server_ids": [(4, server.id)]}) + + # Verify dependency exists + self.assertTrue( + dependent_template.template_requires_ids.filtered( + lambda dep: dep.template_required_id == base_template + ), + "Postgres should depend on Docker", + ) + + # Should raise ValidationError when raise_if_not_possible=True + with self.assertRaises(ValidationError) as context: + base_template.uninstall_from_servers(server, raise_if_not_possible=True) + + error_message = str(context.exception) + self.assertIn("depend", error_message.lower()) + self.assertIn(base_template.name, error_message) + self.assertIn(server.name, error_message) + + def test_uninstall_from_servers_dependent_templates_installed_warning(self): + """ + Test uninstall_from_servers shows warning + when dependent templates are installed + """ + server = self.server_test_1 + # Use postgres template which depends on docker + base_template = self.jet_template_docker + dependent_template = self.jet_template_postgres + + # Install both templates on server + base_template.write({"server_ids": [(4, server.id)]}) + dependent_template.write({"server_ids": [(4, server.id)]}) + + # Mock notify_warning to verify it's called + with patch.object(self.env.user.__class__, "notify_warning") as mock_notify: + base_template.uninstall_from_servers(server, raise_if_not_possible=False) + + # Verify notify_warning was called + mock_notify.assert_called_once() + call_args = mock_notify.call_args + self.assertIn("message", call_args.kwargs) + self.assertIn("depend", call_args.kwargs["message"].lower()) + + def test_uninstall_from_servers_dependent_templates_not_installed(self): + """ + Test uninstall_from_servers succeeds + when dependent templates are not installed + """ + server = self.server_test_1 + # Use docker template + base_template = self.jet_template_docker + + # Install only base template on server (not the dependent one) + base_template.write({"server_ids": [(4, server.id)]}) + + # Mock uninstall to verify it's called + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall.uninstall" + ) as mock_uninstall: + base_template.uninstall_from_servers(server, raise_if_not_possible=True) + + # Verify uninstall was called + mock_uninstall.assert_called_once_with( + server=server, template=base_template + ) + + def test_uninstall_from_servers_success(self): + """Test successful uninstall_from_servers""" + server = self.server_test_1 + template = self.jet_template_test + + # Clean up any existing jets for this template/server combination + existing_jets = server.jet_ids.filtered( + lambda jet: jet.jet_template_id == template + ) + if existing_jets: + existing_jets.unlink() + + # Install template on server + template.write({"server_ids": [(4, server.id)]}) + + # Ensure no jets exist + self.assertFalse( + server.jet_ids.filtered(lambda jet: jet.jet_template_id == template), + "No jets should exist for this template", + ) + + # Mock uninstall to verify it's called + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall.uninstall" + ) as mock_uninstall: + template.uninstall_from_servers(server, raise_if_not_possible=True) + + # Verify uninstall was called + mock_uninstall.assert_called_once_with(server=server, template=template) + + def test_uninstall_from_servers_multiple_servers(self): + """Test uninstall_from_servers with multiple servers""" + server1 = self.server_test_1 + server2 = self.server_test_2 + template = self.jet_template_test + + # Clean up any existing jets for this template on both servers + existing_jets_1 = server1.jet_ids.filtered( + lambda jet: jet.jet_template_id == template + ) + if existing_jets_1: + existing_jets_1.unlink() + existing_jets_2 = server2.jet_ids.filtered( + lambda jet: jet.jet_template_id == template + ) + if existing_jets_2: + existing_jets_2.unlink() + + # Ensure no dependent templates are installed on these servers + # Remove any templates that depend on this template from both servers + for server in [server1, server2]: + dependent_templates = server.jet_template_ids.filtered( + lambda t: t.template_requires_ids.filtered( + lambda dep: dep.template_required_id == template + ) + ) + if dependent_templates: + # Remove server from dependent template's server_ids + for dep_template in dependent_templates: + dep_template.write({"server_ids": [(3, server.id)]}) + + # Install template on both servers + template.write({"server_ids": [(4, server1.id), (4, server2.id)]}) + + # Mock uninstall to verify it's called for both servers + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall.uninstall" + ) as mock_uninstall: + template.uninstall_from_servers( + [server1, server2], raise_if_not_possible=True + ) + + # Verify uninstall was called twice (once per server) + self.assertEqual(mock_uninstall.call_count, 2) + # Verify both servers were called + call_args_list = mock_uninstall.call_args_list + servers_called = [call[1]["server"] for call in call_args_list] + self.assertIn(server1, servers_called) + self.assertIn(server2, servers_called) + + def test_uninstall_from_servers_mixed_validation_states(self): + """Test uninstall_from_servers with mixed server validation states""" + server1 = self.server_test_1 + server2 = self.server_test_2 + server3 = self.server_test_3 + template = self.jet_template_test + + # Server1: Template not installed + template.write({"server_ids": [(5, 0, 0)]}) + + # Server2: Jets still exist + template.write({"server_ids": [(4, server2.id)]}) + self.Jet.create( + { + "name": "Test Jet Mixed Validation Server2", + "reference": "test_jet_mixed_validation_server2", + "jet_template_id": template.id, + "server_id": server2.id, + } + ) + + # Server3: Valid for uninstallation + template.write({"server_ids": [(4, server3.id)]}) + + # Mock uninstall and notify_warning + with ( + patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" + ".CxTowerJetTemplateInstall.uninstall" + ) as mock_uninstall, + patch.object(self.env.user.__class__, "notify_warning") as mock_notify, + ): + template.uninstall_from_servers( + [server1, server2, server3], raise_if_not_possible=False + ) + + # Verify warnings were shown for server1 and server2 + self.assertEqual(mock_notify.call_count, 2) + + # Verify uninstall was called only for server3 + mock_uninstall.assert_called_once_with(server=server3, template=template) diff --git a/addons/cetmix_tower_server/tests/test_jet_template_install_access.py b/addons/cetmix_tower_server/tests/test_jet_template_install_access.py new file mode 100644 index 0000000..3cc9635 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_template_install_access.py @@ -0,0 +1,387 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetTemplateInstallAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Template Install model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional server for testing + cls.server_test_2 = cls.Server.create( + { + "name": "Test Server 2", + "reference": "test_server_2", + "ip_v4_address": "192.168.1.102", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + + def _create_install_record( + self, + template=None, + server=None, + template_access_level="2", + template_user_ids=None, + template_manager_ids=None, + server_user_ids=None, + server_manager_ids=None, + ): + """Helper method to create a jet template install record""" + if not template: + template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "access_level": template_access_level, + "user_ids": template_user_ids + if template_user_ids is not None + else [(5, 0, 0)], + "manager_ids": template_manager_ids + if template_manager_ids is not None + else [(5, 0, 0)], + } + ) + + if not server: + server = self.server_test_1 + + # Update server access if needed + if server_user_ids is not None: + server.write({"user_ids": server_user_ids}) + if server_manager_ids is not None: + server.write({"manager_ids": server_manager_ids}) + + # Create install record + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + return template, server, install_record + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_server_user_ids_template_access_level_manager(self): + """Test Manager: Read when in server user_ids and template access_level <= 2""" + _, _, install_record = self._create_install_record( + template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in server user_ids" + " and template access_level <= 2", + ) + + def test_manager_read_server_manager_ids_template_access_level_manager(self): + """ + Test Manager: Read when in server manager_ids + and template access_level <= 2. + """ + _, _, install_record = self._create_install_record( + template_access_level="2", + server_manager_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in server manager_ids" + " and template access_level <= 2", + ) + + def test_manager_read_template_user_ids_override(self): + """ + Test Manager: Read when in template user_ids overrides access_level + (server user_ids or manager_ids). + """ + # Test with server user_ids + _, _, install_record1 = self._create_install_record( + template_access_level="3", # Root level - normally not accessible + template_user_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record1.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in template user_ids" " and server user_ids", + ) + + # Test with server manager_ids + _, _, install_record2 = self._create_install_record( + template_access_level="3", # Root level - normally not accessible + template_user_ids=[(4, self.manager.id)], + server_manager_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record2.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in template user_ids" " and server manager_ids", + ) + + def test_manager_read_no_access_no_server_access(self): + """ + Test Manager: No read access when not in + server user_ids or manager_ids. + """ + _, _, install_record = self._create_install_record( + template_access_level="1", + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in server user_ids or manager_ids", + ) + + def test_manager_read_no_access_template_root_level(self): + """ + Test Manager: No read access when template access_level + is Root and not in template user_ids. + """ + _, _, install_record = self._create_install_record( + template_access_level="3", # Root level + template_user_ids=[(5, 0, 0)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when template access_level" + " is Root and not in template user_ids", + ) + + def test_manager_read_no_access_template_manager_level_no_server_access(self): + """ + Test Manager: No read access when template access_level + is Manager but not in server. + """ + _, _, install_record = self._create_install_record( + template_access_level="2", + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "=", install_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in server" + " even if template access_level is Manager", + ) + + def test_manager_write_forbidden(self): + """Test Manager: Cannot write/create/delete records""" + _, _, install_record = self._create_install_record( + template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + # Manager should not be able to write + with self.assertRaises(AccessError): + install_record.with_user(self.manager).write({"state": "done"}) + + # Manager should not be able to create + template = self.JetTemplate.create( + { + "name": "New Template", + "reference": "new_template", + "access_level": "2", + } + ) + server = self.server_test_1 + server.write({"user_ids": [(4, self.manager.id)]}) + + with self.assertRaises(AccessError): + self.JetTemplateInstall.with_user(self.manager).create( + { + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + # Manager should not be able to delete + with self.assertRaises(AccessError): + install_record.with_user(self.manager).unlink() + + # ====================== + # Root Access Tests + # ====================== + + def test_root_write_access(self): + """Test Root: Can write any record""" + _, _, install_record = self._create_install_record() + + # Root should be able to write + try: + install_record.with_user(self.root).write({"state": "done"}) + install_record.invalidate_recordset() + self.assertEqual( + install_record.state, "done", "Root should be able to update" + ) + except AccessError: + self.fail("Root should be able to update any record") + + def test_root_create_access(self): + """Test Root: Can create any record""" + template = self.JetTemplate.with_user(self.root).create( + { + "name": "Root Template", + "reference": "root_template", + "access_level": "3", + } + ) + server = self.server_test_1 + + # Root should be able to create + try: + install_record = self.JetTemplateInstall.with_user(self.root).create( + { + "jet_template_id": template.id, + "server_id": server.id, + } + ) + records = self.JetTemplateInstall.with_user(self.root).search( + [("id", "=", install_record.id)] + ) + self.assertEqual(len(records), 1, "Root should be able to create") + except AccessError: + self.fail("Root should be able to create any record") + + def test_root_delete_access(self): + """Test Root: Can delete any record""" + _, _, install_record = self._create_install_record() + + # Root should be able to delete + try: + install_record.with_user(self.root).unlink() + records = self.JetTemplateInstall.with_user(self.root).search( + [("id", "=", install_record.id)] + ) + self.assertEqual(len(records), 0, "Root should be able to delete") + except AccessError: + self.fail("Root should be able to delete any record") + + def test_root_access_all_scenarios(self): + """Test Root can access records in all scenarios""" + # Test various combinations + scenarios = [ + { + "template_access_level": "1", + "server_user_ids": [(5, 0, 0)], + "server_manager_ids": [(5, 0, 0)], + }, + { + "template_access_level": "2", + "server_user_ids": [(5, 0, 0)], + "server_manager_ids": [(5, 0, 0)], + }, + { + "template_access_level": "3", + "server_user_ids": [(5, 0, 0)], + "server_manager_ids": [(5, 0, 0)], + }, + ] + + for scenario in scenarios: + _, _, install_record = self._create_install_record(**scenario) + records = self.JetTemplateInstall.with_user(self.root).search( + [("id", "=", install_record.id)] + ) + self.assertEqual( + len(records), + 1, + f"Root should be able to read record with scenario: {scenario}", + ) + + # ====================== + # Edge Cases + # ====================== + + def test_manager_read_multiple_servers(self): + """Test Manager access across multiple servers""" + # Manager in server 1, template accessible + template1, _, install1 = self._create_install_record( + template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + # Manager not in server 2, same template + _, _, install2 = self._create_install_record( + template=template1, + server=self.server_test_2, + template_access_level="2", + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + # Manager should only see install1 + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "in", [install1.id, install2.id])] + ) + self.assertEqual(len(records), 1, "Manager should only see accessible install") + self.assertEqual(records[0].id, install1.id, "Manager should see install1") + + def test_manager_read_multiple_templates(self): + """Test Manager access with multiple templates""" + # Template 1: Manager level, Manager in server + _, _, install1 = self._create_install_record( + template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + # Template 2: Root level, Manager in server but template user_ids + _, _, install2 = self._create_install_record( + template_access_level="3", + template_user_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + # Manager should see both + records = self.JetTemplateInstall.with_user(self.manager).search( + [("id", "in", [install1.id, install2.id])] + ) + self.assertEqual(len(records), 2, "Manager should see both installs") diff --git a/addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py b/addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py new file mode 100644 index 0000000..f5d240f --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py @@ -0,0 +1,492 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetTemplateInstallLineAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Template Install Line model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional server for testing + cls.server_test_2 = cls.Server.create( + { + "name": "Test Server 2", + "reference": "test_server_2", + "ip_v4_address": "192.168.1.102", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": cls.os_debian_10.id, + } + ) + + def _create_install_line_record( + self, + template=None, + line_template=None, + server=None, + line_template_access_level="2", + line_template_user_ids=None, + server_user_ids=None, + server_manager_ids=None, + ): + """ + Helper method to create a jet template install line record + + Note: Install Line access rules only check server_id and line template + (jet_template_id), not the parent install template. So we only need + to vary these parameters for testing. + + Args: + template: Template for the install record (parent) + - defaults to simple template. + line_template: Template for the install line + server: Server for the install record + line_template_access_level: Access level for line template + line_template_user_ids: User IDs for line template + server_user_ids: User IDs for server + server_manager_ids: Manager IDs for server + """ + if not template: + template = self.JetTemplate.create( + { + "name": "Test Template", + "access_level": "2", # Default, doesn't affect Install Line access + } + ) + + if not line_template: + line_template = self.JetTemplate.create( + { + "name": "Test Line Template", + "reference": "test_line_template", + "access_level": line_template_access_level, + "user_ids": line_template_user_ids + if line_template_user_ids is not None + else [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + } + ) + + if not server: + server = self.server_test_1 + + # Update server access if needed + if server_user_ids is not None: + server.write({"user_ids": server_user_ids}) + if server_manager_ids is not None: + server.write({"manager_ids": server_manager_ids}) + + # Create install record + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + # Create install line record + install_line_record = self.JetTemplateInstallLine.create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": line_template.id, + "order": 10, + } + ) + + return template, line_template, server, install_record, install_line_record + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_server_user_ids_line_template_access_level_manager(self): + """ + Test Manager: Read when in server user_ids + and line template access_level <= 2. + """ + _, _, _, _, install_line_record = self._create_install_line_record( + line_template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in server user_ids " + "and line template access_level <= 2.", + ) + + def test_manager_read_server_manager_ids_line_template_access_level_manager(self): + """ + Test Manager: Read when in server manager_ids + and line template access_level <= 2. + """ + _, _, _, _, install_line_record = self._create_install_line_record( + line_template_access_level="2", + server_manager_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in server manager_ids" + " and line template access_level <= 2", + ) + + def test_manager_read_line_template_user_ids_override(self): + """ + Test Manager: Read when in line template user_ids overrides access_level + (server user_ids or manager_ids). + """ + # Test with server user_ids + _, _, _, _, install_line_record1 = self._create_install_line_record( + line_template_access_level="3", # Root level - normally not accessible + line_template_user_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record1.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in line template user_ids" " and server user_ids", + ) + + # Test with server manager_ids + _, _, _, _, install_line_record2 = self._create_install_line_record( + line_template_access_level="3", # Root level - normally not accessible + line_template_user_ids=[(4, self.manager.id)], + server_manager_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record2.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read when in line template user_ids" + " and server manager_ids", + ) + + def test_manager_read_no_access_no_server_access(self): + """ + Test Manager: No read access when not in server + user_ids and manager_ids. + """ + _, _, _, _, install_line_record = self._create_install_line_record( + line_template_access_level="1", + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in server user_ids or manager_ids", + ) + + def test_manager_read_no_access_line_template_root_level(self): + """ + Test Manager: No read access when line template + access_level is Root and not in line template user_ids. + """ + _, _, _, _, install_line_record = self._create_install_line_record( + line_template_access_level="3", # Root level + line_template_user_ids=[(5, 0, 0)], + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when line template access_level" + " is Root and not in line template user_ids", + ) + + def test_manager_read_no_access_line_template_manager_level_no_server_access(self): + """ + Test Manager: No read access when line template access_level + is Manager but not in server. + """ + _, _, _, _, install_line_record = self._create_install_line_record( + line_template_access_level="2", + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in server" + " even if line template access_level is Manager", + ) + + def test_manager_write_forbidden(self): + """Test Manager: Cannot write/create/delete records""" + _, _, _, _, install_line_record = self._create_install_line_record( + line_template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + # Manager should not be able to write + with self.assertRaises(AccessError): + install_line_record.with_user(self.manager).write({"state": "done"}) + + # Manager should not be able to create + template = self.JetTemplate.create( + { + "name": "New Template", + "reference": "new_template", + "access_level": "2", + } + ) + line_template = self.JetTemplate.create( + { + "name": "New Line Template", + "reference": "new_line_template", + "access_level": "2", + } + ) + server = self.server_test_1 + server.write({"user_ids": [(4, self.manager.id)]}) + + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + with self.assertRaises(AccessError): + self.JetTemplateInstallLine.with_user(self.manager).create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": line_template.id, + "order": 10, + } + ) + + # Manager should not be able to delete + with self.assertRaises(AccessError): + install_line_record.with_user(self.manager).unlink() + + # ====================== + # Root Access Tests + # ====================== + + def test_root_write_access(self): + """Test Root: Can write any record""" + _, _, _, _, install_line_record = self._create_install_line_record() + + # Root should be able to write + try: + install_line_record.with_user(self.root).write({"state": "done"}) + install_line_record.invalidate_recordset() + self.assertEqual( + install_line_record.state, "done", "Root should be able to update" + ) + except AccessError: + self.fail("Root should be able to update any record") + + def test_root_create_access(self): + """Test Root: Can create any record""" + template = self.JetTemplate.with_user(self.root).create( + { + "name": "Root Template", + "reference": "root_template", + "access_level": "3", + } + ) + line_template = self.JetTemplate.with_user(self.root).create( + { + "name": "Root Line Template", + "reference": "root_line_template", + "access_level": "3", + } + ) + server = self.server_test_1 + + install_record = self.JetTemplateInstall.create( + { + "jet_template_id": template.id, + "server_id": server.id, + } + ) + + # Root should be able to create + try: + install_line_record = self.JetTemplateInstallLine.with_user( + self.root + ).create( + { + "jet_template_install_id": install_record.id, + "jet_template_id": line_template.id, + "order": 10, + } + ) + records = self.JetTemplateInstallLine.with_user(self.root).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual(len(records), 1, "Root should be able to create") + except AccessError: + self.fail("Root should be able to create any record") + + def test_root_delete_access(self): + """Test Root: Can delete any record""" + _, _, _, _, install_line_record = self._create_install_line_record() + + # Root should be able to delete + try: + install_line_record.with_user(self.root).unlink() + records = self.JetTemplateInstallLine.with_user(self.root).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual(len(records), 0, "Root should be able to delete") + except AccessError: + self.fail("Root should be able to delete any record") + + def test_root_access_all_scenarios(self): + """Test Root can access records in all scenarios""" + # Test various combinations + scenarios = [ + { + "line_template_access_level": "1", + "server_user_ids": [(5, 0, 0)], + "server_manager_ids": [(5, 0, 0)], + }, + { + "line_template_access_level": "2", + "server_user_ids": [(5, 0, 0)], + "server_manager_ids": [(5, 0, 0)], + }, + { + "line_template_access_level": "3", + "server_user_ids": [(5, 0, 0)], + "server_manager_ids": [(5, 0, 0)], + }, + ] + + for scenario in scenarios: + _, _, _, _, install_line_record = self._create_install_line_record( + **scenario + ) + records = self.JetTemplateInstallLine.with_user(self.root).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 1, + f"Root should be able to read record with scenario: {scenario}", + ) + + # ====================== + # Edge Cases + # ====================== + + def test_manager_read_multiple_servers(self): + """Test Manager access across multiple servers""" + # Manager in server 1, line template accessible + _, line_template1, _, _, install_line1 = self._create_install_line_record( + line_template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + # Manager not in server 2, same line template + _, _, _, _, install_line2 = self._create_install_line_record( + line_template=line_template1, + server=self.server_test_2, + line_template_access_level="2", + server_user_ids=[(5, 0, 0)], + server_manager_ids=[(5, 0, 0)], + ) + + # Manager should only see install_line1 + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "in", [install_line1.id, install_line2.id])] + ) + self.assertEqual( + len(records), 1, "Manager should only see accessible install line" + ) + self.assertEqual( + records[0].id, install_line1.id, "Manager should see install_line1" + ) + + def test_manager_read_multiple_line_templates(self): + """Test Manager access with multiple line templates""" + # Line Template 1: Manager level, Manager in server + _, _, _, _, install_line1 = self._create_install_line_record( + line_template_access_level="2", + server_user_ids=[(4, self.manager.id)], + ) + + # Line Template 2: Root level, Manager in server but line template user_ids + _, _, _, _, install_line2 = self._create_install_line_record( + line_template_access_level="3", + line_template_user_ids=[(4, self.manager.id)], + server_user_ids=[(4, self.manager.id)], + ) + + # Manager should see both + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "in", [install_line1.id, install_line2.id])] + ) + self.assertEqual(len(records), 2, "Manager should see both install lines") + + def test_manager_read_parent_template_does_not_affect_access(self): + """ + Test Manager: Parent install template access level + does not affect Install Line access. + """ + # Verify that Install Line access only depends on server_id and line template, + # not the parent install template. + # Create a line with Root-level parent template, + # but accessible line template - should still be accessible. + _, _, _, _, install_line_record = self._create_install_line_record( + template=self.JetTemplate.create( + { + "name": "Root Parent Template", + "reference": "root_parent_template", + "access_level": "3", + } + ), + line_template_access_level="2", # Manager level - accessible + server_user_ids=[(4, self.manager.id)], + ) + + records = self.JetTemplateInstallLine.with_user(self.manager).search( + [("id", "=", install_line_record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should read Install Line when line template " + "and server are accessible, " + "regardless of parent install template access level", + ) diff --git a/addons/cetmix_tower_server/tests/test_jet_waypoint.py b/addons/cetmix_tower_server/tests/test_jet_waypoint.py new file mode 100644 index 0000000..1f3d022 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_waypoint.py @@ -0,0 +1,1995 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError +from odoo.tools import mute_logger + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetWaypoint(TestTowerJetsCommon): + """ + Test the Jet Waypoint model functionality + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create variables for testing + cls.variable_test_1 = cls.Variable.create( + { + "name": "Test Variable 1", + "reference": "test_var_1", + } + ) + cls.variable_test_2 = cls.Variable.create( + { + "name": "Test Variable 2", + "reference": "test_var_2", + } + ) + cls.variable_test_3 = cls.Variable.create( + { + "name": "Test Variable 3", + "reference": "test_var_3", + } + ) + # waypoint_template and waypoint are now inherited from TestTowerJetsCommon + + # Create commands for flight plans + cls.command_success = cls.Command.create( + { + "name": "Command -> Success", + "action": "python_code", + "code": "# Just return default values", + } + ) + cls.command_error = cls.Command.create( + { + "name": "Command -> Error", + "action": "python_code", + "code": "result = {'exit_code': -100, 'message': 'Error'}", + } + ) + cls.command_waypoint_check = cls.Command.create( + { + "name": "Command -> Waypoint Check", + "action": "python_code", + "code": ( + "result = {'exit_code': waypoint.id if waypoint else -1, " + "'message': 'waypoint check'}" + ), + } + ) + + # Create flight plans + cls.plan_success = cls.Plan.create( + { + "name": "Waypoint Success Plan", + } + ) + cls.plan_line.create( + { + "sequence": 10, + "plan_id": cls.plan_success.id, + "command_id": cls.command_success.id, + } + ) + + cls.plan_error = cls.Plan.create( + { + "name": "Waypoint Error Plan", + } + ) + cls.plan_line.create( + { + "sequence": 10, + "plan_id": cls.plan_error.id, + "command_id": cls.command_error.id, + } + ) + + cls.plan_waypoint_check = cls.Plan.create( + { + "name": "Waypoint Check Plan", + } + ) + cls.plan_line.create( + { + "sequence": 10, + "plan_id": cls.plan_waypoint_check.id, + "command_id": cls.command_waypoint_check.id, + } + ) + + def test_save_variable_values_empty(self): + """ + Test _save_variable_values when jet has no variable values + """ + # Ensure jet has no variable values + self.jet_test.variable_value_ids.unlink() + + # Save variable values + result = self.waypoint._save_variable_values() + + # Should return True + self.assertTrue(result, "Should return True when saving values") + + # Waypoint should have empty variable_values (or False, which is equivalent) + variable_values = self.waypoint.variable_values or {} + self.assertEqual( + variable_values, + {}, + "Variable values should be empty dict when jet has no values", + ) + + def test_save_variable_values_with_values(self): + """ + Test _save_variable_values when jet has variable values + """ + # Create variable values for the jet + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "value_1", + "jet_id": self.jet_test.id, + } + ) + self.VariableValue.create( + { + "variable_id": self.variable_test_2.id, + "value_char": "value_2", + "jet_id": self.jet_test.id, + } + ) + + # Save variable values + result = self.waypoint._save_variable_values() + + # Should return True + self.assertTrue(result, "Should return True when saving values") + + # Waypoint should have saved variable values + self.assertEqual( + self.waypoint.variable_values, + {"test_var_1": "value_1", "test_var_2": "value_2"}, + "Variable values should be saved correctly", + ) + + def test_save_variable_values_with_empty_string(self): + """ + Test _save_variable_values when variable value is empty string + """ + # Create variable value with empty string + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "", + "jet_id": self.jet_test.id, + } + ) + + # Save variable values + self.waypoint._save_variable_values() + + # Waypoint should have saved empty string value + self.assertEqual( + self.waypoint.variable_values, + {"test_var_1": ""}, + "Empty string values should be saved", + ) + + def test_save_variable_values_only_jet_values(self): + """ + Test _save_variable_values only saves jet-specific values, + not template/server/global values + """ + # Create jet-specific variable value + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "jet_value", + "jet_id": self.jet_test.id, + } + ) + + # Create template variable value (should not be saved) + self.VariableValue.create( + { + "variable_id": self.variable_test_2.id, + "value_char": "template_value", + "jet_template_id": self.jet_template_test.id, + } + ) + + # Save variable values + self.waypoint._save_variable_values() + + # Waypoint should only have jet-specific value + self.assertEqual( + self.waypoint.variable_values, + {"test_var_1": "jet_value"}, + "Should only save jet-specific values", + ) + self.assertNotIn( + "test_var_2", + self.waypoint.variable_values, + "Should not save template values", + ) + + def test_restore_variable_values_empty(self): + """ + Test _restore_variable_values when waypoint has no saved values + """ + # Create some variable values in jet + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "existing_value", + "jet_id": self.jet_test.id, + } + ) + + # Set waypoint variable_values to empty + self.waypoint.variable_values = {} + + # Restore variable values + result = self.waypoint._restore_variable_values() + + # Should return True + self.assertTrue(result, "Should return True when restoring values") + + # Jet should have no variable values + self.assertEqual( + len(self.jet_test.variable_value_ids), + 0, + "All jet variable values should be removed when waypoint is empty", + ) + + def test_restore_variable_values_basic(self): + """ + Test _restore_variable_values restores values correctly + """ + # Set waypoint variable values + self.waypoint.variable_values = { + "test_var_1": "restored_value_1", + "test_var_2": "restored_value_2", + } + + # Restore variable values + result = self.waypoint._restore_variable_values() + + # Should return True + self.assertTrue(result, "Should return True when restoring values") + + # Check values were restored + self.assertEqual( + self.jet_test.get_variable_value("test_var_1", no_fallback=True), + "restored_value_1", + "Variable 1 should be restored", + ) + self.assertEqual( + self.jet_test.get_variable_value("test_var_2", no_fallback=True), + "restored_value_2", + "Variable 2 should be restored", + ) + + def test_restore_variable_values_removes_unsaved(self): + """ + Test _restore_variable_values removes variable values not in waypoint + """ + # Create variable values in jet + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "value_1", + "jet_id": self.jet_test.id, + } + ) + self.VariableValue.create( + { + "variable_id": self.variable_test_2.id, + "value_char": "value_2", + "jet_id": self.jet_test.id, + } + ) + self.VariableValue.create( + { + "variable_id": self.variable_test_3.id, + "value_char": "value_3", + "jet_id": self.jet_test.id, + } + ) + + # Set waypoint to only have variable 1 and 2 + self.waypoint.variable_values = { + "test_var_1": "value_1", + "test_var_2": "value_2", + } + + # Restore variable values + self.waypoint._restore_variable_values() + + # Variable 3 should be removed + self.assertIsNone( + self.jet_test.get_variable_value("test_var_3", no_fallback=True), + "Variable 3 should be removed", + ) + + # Variables 1 and 2 should still exist + self.assertEqual( + self.jet_test.get_variable_value("test_var_1", no_fallback=True), + "value_1", + "Variable 1 should still exist", + ) + self.assertEqual( + self.jet_test.get_variable_value("test_var_2", no_fallback=True), + "value_2", + "Variable 2 should still exist", + ) + + def test_restore_variable_values_updates_existing(self): + """ + Test _restore_variable_values updates existing variable values + """ + # Create variable value in jet + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "old_value", + "jet_id": self.jet_test.id, + } + ) + + # Set waypoint with new value + self.waypoint.variable_values = {"test_var_1": "new_value"} + + # Restore variable values + self.waypoint._restore_variable_values() + + # Value should be updated + self.assertEqual( + self.jet_test.get_variable_value("test_var_1", no_fallback=True), + "new_value", + "Variable value should be updated", + ) + + def test_save_and_restore_roundtrip(self): + """ + Test saving and restoring variable values in a roundtrip + """ + # Create initial variable values + self.VariableValue.create( + { + "variable_id": self.variable_test_1.id, + "value_char": "initial_value_1", + "jet_id": self.jet_test.id, + } + ) + self.VariableValue.create( + { + "variable_id": self.variable_test_2.id, + "value_char": "initial_value_2", + "jet_id": self.jet_test.id, + } + ) + + # Save variable values + self.waypoint._save_variable_values() + + # Modify jet values + self.jet_test.set_variable_value("test_var_1", "modified_value_1") + self.jet_test.set_variable_value("test_var_2", "modified_value_2") + self.VariableValue.create( + { + "variable_id": self.variable_test_3.id, + "value_char": "new_value", + "jet_id": self.jet_test.id, + } + ) + + # Restore variable values + self.waypoint._restore_variable_values() + + # Values should be restored to original + self.assertEqual( + self.jet_test.get_variable_value("test_var_1", no_fallback=True), + "initial_value_1", + "Variable 1 should be restored to original value", + ) + self.assertEqual( + self.jet_test.get_variable_value("test_var_2", no_fallback=True), + "initial_value_2", + "Variable 2 should be restored to original value", + ) + # Variable 3 should be removed (not in saved waypoint) + self.assertIsNone( + self.jet_test.get_variable_value("test_var_3", no_fallback=True), + "Variable 3 should be removed", + ) + + def test_write_waypoint_template_draft_allowed(self): + """ + Test that modifying waypoint_template_id is allowed when state is draft + """ + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Draft", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + } + ) + + # Should be able to change template in draft state + waypoint.write({"waypoint_template_id": self.waypoint_template_2.id}) + self.assertEqual( + waypoint.waypoint_template_id.id, + self.waypoint_template_2.id, + "Should be able to change template in draft state", + ) + + def test_write_waypoint_template_not_draft_raises_error(self): + """ + Test that modifying waypoint_template_id raises ValidationError + when state is not draft + """ + # Create waypoint in ready state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Ready", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + } + ) + + # Should raise ValidationError when trying to change template + with self.assertRaises(ValidationError) as context: + waypoint.write({"waypoint_template_id": self.waypoint_template_2.id}) + + self.assertIn( + "draft state", + str(context.exception), + "Should raise ValidationError about draft state", + ) + + def test_write_waypoint_template_same_value_allowed(self): + """ + Test that setting waypoint_template_id to the same value is allowed + even when not in draft state + """ + # Create waypoint in ready state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Ready", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + } + ) + original_template_id = waypoint.waypoint_template_id.id + + # Should be able to set to the same template + waypoint.write({"waypoint_template_id": original_template_id}) + self.assertEqual( + waypoint.waypoint_template_id.id, + original_template_id, + "Should be able to set same template value", + ) + + def test_write_other_fields_not_draft_allowed(self): + """ + Test that modifying other fields is allowed when state is not draft + """ + # Create waypoint in ready state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Ready", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + } + ) + + # Should be able to modify other fields + waypoint.write({"name": "Updated Name"}) + self.assertEqual( + waypoint.name, + "Updated Name", + "Should be able to modify other fields when not in draft", + ) + + def test_prepare_without_flight_plan(self): + """ + Test prepare() when waypoint template has no plan_create_id + """ + # Create waypoint template without plan_create_id + waypoint_template_no_plan = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template No Plan", + "jet_template_id": self.jet_template_test.id, + } + ) + + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint No Plan", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template_no_plan.id, + "state": "draft", + } + ) + + # Call prepare + result = waypoint.prepare() + + # Should return True and set state to ready + self.assertTrue(result, "Should return True") + self.assertEqual( + waypoint.state, + "ready", + "State should be set to ready when no flight plan", + ) + + def test_prepare_without_flight_plan_with_is_destination(self): + """ + Test prepare() when waypoint template has no plan_create_id + and is_destination=True + Should automatically call fly_to() when prepare completes + """ + # Create waypoint template without plan_create_id + waypoint_template_no_plan = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template No Plan Destination", + "jet_template_id": self.jet_template_test.id, + } + ) + + # Create waypoint in draft state with is_destination=True + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint No Plan Destination", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template_no_plan.id, + "state": "draft", + } + ) + + # Call prepare + result = waypoint.prepare(is_destination=True) + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to current (because fly_to() was called) + # Since there's no previous waypoint and no plan_arrive_id, + # fly_to() sets state to arriving and calls _arrive() which sets it to current + self.assertEqual( + waypoint.state, + "current", + "State should be set to current after fly_to() and _arrive()", + ) + # Waypoint should be set as current waypoint + self.assertEqual( + self.jet_test.waypoint_id.id, + waypoint.id, + "Waypoint should be set as current waypoint after fly_to()", + ) + # is_destination should be cleared after arriving + self.assertFalse( + waypoint.is_destination, + "is_destination should be cleared after arriving", + ) + + def test_prepare_with_flight_plan_success(self): + """ + Test prepare() when waypoint template has plan_create_id and plan succeeds + """ + # Set template to use success plan + self.waypoint_template.plan_create_id = self.plan_success.id + + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint With Plan", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + } + ) + + # Call prepare - plan executes synchronously in tests + result = waypoint.prepare() + + # Should return True + self.assertTrue(result, "Should return True") + + # State should be set to ready after successful plan completion + # (plan executes synchronously in tests, preparing -> ready) + self.assertEqual( + waypoint.state, + "ready", + "State should be set to ready after successful plan completion", + ) + # Waypoint should NOT be set as current waypoint after preparing + # (only arriving sets waypoint as current) + self.assertNotEqual( + self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False, + waypoint.id, + "Waypoint should not be set as current waypoint after preparing", + ) + + def test_waypoint_variable_in_python_command_prepare(self): + """ + Test that 'waypoint' variable is available in Python commands + run for a waypoint plan (plan_create) and its id is used as exit code + """ + # Set template to use waypoint check plan + self.waypoint_template.plan_create_id = self.plan_waypoint_check.id + + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint For Variable Check", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + } + ) + + # Call prepare - plan executes synchronously in tests + waypoint.prepare() + + # Find the plan log created by prepare + plan_log = self.PlanLog.search( + [("waypoint_id", "=", waypoint.id)], + order="create_date desc", + limit=1, + ) + self.assertTrue(plan_log, "Plan log should be created") + + # Plan exit code (plan_status) must equal waypoint id + self.assertEqual( + plan_log.plan_status, + waypoint.id, + "Plan status must equal waypoint id (from waypoint variable)", + ) + + def test_waypoint_variable_in_python_command_arrive(self): + """ + Test that 'waypoint' variable is available in Python commands + run for a waypoint arrive plan and its id is used as exit code + """ + # Create waypoint template with plan_arrive_id + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Waypoint Template For Arrive Check", + "jet_template_id": self.jet_template_test.id, + "plan_arrive_id": self.plan_waypoint_check.id, + } + ) + + # Create waypoint in arriving state (no previous waypoint) + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint For Arrive Variable Check", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template.id, + "state": "arriving", + } + ) + + # Call arrive - plan executes synchronously in tests + waypoint._arrive() + + # Find the plan log created by arrive + plan_log = self.PlanLog.search( + [("waypoint_id", "=", waypoint.id)], + order="create_date desc", + limit=1, + ) + self.assertTrue(plan_log, "Plan log should be created") + + # Plan exit code (plan_status) must equal waypoint id + self.assertEqual( + plan_log.plan_status, + waypoint.id, + "Plan status must equal waypoint id (from waypoint variable)", + ) + + def test_prepare_with_flight_plan_error(self): + """ + Test prepare() when waypoint template has plan_create_id and plan fails + """ + # Set template to use error plan + self.waypoint_template.plan_create_id = self.plan_error.id + + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint With Plan Error", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + } + ) + + # Call prepare - plan executes synchronously in tests + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + result = waypoint.prepare() + + # Should return True + self.assertTrue(result, "Should return True") + + # State should be set to error after failed plan completion + # (plan executes synchronously in tests) + self.assertEqual( + waypoint.state, + "error", + "State should be set to error after failed plan completion", + ) + # Waypoint should not be set as current waypoint on error + self.assertNotEqual( + self.jet_test.waypoint_id.id, + waypoint.id, + "Waypoint should not be set as current waypoint after failed prepare", + ) + + def test_prepare_not_draft_state(self): + """ + Test prepare() when waypoint is not in draft state + """ + # Create waypoint in ready state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Ready", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + } + ) + + # Call prepare. This will log and error because waypoint is not in draft state + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + with self.assertRaises(ValidationError): + waypoint.prepare() + + def test_plan_finished_preparing_success(self): + """ + Test _plan_finished when waypoint is in preparing state and plan succeeds + """ + # Create waypoint in preparing state (simulating async plan execution) + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Preparing", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "preparing", + } + ) + + # Create plan log with success status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_success.id, + "plan_status": 0, # Success + } + ) + + # Call _plan_finished + result = waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to ready + # (preparing -> ready, not current) + self.assertEqual( + waypoint.state, + "ready", + "State should be set to ready after successful plan completion", + ) + # Waypoint should NOT be set as current waypoint after preparing + # (only arriving sets waypoint as current) + self.assertNotEqual( + self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False, + waypoint.id, + "Waypoint should not be set as current waypoint after preparing", + ) + + def test_plan_finished_preparing_success_with_is_destination(self): + """ + Test _plan_finished when waypoint is in preparing state with is_destination=True + Should automatically call fly_to() when preparing finishes + """ + # Create waypoint in preparing state with is_destination=True + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Preparing Destination", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "preparing", + "is_destination": True, + } + ) + + # Create plan log with success status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_success.id, + "plan_status": 0, # Success + } + ) + + # Call _plan_finished + result = waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to arriving (because fly_to() was called) + # Since there's no previous waypoint and no plan_arrive_id, + # fly_to() sets state to arriving and calls _arrive() which sets it to current + self.assertEqual( + waypoint.state, + "current", + "State should be set to current after fly_to() and _arrive()", + ) + # Waypoint should be set as current waypoint + self.assertEqual( + self.jet_test.waypoint_id.id, + waypoint.id, + "Waypoint should be set as current waypoint after fly_to()", + ) + # is_destination should be cleared after arriving + self.assertFalse( + waypoint.is_destination, + "is_destination should be cleared after arriving", + ) + + def test_plan_finished_arriving_success(self): + """ + Test _plan_finished when waypoint is in arriving state and plan succeeds + """ + # Create waypoint in arriving state (simulating async plan execution) + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Arriving", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "arriving", + } + ) + + # Create plan log with success status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_success.id, + "plan_status": 0, # Success + } + ) + + # Call _plan_finished + result = waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to current + # (waypoint becomes current after successful arrive) + self.assertEqual( + waypoint.state, + "current", + "State should be set to current after successful plan completion", + ) + # Waypoint should be set as current waypoint + self.assertEqual( + self.jet_test.waypoint_id.id, + waypoint.id, + "Waypoint should be set as current waypoint after successful arrive", + ) + + def test_plan_finished_leaving_success(self): + """ + Test _plan_finished when waypoint is in leaving state and plan succeeds + """ + # Create current waypoint in current state + current_waypoint = self.JetWaypoint.create( + { + "name": "Current Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = current_waypoint.id + + # Create destination waypoint in arriving state + destination_waypoint = self.JetWaypoint.create( + { + "name": "Destination Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "is_destination": True, + "state": "arriving", + } + ) + + # Set current waypoint to leaving state + # readonly=True only affects UI, can be written programmatically + current_waypoint.write({"state": "leaving"}) + + # Create plan log with success status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_success.id, + "plan_status": 0, # Success + } + ) + + # Call _plan_finished on leaving waypoint + result = current_waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # Leaving waypoint state should be set to ready + self.assertEqual( + current_waypoint.state, + "ready", + "Leaving waypoint state should be set to ready", + ) + # Destination waypoint should have _arrive() called + # (state should be current if no plan_arrive_id) + # Since waypoint_template has no plan_arrive_id by default, + # _arrive() sets state to current + self.assertEqual( + destination_waypoint.state, + "current", + "Destination waypoint should have _arrive() called", + ) + # Destination waypoint should be set as current waypoint + self.assertEqual( + self.jet_test.waypoint_id.id, + destination_waypoint.id, + "Destination waypoint should be set as current waypoint" + " after leaving completes", + ) + + def test_plan_finished_deleting_success(self): + """ + Test _plan_finished when waypoint is in deleting state and plan succeeds + """ + # Create waypoint template with plan_delete_id + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Template With Delete Plan", + "jet_template_id": self.jet_template_test.id, + "plan_delete_id": self.plan_success.id, + } + ) + + # Create waypoint and set it as current + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Deleting", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template.id, + "state": "ready", + } + ) + self.jet_test.waypoint_id = waypoint.id + + # Set waypoint to deleting state + # readonly=True only affects UI, can be written programmatically + waypoint.write({"state": "deleting"}) + + # Create plan log with success status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_success.id, + "plan_status": 0, # Success + } + ) + + # Call _plan_finished + result = waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # Waypoint should be unlinked (deleted) + # State is set to "deleted" before unlink + self.assertFalse( + waypoint.exists(), + "Waypoint should be unlinked after successful delete plan", + ) + # Jet waypoint_id should be set to False + self.assertFalse( + self.jet_test.waypoint_id, + "Jet waypoint_id should be set to False after successful delete", + ) + + def test_plan_finished_error(self): + """ + Test _plan_finished when plan fails (plan_status != 0) + """ + # Create waypoint in preparing state (simulating async plan execution) + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Preparing", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "preparing", + } + ) + original_waypoint_id = ( + self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False + ) + + # Create plan log with error status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_error.id, + "plan_status": 1, # Error + } + ) + + # Call _plan_finished + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + result = waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to error + self.assertEqual( + waypoint.state, + "error", + "State should be set to error after failed plan completion", + ) + # Waypoint should not be set as current waypoint + if original_waypoint_id: + self.assertEqual( + self.jet_test.waypoint_id.id, + original_waypoint_id, + "Current waypoint should not change on error", + ) + else: + self.assertFalse( + self.jet_test.waypoint_id, + "Current waypoint should remain False on error", + ) + + def test_plan_finished_error_arriving(self): + """ + Test _plan_finished when waypoint is in arriving state and plan fails + """ + # Create waypoint in arriving state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Arriving", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "arriving", + } + ) + + # Create plan log with error status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_error.id, + "plan_status": 1, # Error + } + ) + + # Call _plan_finished + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + result = waypoint._plan_finished(plan_log) + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to error + self.assertEqual( + waypoint.state, + "error", + "State should be set to error after failed plan completion", + ) + # Waypoint should not be set as current waypoint on error + self.assertNotEqual( + self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False, + waypoint.id, + "Waypoint should not be set as current waypoint after failed arrive", + ) + + def test_get_custom_variable_values_with_metadata(self): + """ + Test _get_custom_variable_values with metadata + """ + # Set template to use success plan + self.waypoint_template.plan_create_id = self.plan_success.id + + # Create waypoint with metadata + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint With Metadata", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + "metadata": {"key1": "value1", "key2": "value2", "env": "production"}, + } + ) + + # Call prepare to trigger flight plan + waypoint.prepare() + + # Find the plan log created by prepare + plan_log = self.PlanLog.search( + [ + ("waypoint_id", "=", waypoint.id), + ], + order="create_date desc", + limit=1, + ) + self.assertTrue(plan_log, "Plan log should be created") + + # Check custom variable values in plan log + self.assertEqual( + plan_log.variable_values.get("__waypoint"), + waypoint.reference, + "__waypoint should match waypoint reference", + ) + self.assertEqual( + plan_log.variable_values.get("__waypoint_type"), + self.waypoint_template.reference, + "__waypoint_type should match waypoint template reference", + ) + self.assertEqual( + plan_log.variable_values.get("__waypoint_state"), + "preparing", + "__waypoint_state should be preparing", + ) + # Check metadata keys + self.assertEqual( + plan_log.variable_values.get("__waypoint_key1"), + "value1", + "__waypoint_key1 should match metadata value", + ) + self.assertEqual( + plan_log.variable_values.get("__waypoint_key2"), + "value2", + "__waypoint_key2 should match metadata value", + ) + self.assertEqual( + plan_log.variable_values.get("__waypoint_env"), + "production", + "__waypoint_env should match metadata value", + ) + + def test_get_custom_variable_values_without_metadata(self): + """ + Test _get_custom_variable_values without metadata + """ + # Set template to use success plan + self.waypoint_template.plan_create_id = self.plan_success.id + + # Create waypoint without metadata + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Without Metadata", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + } + ) + + # Call prepare to trigger flight plan + waypoint.prepare() + + # Find the plan log created by prepare + plan_log = self.PlanLog.search( + [("waypoint_id", "=", waypoint.id)], + order="create_date desc", + limit=1, + ) + self.assertTrue(plan_log, "Plan log should be created") + + # Check basic custom variable values + self.assertEqual( + plan_log.variable_values.get("__waypoint"), + waypoint.reference, + "__waypoint should match waypoint reference", + ) + self.assertEqual( + plan_log.variable_values.get("__waypoint_type"), + self.waypoint_template.reference, + "__waypoint_type should match waypoint template reference", + ) + self.assertEqual( + plan_log.variable_values.get("__waypoint_state"), + "preparing", + "__waypoint_state should be preparing", + ) + # Check that metadata keys are not present + self.assertNotIn( + "__waypoint_key1", + plan_log.variable_values, + "Metadata keys should not be present when metadata is empty", + ) + + def test_leave_from_current_state(self): + """ + Test _leave() when waypoint is in current state + """ + # Create waypoint in current state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Current", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = waypoint.id + + # Call _leave + result = waypoint._leave() + + # Should return True + self.assertTrue(result, "Should return True") + # State should be set to ready + # (_leave() completes immediately when no plan_leave_id in tests) + self.assertEqual( + waypoint.state, + "ready", + "State should be set to ready after leaving completes", + ) + + def test_fly_to_from_current_waypoint(self): + """ + Test fly_to() when previous waypoint is in current state + """ + # Create current waypoint + current_waypoint = self.JetWaypoint.create( + { + "name": "Current Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = current_waypoint.id + + # Create destination waypoint + destination_waypoint = self.JetWaypoint.create( + { + "name": "Destination Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + } + ) + + # Call fly_to on destination waypoint + result = destination_waypoint.fly_to() + + # Should return True + self.assertTrue(result, "Should return True") + # Current waypoint should be in ready state + # (_leave() completes immediately when no plan_leave_id in tests) + self.assertEqual( + current_waypoint.state, + "ready", + "Current waypoint should be in ready state after leaving completes", + ) + # Destination waypoint should be in current state + # (_arrive() completes immediately when no plan_arrive_id in tests) + self.assertEqual( + destination_waypoint.state, + "current", + "Destination waypoint should be in current state after arriving", + ) + # Destination waypoint should be set as current waypoint + self.assertEqual( + self.jet_test.waypoint_id.id, + destination_waypoint.id, + "Destination waypoint should be set as current waypoint", + ) + + def test_fly_to_leave_failure_does_not_keep_destination_arriving(self): + """ + Regression: if source leave plan fails during fly_to(), + destination must not stay in arriving. + """ + # Create template with failing leave plan. + waypoint_template_with_leave_error = self.JetWaypointTemplate.create( + { + "name": "Template Leave Error", + "jet_template_id": self.jet_template_test.id, + "plan_leave_id": self.plan_error.id, + } + ) + + # Create current waypoint that will fail while leaving. + current_waypoint = self.JetWaypoint.create( + { + "name": "Current Waypoint Failing Leave", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template_with_leave_error.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = current_waypoint.id + + # Create destination waypoint (target of fly_to). + destination_waypoint = self.JetWaypoint.create( + { + "name": "Destination Waypoint Stuck Arriving", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + } + ) + + # Execute fly_to; leaving fails synchronously in tests. + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + result = destination_waypoint.fly_to() + + self.assertFalse(result, "fly_to() should return False when leave fails") + self.assertEqual( + current_waypoint.state, + "error", + "Source waypoint should become error after failed leave plan", + ) + self.assertNotEqual( + destination_waypoint.state, + "arriving", + "Destination waypoint must be reverted from arriving when leave fails", + ) + self.assertFalse( + destination_waypoint.is_destination, + "Destination flag must be cleared when leave fails", + ) + + def test_unlink_current_state_raises_error(self): + """ + Test unlink() when waypoint is in current state raises ValidationError + """ + # Create waypoint in current state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Current", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = waypoint.id + + # Should raise ValidationError when trying to delete + + with self.assertRaises(ValidationError) as context: + waypoint.unlink() + + self.assertIn( + "current waypoint", + str(context.exception), + "Should raise ValidationError about current waypoint", + ) + + def test_unlink_current_state_with_no_raise_context(self): + """ + Test unlink() when waypoint is in current state + with 'waypoint_no_raise_on_delete' context. + The context prevents exception but waypoint is not deleted. + """ + # Create waypoint in current state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint Current", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = waypoint.id + waypoint_id = waypoint.id + + # Mute logger error for this test + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + # Should not raise error with waypoint_no_raise_on_delete context + waypoint.with_context(waypoint_no_raise_on_delete=True).unlink() + + # Waypoint should still exist (not deleted) + # The context only prevents exception, but doesn't allow deletion + self.assertTrue( + waypoint.exists(), + "Waypoint should still exist - context only prevents exception", + ) + self.assertEqual( + waypoint.id, + waypoint_id, + "Waypoint ID should remain the same", + ) + self.assertEqual( + waypoint.state, + "current", + "Waypoint state should remain current", + ) + + def test_prepare_saves_variable_values(self): + """ + Test that prepare() saves variable values when state changes to ready + """ + # Set some variable values on the jet + self.jet_test.set_variable_value("test_var_1", "value1") + self.jet_test.set_variable_value("test_var_2", "value2") + + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "draft", + } + ) + + # Ensure waypoint has no plan_create_id (so it goes directly to ready) + waypoint.waypoint_template_id.plan_create_id = False + + # Call prepare + waypoint.prepare() + + # Variable values should be saved in waypoint + variable_values = waypoint.variable_values or {} + self.assertEqual( + variable_values.get("test_var_1"), + "value1", + "Variable value should be saved when preparing", + ) + self.assertEqual( + variable_values.get("test_var_2"), + "value2", + "Variable value should be saved when preparing", + ) + + def test_prepare_with_plan_saves_variable_values(self): + """ + Test that prepare() saves variable values when plan completes + """ + # Set some variable values on the jet + self.jet_test.set_variable_value("test_var_1", "value1") + self.jet_test.set_variable_value("test_var_2", "value2") + + # Create waypoint template with plan_create_id + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Template", + "jet_template_id": self.jet_template_test.id, + "plan_create_id": self.plan_success.id, + } + ) + + # Create waypoint in draft state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template.id, + "state": "draft", + } + ) + + # Call prepare (plan executes synchronously in tests) + waypoint.prepare() + + # Variable values should be saved in waypoint after plan completes + variable_values = waypoint.variable_values or {} + self.assertEqual( + variable_values.get("test_var_1"), + "value1", + "Variable value should be saved when preparing completes", + ) + self.assertEqual( + variable_values.get("test_var_2"), + "value2", + "Variable value should be saved when preparing completes", + ) + + def test_leave_saves_variable_values(self): + """ + Test that _leave() saves variable values when state changes to ready + """ + # Set some variable values on the jet + self.jet_test.set_variable_value("test_var_1", "value1") + self.jet_test.set_variable_value("test_var_2", "value2") + + # Create waypoint in current state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = waypoint.id + + # Ensure waypoint has no plan_leave_id (so it goes directly to ready) + waypoint.waypoint_template_id.plan_leave_id = False + + # Call _leave + waypoint._leave() + + # Variable values should be saved in waypoint + variable_values = waypoint.variable_values or {} + self.assertEqual( + variable_values.get("test_var_1"), + "value1", + "Variable value should be saved when leaving", + ) + self.assertEqual( + variable_values.get("test_var_2"), + "value2", + "Variable value should be saved when leaving", + ) + + def test_leave_with_plan_saves_variable_values(self): + """ + Test that _leave() saves variable values when plan completes + """ + # Set some variable values on the jet + self.jet_test.set_variable_value("test_var_1", "value1") + self.jet_test.set_variable_value("test_var_2", "value2") + + # Create waypoint template with plan_leave_id + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Template", + "jet_template_id": self.jet_template_test.id, + "plan_leave_id": self.plan_success.id, + } + ) + + # Create waypoint in current state + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = waypoint.id + + # Call _leave (plan executes synchronously in tests) + waypoint._leave() + + # Variable values should be saved in waypoint after plan completes + variable_values = waypoint.variable_values or {} + self.assertEqual( + variable_values.get("test_var_1"), + "value1", + "Variable value should be saved when leaving completes", + ) + self.assertEqual( + variable_values.get("test_var_2"), + "value2", + "Variable value should be saved when leaving completes", + ) + + def test_fly_to_restores_variable_values(self): + """ + Test that fly_to() restores variable values when state changes to arriving + """ + # Create waypoint with saved variable values + waypoint = self.JetWaypoint.create( + { + "name": "Test Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + "variable_values": { + "test_var_1": "saved_value1", + "test_var_2": "saved_value2", + }, + } + ) + + # Set different values on the jet + self.jet_test.set_variable_value("test_var_1", "current_value1") + self.jet_test.set_variable_value("test_var_2", "current_value2") + + # Call fly_to (no previous waypoint) + waypoint.fly_to() + + # Variable values should be restored from waypoint + self.assertEqual( + self.jet_test.get_variable_value("test_var_1"), + "saved_value1", + "Variable value should be restored when flying to waypoint", + ) + self.assertEqual( + self.jet_test.get_variable_value("test_var_2"), + "saved_value2", + "Variable value should be restored when flying to waypoint", + ) + + def test_fly_to_restores_variable_values_with_previous_waypoint(self): + """ + Test that fly_to() restores variable values + after previous waypoint saves its values + """ + # Create previous waypoint in current state + previous_waypoint = self.JetWaypoint.create( + { + "name": "Previous Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + } + ) + self.jet_test.waypoint_id = previous_waypoint.id + + # Set variable values on the jet + self.jet_test.set_variable_value("test_var_1", "previous_value1") + self.jet_test.set_variable_value("test_var_2", "previous_value2") + + # Create destination waypoint with saved variable values + destination_waypoint = self.JetWaypoint.create( + { + "name": "Destination Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "ready", + "variable_values": { + "test_var_1": "destination_value1", + "test_var_2": "destination_value2", + }, + } + ) + + # Ensure previous waypoint has no plan_leave_id (so it saves values immediately) + previous_waypoint.waypoint_template_id.plan_leave_id = False + + # Call fly_to + destination_waypoint.fly_to() + + # Previous waypoint should have saved its values + previous_values = previous_waypoint.variable_values or {} + self.assertEqual( + previous_values.get("test_var_1"), + "previous_value1", + "Previous waypoint should save its variable values", + ) + + # Variable values should be restored from destination waypoint + self.assertEqual( + self.jet_test.get_variable_value("test_var_1"), + "destination_value1", + "Variable value should be restored from destination waypoint", + ) + self.assertEqual( + self.jet_test.get_variable_value("test_var_2"), + "destination_value2", + "Variable value should be restored from destination waypoint", + ) + + def test_arriving_error_restores_variable_values(self): + """ + Test that when arriving fails, + variable values are restored from current waypoint + """ + # Create current waypoint with saved variable values + current_waypoint = self.JetWaypoint.create( + { + "name": "Current Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "current", + "variable_values": { + "test_var_1": "current_value1", + "test_var_2": "current_value2", + }, + } + ) + self.jet_test.waypoint_id = current_waypoint.id + + # Create arriving waypoint + arriving_waypoint = self.JetWaypoint.create( + { + "name": "Arriving Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "state": "arriving", + } + ) + + # Set different values on the jet + self.jet_test.set_variable_value("test_var_1", "arriving_value1") + self.jet_test.set_variable_value("test_var_2", "arriving_value2") + + # Create plan log with error status + plan_log = self.PlanLog.create( + { + "server_id": self.jet_test.server_id.id, + "plan_id": self.plan_error.id, + "plan_status": -100, # Error + } + ) + + # Call _plan_finished with error + with mute_logger( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" + ): + arriving_waypoint._plan_finished(plan_log) + + # Variable values should be restored from current waypoint + self.assertEqual( + self.jet_test.get_variable_value("test_var_1"), + "current_value1", + "Variable value should be restored from current waypoint on error", + ) + self.assertEqual( + self.jet_test.get_variable_value("test_var_2"), + "current_value2", + "Variable value should be restored from current waypoint on error", + ) + + # Current waypoint state should be "current" + self.assertEqual( + current_waypoint.state, + "current", + "Current waypoint state should remain current", + ) + + # Arriving waypoint state should be "error" + self.assertEqual( + arriving_waypoint.state, + "error", + "Arriving waypoint state should be error", + ) + + # ------------------------------------ + # --- _check_is_destination tests ---- + # ------------------------------------ + + def _make_destination_waypoint(self, name, jet=None): + """ + Helper: create a waypoint and atomically transition it to the + ``preparing`` state with ``is_destination=True``. + + This mirrors what ``prepare(is_destination=True)`` does internally + when the waypoint template has a ``plan_create_id`` (it writes + ``state=preparing`` + ``is_destination`` in one call and does not + proceed to ``fly_to()``). Using that path keeps ``is_destination`` + stable for subsequent constraint assertions, whereas calling + ``prepare()`` without a plan triggers ``fly_to()`` → ``_arrive()``, + which clears ``is_destination`` immediately. + + Args: + name (str): Name of the waypoint. + jet (cx.tower.jet, optional): Target jet. Defaults to jet_test. + + Returns: + cx.tower.jet.waypoint: Waypoint in ``preparing`` state with + ``is_destination=True``. + """ + if jet is None: + jet = self.jet_test + waypoint = self.JetWaypoint.create( + { + "name": name, + "jet_id": jet.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + waypoint.write({"state": "preparing", "is_destination": True}) + return waypoint + + def test_check_is_destination_single_allowed(self): + """ + Preparing one destination waypoint for a jet via prepare() is valid. + """ + waypoint = self._make_destination_waypoint("Destination Waypoint") + self.assertTrue(waypoint.is_destination) + + def test_check_is_destination_different_jets_allowed(self): + """ + Each jet may independently have its own destination waypoint. + """ + self._make_destination_waypoint("Destination Jet Test", jet=self.jet_test) + waypoint_other = self._make_destination_waypoint( + "Destination Jet Odoo", jet=self.jet_odoo + ) + self.assertTrue(waypoint_other.is_destination) + + def test_check_is_destination_false_ignored(self): + """ + Waypoints with is_destination=False are never checked, even when + another destination already exists for the same jet. + """ + self._make_destination_waypoint("Existing Destination") + # Creating a non-destination waypoint must not raise. + non_dest = self.JetWaypoint.create( + { + "name": "Non Destination", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + "is_destination": False, + } + ) + self.assertFalse(non_dest.is_destination) + + def _assert_state_blocks_destination(self, state): + """ + Helper: create a waypoint, force it into ``state``, then assert that + writing ``is_destination=True`` raises a ValidationError. + + Args: + state (str): Waypoint state to test. + """ + waypoint = self.JetWaypoint.create( + { + "name": f"Waypoint in {state}", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + waypoint.write({"state": state}) + with self.assertRaises(ValidationError): + waypoint.write({"is_destination": True}) + + def test_check_is_destination_draft_state_raises(self): + """ + Setting is_destination=True directly on a waypoint in the 'draft' state + must raise a ValidationError. + Use prepare(is_destination=True) to designate a destination waypoint. + """ + self._assert_state_blocks_destination("draft") + + def test_check_is_destination_error_state_raises(self): + """ + Setting is_destination=True on a waypoint in the 'error' state + must raise a ValidationError. + """ + self._assert_state_blocks_destination("error") + + def test_check_is_destination_leaving_state_raises(self): + """ + Setting is_destination=True on a waypoint in the 'leaving' state + must raise a ValidationError. + """ + self._assert_state_blocks_destination("leaving") + + def test_check_is_destination_deleting_state_raises(self): + """ + Setting is_destination=True on a waypoint in the 'deleting' state + must raise a ValidationError. + """ + self._assert_state_blocks_destination("deleting") + + def test_check_is_destination_deleted_state_raises(self): + """ + Setting is_destination=True on a waypoint in the 'deleted' state + must raise a ValidationError. + """ + self._assert_state_blocks_destination("deleted") + + def test_check_is_destination_duplicate_on_create_raises(self): + """ + Setting is_destination via prepare() then trying to prepare a second + destination for the same jet must raise a ValidationError. + """ + self._make_destination_waypoint("First Destination") + second = self.JetWaypoint.create( + { + "name": "Second Destination", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + with self.assertRaises(ValidationError): + second.write({"state": "ready", "is_destination": True}) + + def test_check_is_destination_duplicate_on_write_raises(self): + """ + Writing is_destination=True on a second ready waypoint for the same jet + must raise a ValidationError. + """ + self._make_destination_waypoint("Existing Destination") + second = self.JetWaypoint.create( + { + "name": "Second Waypoint", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + second.write({"state": "ready"}) + with self.assertRaises(ValidationError): + second.write({"is_destination": True}) + + def test_check_is_destination_duplicate_within_same_batch_raises(self): + """ + Writing is_destination=True on two ready waypoints for the same jet + in a single write() call must raise a ValidationError. + + Both records are excluded from the DB search (neither is a destination + yet), so the constraint must also detect duplicates within the batch. + """ + wp1 = self.JetWaypoint.create( + { + "name": "Batch Destination 1", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + wp2 = self.JetWaypoint.create( + { + "name": "Batch Destination 2", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + (wp1 | wp2).write({"state": "ready"}) + with self.assertRaises(ValidationError): + (wp1 | wp2).write({"is_destination": True}) + + # ------------------------------------ + # --- unlink destination guard tests - + # ------------------------------------ + + @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint") + def test_unlink_destination_waypoint_raises(self): + """ + Deleting a waypoint with is_destination=True must raise a + ValidationError regardless of state, to prevent the jet from being + stranded mid-flight while a leave plan is still running. + """ + waypoint = self._make_destination_waypoint("Active Destination") + with self.assertRaises(ValidationError): + waypoint.unlink() + + @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint") + def test_unlink_destination_waypoint_no_raise_context_logs(self): + """ + When waypoint_no_raise_on_delete=True is set in context, deleting a + destination waypoint must not raise but must log the error and skip + the record. + """ + waypoint = self._make_destination_waypoint("Active Destination No Raise") + waypoint.with_context(waypoint_no_raise_on_delete=True).unlink() + # Record must still exist — it was skipped, not deleted. + self.assertTrue(waypoint.exists()) + + def test_unlink_non_destination_ready_waypoint_allowed(self): + """ + Deleting a ready waypoint that is NOT a destination must still work. + """ + waypoint = self.JetWaypoint.create( + { + "name": "Ready Non-Destination", + "jet_id": self.jet_test.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + waypoint.write({"state": "ready"}) + waypoint.unlink() + self.assertFalse(waypoint.exists()) diff --git a/addons/cetmix_tower_server/tests/test_jet_waypoint_access.py b/addons/cetmix_tower_server/tests/test_jet_waypoint_access.py new file mode 100644 index 0000000..6156870 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_waypoint_access.py @@ -0,0 +1,970 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetWaypointAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Waypoint model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Use existing users from common.py (cls.user, cls.manager, cls.root) + # Create additional manager for multi-manager tests + cls.manager2 = cls.Users.create( + { + "name": "Test Manager 2", + "login": "test_manager_2", + "email": "test_manager_2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_access_jet_user_ids(self): + """Test Manager: Read when user is added in jet's user_ids""" + # Use existing jet and add manager to user_ids + self.jet_test.write({"user_ids": [(4, self.manager.id)]}) + jet = self.jet_test + + record = self.JetWaypoint.create( + { + "name": "Waypoint with User Access", + "reference": "waypoint_user_access", + "jet_id": jet.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + + records = self.JetWaypoint.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should be able to read when added to jet's user_ids", + ) + + def test_manager_read_access_jet_manager_ids(self): + """Test Manager: Read when user is added in jet's manager_ids""" + # Use existing jet and add manager to manager_ids + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + jet = self.jet_test + + record = self.JetWaypoint.create( + { + "name": "Waypoint with Manager Access", + "reference": "waypoint_manager_access", + "jet_id": jet.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + + records = self.JetWaypoint.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should be able to read when added to jet's manager_ids", + ) + + def test_manager_read_no_access_root_level(self): + """Test Manager: No read access for Root level (3) even with jet access""" + # Use existing jet and add manager to manager_ids (has jet access) + self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) + jet = self.jet_test + + # Create waypoint template with Root level + waypoint_template_root = self.JetWaypointTemplate.create( + { + "name": "Root Level Template", + "reference": "root_level_template", + "jet_template_id": self.jet_template_test.id, + "access_level": "3", # Root level + } + ) + + record = self.JetWaypoint.create( + { + "name": "Root Level Waypoint", + "reference": "root_level_waypoint", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template_root.id, + "access_level": "3", # Explicitly set Root level + } + ) + + records = self.JetWaypoint.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read access_level='3' " + "even when in jet's manager_ids (Root level blocks access)", + ) + + def test_manager_read_no_access_not_in_jet(self): + """Test Manager: No read access when not in jet's Users or Managers""" + # Use existing jet (manager not in user_ids/manager_ids) + jet = self.jet_test + + record = self.JetWaypoint.create( + { + "name": "No Access Waypoint", + "reference": "no_access_waypoint", + "jet_id": jet.id, + "waypoint_template_id": self.waypoint_template.id, + } + ) + + records = self.JetWaypoint.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in jet's user_ids or manager_ids", + ) + + # ====================== + # Manager Write/Create Access Tests + # ====================== + + def test_manager_write_access_level_and_template_manager_ids(self): + """Test Manager: Write when access_level <= 2 AND in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.create( + { + "name": "Manager Can Write", + "reference": "manager_can_write", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + # Manager should be able to write + try: + record.with_user(self.manager).write({"name": "Updated Name"}) + record.invalidate_recordset() + self.assertEqual( + record.name, "Updated Name", "Manager should be able to update" + ) + except AccessError: + self.fail("Manager should be able to update when in template's manager_ids") + + def test_manager_write_forbidden_not_in_template_manager_ids(self): + """Test Manager: No write when not in template's manager_ids""" + # Create jet template without manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": False, + } + ) + + # Create jet with manager in manager_ids (for read access) + jet = self._create_jet( + name="No Write Jet", + reference="no_write_jet", + template=jet_template, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.create( + { + "name": "No Write Access", + "reference": "no_write_access", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_write_forbidden_root_level(self): + """Test Manager: No write when access_level is Root (3)""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template with Root level + waypoint_template_root = self.JetWaypointTemplate.create( + { + "name": "Root Level Template", + "reference": "root_level_template", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + record = self.JetWaypoint.create( + { + "name": "Root Level No Write", + "reference": "root_level_no_write", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template_root.id, + "access_level": "3", # Explicitly set Root level + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_create_access(self): + """Test Manager: Create when access_level <= 2 AND in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + # Try to create without being in template's manager_ids - should fail + jet_template_no_access = self.JetTemplate.create( + { + "name": "No Access Template", + "reference": "no_access_template", + "manager_ids": False, + } + ) + + jet_no_access = self._create_jet( + name="No Access Jet", + reference="no_access_jet", + template=jet_template_no_access, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], # Manager in jet but not template + ) + + waypoint_template_no_access = self.JetWaypointTemplate.create( + { + "name": "No Access Waypoint Template", + "reference": "no_access_waypoint_template", + "jet_template_id": jet_template_no_access.id, + } + ) + + with self.assertRaises(AccessError): + self.JetWaypoint.with_user(self.manager).create( + { + "name": "Create Fail", + "reference": "create_fail", + "jet_id": jet_no_access.id, + "waypoint_template_id": waypoint_template_no_access.id, + } + ) + + # Create with manager in template's manager_ids - should succeed + try: + record = self.JetWaypoint.with_user(self.manager).create( + { + "name": "Create Success", + "reference": "create_success", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + records = self.JetWaypoint.search([("id", "=", record.id)]) + self.assertEqual(len(records), 1, "Manager should be able to create") + except AccessError: + self.fail("Manager should be able to create when in template's manager_ids") + + # ====================== + # Manager Delete Access Tests + # ====================== + + def test_manager_delete_own_record(self): + """Test Manager: Delete own record when in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.with_user(self.manager).create( + { + "name": "My Record", + "reference": "my_record", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + try: + record.with_user(self.manager).unlink() + records = self.JetWaypoint.search([("id", "=", record.id)]) + self.assertEqual( + len(records), 0, "Manager should be able to delete own record" + ) + except AccessError: + self.fail("Manager should be able to delete own record") + + def test_manager_delete_not_creator(self): + """Test Manager: Cannot delete record created by another user""" + # Create jet template with both managers in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.with_user(self.manager2).create( + { + "name": "Other's Record", + "reference": "others_record", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + # Manager1 cannot delete Manager2's record + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + def test_manager_delete_not_in_template_manager_ids(self): + """Test Manager: Cannot delete when not in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Delete Not In Template Jet", + reference="delete_not_in_template_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.with_user(self.manager).create( + { + "name": "Removed Manager", + "reference": "removed_manager", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + # Remove from template's manager_ids + jet_template.write({"manager_ids": False}) + + # Cannot delete anymore + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + def test_manager_delete_root_level(self): + """Test Manager: Cannot delete Root level record""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template with Root level + waypoint_template_root = self.JetWaypointTemplate.create( + { + "name": "Root Level Template", + "reference": "root_level_template", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + # Create record with Root level as root (default user) + record = self.JetWaypoint.create( + { + "name": "Root Level Delete", + "reference": "root_level_delete", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template_root.id, + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + # ====================== + # Root Access Tests + # ====================== + + def test_root_full_access(self): + """ + Test Root: Full CRUD access regardless of access_level or creator. + + Root has unrestricted access to all records via security rule + [(1, '=', 1)], so we test: + - Create records with all access levels + - Read records with all access levels + - Write to records with all access levels + - Delete records regardless of creator + """ + # Create jet template for testing + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + } + ) + + # Create jet from this template with unique name + jet = self._create_jet( + name="Write Access Jet", + reference="write_access_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Test CRUD operations for all access levels (only Manager and Root exist) + for access_level in ["2", "3"]: + # Create waypoint template with specific access level + waypoint_template = self.JetWaypointTemplate.create( + { + "name": f"Template Level {access_level}", + "reference": f"template_level_{access_level}", + "jet_template_id": jet_template.id, + "access_level": access_level, + } + ) + + # Root can create any level + record = self.JetWaypoint.with_user(self.root).create( + { + "name": f"Root Level {access_level}", + "reference": f"root_level_{access_level}", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + # Root can read any level + records = self.JetWaypoint.with_user(self.root).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + f"Root should be able to read access_level={access_level}", + ) + + # Root can write any level + record.with_user(self.root).write( + {"name": f"Root Updated Level {access_level}"} + ) + record.invalidate_recordset() + self.assertEqual( + record.name, + f"Root Updated Level {access_level}", + f"Root should be able to update access_level={access_level}", + ) + + # Test Root can delete records created by other users + # Add manager to template's manager_ids so they can create the record + jet_template.write({"manager_ids": [(4, self.manager.id)]}) + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Manager Template", + "reference": "manager_template", + "jet_template_id": jet_template.id, + } + ) + manager_record = self.JetWaypoint.with_user(self.manager).create( + { + "name": "Manager's Record", + "reference": "managers_record", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + manager_record.with_user(self.root).unlink() + records = self.JetWaypoint.with_user(self.root).search( + [("id", "=", manager_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Root should be able to delete records from any creator", + ) + + # ====================== + # Edge Cases + # ====================== + + def test_access_level_changes_visibility(self): + """Test that changing access_level affects visibility""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet with manager in manager_ids with unique name + jet = self._create_jet( + name="Access Level Changes Jet", + reference="access_level_changes_jet", + template=jet_template, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], + ) + + # Create waypoint template with Manager level + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + record = self.JetWaypoint.create( + { + "name": "Changing Level", + "reference": "changing_level", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + } + ) + + # Manager can read + records = self.JetWaypoint.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 1, "Manager should read level 2") + + # Change template to Root level + waypoint_template.write({"access_level": "3"}) + # Update waypoint's access_level since it's stored and doesn't auto-update + record.write({"access_level": "3"}) + record.invalidate_recordset() + + # Manager cannot read anymore + records = self.JetWaypoint.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 0, "Manager should not read level 3") + + def test_manager_prepare_forbidden_no_write_access(self): + """Test Manager: Cannot prepare waypoint without write access""" + # Create jet template without manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": False, + } + ) + + # Create jet with manager in manager_ids (for read access) + jet = self._create_jet( + name="Prepare Forbidden Jet", + reference="prepare_forbidden_jet", + template=jet_template, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.create( + { + "name": "Prepare Forbidden", + "reference": "prepare_forbidden", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + "state": "draft", + } + ) + + # Manager should not be able to prepare without write access + with self.assertRaises(AccessError): + record.with_user(self.manager).prepare() + + def test_manager_prepare_forbidden_root_level(self): + """Test Manager: Cannot prepare waypoint with Root level""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template + jet = self._create_jet( + name="Prepare Root Level Jet", + reference="prepare_root_level_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template with Root level + waypoint_template_root = self.JetWaypointTemplate.create( + { + "name": "Root Level Template", + "reference": "root_level_template", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + record = self.JetWaypoint.create( + { + "name": "Root Level Prepare", + "reference": "root_level_prepare", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template_root.id, + "access_level": "3", # Explicitly set Root level + "state": "draft", + } + ) + + # Manager should not be able to prepare Root level waypoint + with self.assertRaises(AccessError): + record.with_user(self.manager).prepare() + + def test_manager_fly_to_forbidden_no_write_access(self): + """Test Manager: Cannot fly_to waypoint without write access""" + # Create jet template without manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": False, + } + ) + + # Create jet with manager in manager_ids (for read access) + jet = self._create_jet( + name="Fly To Forbidden Jet", + reference="fly_to_forbidden_jet", + template=jet_template, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.create( + { + "name": "Fly To Forbidden", + "reference": "fly_to_forbidden", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + "state": "ready", + } + ) + + # Manager should not be able to fly_to without write access + with self.assertRaises(AccessError): + record.with_user(self.manager).fly_to() + + def test_manager_fly_to_forbidden_root_level(self): + """Test Manager: Cannot fly_to waypoint with Root level""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create jet from this template + jet = self._create_jet( + name="Fly To Root Level Jet", + reference="fly_to_root_level_jet", + template=jet_template, + server=self.server_test_1, + ) + + # Create waypoint template with Root level + waypoint_template_root = self.JetWaypointTemplate.create( + { + "name": "Root Level Template", + "reference": "root_level_template", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + record = self.JetWaypoint.create( + { + "name": "Root Level Fly To", + "reference": "root_level_fly_to", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template_root.id, + "access_level": "3", # Explicitly set Root level + "state": "ready", + } + ) + + # Manager should not be able to fly_to Root level waypoint + with self.assertRaises(AccessError): + record.with_user(self.manager).fly_to() + + def test_manager_prepare_success_with_write_access(self): + """Test Manager: Can prepare waypoint with write access""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Ensure manager has server access + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + + # Create jet from this template with manager in manager_ids + jet = self._create_jet( + name="Prepare Success Jet", + reference="prepare_success_jet", + template=jet_template, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.create( + { + "name": "Prepare Success", + "reference": "prepare_success", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + "state": "draft", + } + ) + + # Manager should be able to prepare with write access + try: + result = record.with_user(self.manager).prepare() + self.assertTrue(result, "Manager should be able to prepare") + record.invalidate_recordset() + # State should be ready (no plan_create_id) + self.assertEqual(record.state, "ready", "State should be ready") + except AccessError: + self.fail( + "Manager should be able to prepare when in template's manager_ids" + ) + + def test_manager_fly_to_success_with_write_access(self): + """Test Manager: Can fly_to waypoint with write access""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Ensure manager has server access + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + + # Create jet from this template with manager in manager_ids + jet = self._create_jet( + name="Fly To Success Jet", + reference="fly_to_success_jet", + template=jet_template, + server=self.server_test_1, + manager_ids=[(4, self.manager.id)], + ) + + # Create waypoint template + waypoint_template = self.JetWaypointTemplate.create( + { + "name": "Test Waypoint Template", + "reference": "test_waypoint_template", + "jet_template_id": jet_template.id, + } + ) + + record = self.JetWaypoint.create( + { + "name": "Fly To Success", + "reference": "fly_to_success", + "jet_id": jet.id, + "waypoint_template_id": waypoint_template.id, + "state": "ready", + } + ) + + # Manager should be able to fly_to with write access + try: + result = record.with_user(self.manager).fly_to() + self.assertTrue(result, "Manager should be able to fly_to") + record.invalidate_recordset() + # State should be current (no previous waypoint, no plan_arrive_id) + self.assertEqual(record.state, "current", "State should be current") + except AccessError: + self.fail("Manager should be able to fly_to when in template's manager_ids") diff --git a/addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py b/addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py new file mode 100644 index 0000000..0102a5c --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py @@ -0,0 +1,504 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerJetWaypointTemplateAccess(TestTowerJetsCommon): + """ + Test access rules for Jet Waypoint Template model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Use existing users from common.py (cls.user, cls.manager, cls.root) + # Create additional manager for multi-manager tests + cls.manager2 = cls.Users.create( + { + "name": "Test Manager 2", + "login": "test_manager_2", + "email": "test_manager_2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + # ====================== + # Manager Read Access Tests + # ====================== + + def test_manager_read_access_user_ids(self): + """Test Manager: Read when user is added in template's user_ids""" + # Create jet template with manager in user_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "user_ids": [(4, self.manager.id)], + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "Waypoint with User Access", + "reference": "waypoint_user_access", + "jet_template_id": jet_template.id, + "access_level": "2", # Manager level + } + ) + + records = self.JetWaypointTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should be able to read when added to template's user_ids", + ) + + def test_manager_read_access_manager_ids(self): + """Test Manager: Read when user is added in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "Waypoint with Manager Access", + "reference": "waypoint_manager_access", + "jet_template_id": jet_template.id, + "access_level": "2", # Manager level + } + ) + + records = self.JetWaypointTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should be able to read when added to template's manager_ids", + ) + + def test_manager_read_no_access_root_level(self): + """ + Test Manager: No read access for Root level (3) + without user_ids/manager_ids + """ + # Create jet template without manager access + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "user_ids": False, + "manager_ids": False, + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "Root Level Waypoint", + "reference": "root_level_waypoint", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + records = self.JetWaypointTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read access_level='3' " + "when not in template's user_ids or manager_ids", + ) + + def test_manager_read_no_access_not_in_template(self): + """Test Manager: No read access when not in template's Users or Managers""" + # Create jet template without manager access + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "user_ids": False, + "manager_ids": False, + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "No Access Waypoint", + "reference": "no_access_waypoint", + "jet_template_id": jet_template.id, + "access_level": "2", # Manager level + } + ) + + records = self.JetWaypointTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not read when not in template's user_ids or manager_ids", + ) + + # ====================== + # Manager Write/Create Access Tests + # ====================== + + def test_manager_write_access_level_and_manager_ids(self): + """Test Manager: Write when access_level <= 2 AND in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "Manager Can Write", + "reference": "manager_can_write", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + # Manager should be able to write + try: + record.with_user(self.manager).write({"name": "Updated Name"}) + record.invalidate_recordset() + self.assertEqual( + record.name, "Updated Name", "Manager should be able to update" + ) + except AccessError: + self.fail("Manager should be able to update when in template's manager_ids") + + def test_manager_write_forbidden_not_in_manager_ids(self): + """Test Manager: No write when not in template's manager_ids""" + # Create jet template with manager only in user_ids, not manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "user_ids": [(4, self.manager.id)], # Only in user_ids + "manager_ids": False, + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "No Write Access", + "reference": "no_write_access", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_write_forbidden_root_level(self): + """Test Manager: No write when access_level is Root (3)""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + record = self.JetWaypointTemplate.create( + { + "name": "Root Level No Write", + "reference": "root_level_no_write", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).write({"name": "Should Fail"}) + + def test_manager_create_access(self): + """Test Manager: Create when access_level <= 2 AND in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Try to create without being in manager_ids - should fail + jet_template_no_access = self.JetTemplate.create( + { + "name": "No Access Template", + "reference": "no_access_template", + "manager_ids": False, + } + ) + + with self.assertRaises(AccessError): + self.JetWaypointTemplate.with_user(self.manager).create( + { + "name": "Create Fail", + "reference": "create_fail", + "jet_template_id": jet_template_no_access.id, + "access_level": "2", + } + ) + + # Create with manager in template's manager_ids - should succeed + try: + record = self.JetWaypointTemplate.with_user(self.manager).create( + { + "name": "Create Success", + "reference": "create_success", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + records = self.JetWaypointTemplate.search([("id", "=", record.id)]) + self.assertEqual(len(records), 1, "Manager should be able to create") + except AccessError: + self.fail("Manager should be able to create when in template's manager_ids") + + # ====================== + # Manager Delete Access Tests + # ====================== + + def test_manager_delete_own_record(self): + """Test Manager: Delete own record when in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + record = self.JetWaypointTemplate.with_user(self.manager).create( + { + "name": "My Record", + "reference": "my_record", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + try: + record.with_user(self.manager).unlink() + records = self.JetWaypointTemplate.search([("id", "=", record.id)]) + self.assertEqual( + len(records), 0, "Manager should be able to delete own record" + ) + except AccessError: + self.fail("Manager should be able to delete own record") + + def test_manager_delete_not_creator(self): + """Test Manager: Cannot delete record created by another user""" + # Create jet template with both managers in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], + } + ) + + record = self.JetWaypointTemplate.with_user(self.manager2).create( + { + "name": "Other's Record", + "reference": "others_record", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + # Manager1 cannot delete Manager2's record + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + def test_manager_delete_not_in_manager_ids(self): + """Test Manager: Cannot delete when not in template's manager_ids""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + record = self.JetWaypointTemplate.with_user(self.manager).create( + { + "name": "Removed Manager", + "reference": "removed_manager", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + # Remove from manager_ids + jet_template.write({"manager_ids": False}) + + # Cannot delete anymore + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + def test_manager_delete_root_level(self): + """Test Manager: Cannot delete Root level record""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create record with Root level as root (default user) + record = self.JetWaypointTemplate.create( + { + "name": "Root Level Delete", + "reference": "root_level_delete", + "jet_template_id": jet_template.id, + "access_level": "3", # Root level + } + ) + + with self.assertRaises(AccessError): + record.with_user(self.manager).unlink() + + # ====================== + # Root Access Tests + # ====================== + + def test_root_full_access(self): + """ + Test Root: Full CRUD access regardless of access_level or creator. + + Root has unrestricted access to all records via security rule + [(1, '=', 1)], so we test: + - Create records with all access levels + - Read records with all access levels + - Write to records with all access levels + - Delete records regardless of creator + """ + # Create jet template for testing + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + } + ) + + # Test CRUD operations for all access levels (only Manager and Root exist) + for access_level in ["2", "3"]: + # Root can create any level + record = self.JetWaypointTemplate.with_user(self.root).create( + { + "name": f"Root Level {access_level}", + "reference": f"root_level_{access_level}", + "jet_template_id": jet_template.id, + "access_level": access_level, + } + ) + + # Root can read any level + records = self.JetWaypointTemplate.with_user(self.root).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + f"Root should be able to read access_level={access_level}", + ) + + # Root can write any level + record.with_user(self.root).write( + {"name": f"Root Updated Level {access_level}"} + ) + record.invalidate_recordset() + self.assertEqual( + record.name, + f"Root Updated Level {access_level}", + f"Root should be able to update access_level={access_level}", + ) + + # Test Root can delete records created by other users + # Add manager to template's manager_ids so they can create the record + jet_template.write({"manager_ids": [(4, self.manager.id)]}) + manager_record = self.JetWaypointTemplate.with_user(self.manager).create( + { + "name": "Manager's Record", + "reference": "managers_record", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + manager_record.with_user(self.root).unlink() + records = self.JetWaypointTemplate.with_user(self.root).search( + [("id", "=", manager_record.id)] + ) + self.assertEqual( + len(records), + 0, + "Root should be able to delete records from any creator", + ) + + # ====================== + # Edge Cases + # ====================== + + def test_access_level_changes_visibility(self): + """Test that changing access_level affects visibility""" + # Create jet template with manager in manager_ids + jet_template = self.JetTemplate.create( + { + "name": "Test Template", + "reference": "test_template", + "manager_ids": [(4, self.manager.id)], + } + ) + + # Create with Manager level + record = self.JetWaypointTemplate.create( + { + "name": "Changing Level", + "reference": "changing_level", + "jet_template_id": jet_template.id, + "access_level": "2", + } + ) + + # Manager can read + records = self.JetWaypointTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 1, "Manager should read level 2") + + # Change to Root level + record.write({"access_level": "3"}) + + # Manager cannot read anymore + records = self.JetWaypointTemplate.with_user(self.manager).search( + [("id", "=", record.id)] + ) + self.assertEqual(len(records), 0, "Manager should not read level 3") diff --git a/addons/cetmix_tower_server/tests/test_key.py b/addons/cetmix_tower_server/tests/test_key.py new file mode 100644 index 0000000..c633417 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_key.py @@ -0,0 +1,919 @@ +from odoo.exceptions import AccessError, ValidationError + +from .common import TestTowerCommon + + +class TestTowerKey(TestTowerCommon): + """Test class for tower key.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create another manager for testing + cls.manager_2 = cls.Users.create( + { + "name": "Second Manager", + "login": "manager2", + "email": "manager2@test.com", + "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], + } + ) + + # Create test servers + cls.server_1 = cls.Server.create( + { + "name": "Test Server 1", + "ip_v4_address": "192.168.1.1", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + cls.server_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "192.168.1.2", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + } + ) + cls.test_key = cls.Key.create( + {"name": "Test Key", "key_type": "s", "secret_value": "test value"} + ) + + def test_key_creation(self): + """ + Test key creation. + We override create method so need to check if reference is generated properly + """ + + # -- 1-- + # Check new key values + key_one = self.Key.create( + {"name": " test key meme ", "secret_value": "test value", "key_type": "s"} + ) + self.assertEqual( + key_one.reference, "test_key_meme", "Reference must be 'test_key_meme'" + ) + self.assertEqual( + key_one.name, + "test key meme", + "Trailing and leading whitespaces must be removed from name", + ) + + def test_extract_key_strings(self): + """Check if key strings are extracted properly""" + code = ( + "Hey #!cxtower.secret.MEME_KEY!# & Doge #!cxtower.secret.DOGE_KEY !# so " + "like #!cxtower.secret.MEME_KEY!#!\n" + "They make #!memes together." + "And this is another string for the same #!cxtower.secret.MEME_KEY !#" + ) + key_strings = self.Key._extract_key_strings(code) + self.assertEqual(len(key_strings), 3, "Must be 3 key stings") + self.assertIn( + "#!cxtower.secret.MEME_KEY!#", + key_strings, + "Key string must be in key strings", + ) + self.assertIn( + "#!cxtower.secret.DOGE_KEY !#", + key_strings, + "Key string must be in key strings", + ) + self.assertIn( + "#!cxtower.secret.MEME_KEY !#", + key_strings, + "Key string must be in key strings", + ) + + def test_parse_key_string(self): + """Check if key string is parsed correctly""" + + # Test global key + doge_key = self.Key.create( + { + "name": "doge key", + "reference": "DOGE_KEY", + "secret_value": "Doge dog", + "key_type": "s", + } + ) + key_string = "#!cxtower.secret.DOGE_KEY!#" + key_value = self.Key._parse_key_string(key_string) + self.assertEqual(key_value, "Doge dog", "Key value doesn't match") + + # Test the same key string but with some spaces before the key terminator + key_string = "#!cxtower.secret.DOGE_KEY !#" + key_value = self.Key._parse_key_string(key_string) + self.assertEqual(key_value, "Doge dog", "Key value doesn't match") + + # Test partner specific key + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge partner", + "partner_id": self.user_bob.partner_id.id, + } + ) + # compose kwargs + kwargs = { + "partner_id": self.user_bob.partner_id.id, + "server_id": self.server_test_1.id, + } + key_value = self.Key._parse_key_string(key_string, **kwargs) + self.assertEqual(key_value, "Doge partner", "Key value doesn't match") + + # Test server specific key + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge server", + "server_id": self.server_test_1.id, + } + ) + key_value = self.Key._parse_key_string(key_string, **kwargs) + + # Test server and partner specific key + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge server and partner", + "server_id": self.server_test_1.id, + "partner_id": self.user_bob.partner_id.id, + } + ) + key_value = self.Key._parse_key_string(key_string, **kwargs) + self.assertEqual( + key_value, "Doge server and partner", "Key value doesn't match" + ) + + # Test missing key + key_string = "#!cxtower.secret.ANOTHER_KEY!#" + key_value = self.Key._parse_key_string(key_string) + self.assertIsNone(key_value, "Key value must be 'None'") + + # Test missformatted key + key_string = "#!cxtower.ANOTHER_KEY!#" + key_value = self.Key._parse_key_string(key_string) + self.assertIsNone(key_value, "Key value must be 'None'") + + # Test another missformatted key + key_string = "#!cxtower.notasecret.DOGE_KEY!#" + key_value = self.Key._parse_key_string(key_string) + self.assertIsNone(key_value, "Key value must be 'None'") + + def test_resolve_key(self): + """Check generic key resolver""" + self.Key.create( + { + "name": "doge key", + "reference": "DOGE_KEY", + "secret_value": "Doge dog", + "key_type": "s", + } + ) + + # Existing key + key_value = self.Key._resolve_key("secret", "DOGE_KEY") + self.assertEqual(key_value, "Doge dog", "Key value doesn't match") + + # Non existing key + key_value = self.Key._resolve_key("server", "PEPE_KEY") + self.assertIsNone(key_value, "Key value must be 'None'") + + def test_resolve_key_type_secret(self): + """Check 'secret' type key resolver""" + doge_key = self.Key.create( + { + "name": "doge key", + "reference": "DOGE_KEY", + "key_type": "s", + } + ) + + # 1. Test server and partner specific key + server_partner_value = self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge server and partner", + "server_id": self.server_test_1.id, + "partner_id": self.user_bob.partner_id.id, + } + ) + kwargs = { + "partner_id": self.user_bob.partner_id.id, + "server_id": self.server_test_1.id, + } + key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) + self.assertEqual( + key_value, "Doge server and partner", "Key value doesn't match" + ) + + # 2. Global key + doge_key.write({"secret_value": "Doge dog"}) + key_value = self.Key._resolve_key_type_secret("DOGE_KEY") + self.assertEqual(key_value, "Doge dog", "Key value doesn't match") + + # 3. Non existing key + key_value = self.Key._resolve_key_type_secret("PEPE_KEY") + self.assertIsNone(key_value, "Key value must be 'None'") + + # 4. Partner specific key + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge partner", + "partner_id": self.user_bob.partner_id.id, + } + ) + kwargs = { + "partner_id": self.user_bob.partner_id.id, + } + key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) + self.assertEqual(key_value, "Doge partner", "Key value doesn't match") + + # 5. Test server specific key + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge server", + "server_id": self.server_test_1.id, + } + ) + kwargs = { + "server_id": self.server_test_1.id, + } + key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) + self.assertEqual(key_value, "Doge server", "Key value doesn't match") + + # 6. Test with non matching partner. Should return server specific value + kwargs = { + "partner_id": self.user.partner_id.id, + "server_id": self.server_test_1.id, + } + key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) + self.assertEqual(key_value, "Doge server", "Key value doesn't match") + + # 7. Change partner in the server-partner specific value. + # Should return server specific value + server_partner_value.write({"partner_id": self.manager.partner_id.id}) + kwargs = { + "server_id": self.server_test_1.id, + } + key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) + self.assertEqual(key_value, "Doge server", "Key value doesn't match") + + # 8. Test with the global key again + key_value = self.Key._resolve_key_type_secret("DOGE_KEY") + self.assertEqual(key_value, "Doge dog", "Key value doesn't match") + + def test_parse_code(self): + """Test code parsing""" + + def check_parsed_code( + code, code_parsed_expected, expected_key_values=None, **kwargs + ): + """Helper function for code parse testing + + Args: + code (Text): code to parse + code_parsed_expected (Text): expected parsed code + expected_key_values (list, optional): key values that are expected + to be returned. Defaults to None. + """ + code_parsed = self.Key._parse_code(code, **kwargs) + self.assertEqual( + code_parsed, + code_parsed_expected, + msg="Parsed code doesn't match expected one", + ) + if expected_key_values: + result = self.Key._parse_code_and_return_key_values(code, **kwargs) + code_parsed = result["code"] + key_values = result["key_values"] + self.assertEqual( + code_parsed, + code_parsed_expected, + msg="Parsed code doesn't match expected one", + ) + self.assertEqual( + len(key_values), + len(expected_key_values), + "Number of key values doesn't match number of expected ones", + ) + for expected_value in expected_key_values: + self.assertIn( + expected_value, + key_values, + f"Value {expected_value} must be in the returned key values", + ) + + # Create new key + self.Key.create( + { + "name": "Meme key", + "reference": "MEME_KEY", + "secret_value": "Pepe Frog", + "key_type": "s", + } + ) + + # Check key parser + + # 1 - single line + + code = "The key to understand this meme is #!cxtower.secret.MEME_KEY!#" + code_parsed_expected = "The key to understand this meme is Pepe Frog" + expected_key_values = ["Pepe Frog"] + check_parsed_code(code, code_parsed_expected, expected_key_values) + + # 2 - multi line + code = "Welcome #!cxtower.secret.MEME_KEY!#\nNew hero of this city!" + code_parsed_expected = "Welcome Pepe Frog\nNew hero of this city!" + expected_key_values = ["Pepe Frog"] + check_parsed_code(code, code_parsed_expected, expected_key_values) + + # 3 - Key not found + code = "Don't mess with #!cxtower.secret.DOGE_LIKE!# He will make you cry" + code_parsed_expected = "Don't mess with None He will make you cry" + expected_key_values = [] + check_parsed_code(code, code_parsed_expected, expected_key_values) + + check_parsed_code(code, code_parsed_expected) + + # 4 - Multi keys + # Create new key + doge_key = self.Key.create( + { + "name": "doge key", + "reference": "DOGE_KEY", + "secret_value": "Doge dog", + "key_type": "s", + } + ) + code = ( + "Hey #!cxtower.secret.MEME_KEY!# & Doge #!cxtower.secret.DOGE_KEY !# so " + "like #!cxtower.secret.MEME_KEY!#!\n" + "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" + "cxtower.secret.DOGE_KEY" + ) + code_parsed_expected = ( + "Hey Pepe Frog & Doge Doge dog so " + "like Pepe Frog!\n" + "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" + "cxtower.secret.DOGE_KEY" + ) + expected_key_values = ["Pepe Frog", "Doge dog"] + check_parsed_code(code, code_parsed_expected, expected_key_values) + + # 5 - Partner specific key + # Create new key for partner Bob + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge wow", + "partner_id": self.user_bob.partner_id.id, + } + ) + # compose kwargs + kwargs = {"partner_id": self.user_bob.partner_id.id} + code_parsed_expected = ( + "Hey Pepe Frog & Doge Doge wow so " + "like Pepe Frog!\n" + "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" + "cxtower.secret.DOGE_KEY" + ) + expected_key_values = ["Pepe Frog", "Doge wow"] + check_parsed_code(code, code_parsed_expected, expected_key_values, **kwargs) + + # 6 - Server specific key + # Create new key for server Test 1 + self.KeyValue.create( + { + "key_id": doge_key.id, + "secret_value": "Doge much", + "server_id": self.server_test_1.id, + } + ) + # compose kwargs + kwargs = { + "partner_id": self.user_bob.partner_id.id, # not needed but may keep it + "server_id": self.server_test_1.id, + } + code_parsed_expected = ( + "Hey Pepe Frog & Doge Doge much so " + "like Pepe Frog!\n" + "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" + "cxtower.secret.DOGE_KEY" + ) + expected_key_values = ["Pepe Frog", "Doge much"] + check_parsed_code(code, code_parsed_expected, expected_key_values, **kwargs) + + def test_replace_with_spoiler(self): + """Check if secrets are replaced with spoiler correctly""" + + code = ( + "Hey Pepe Frog & Doge Doge much so " + "like Pepe Frog!\n" + "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" + "cxtower.secret.DOGE_KEY" + ) + placeholder = self.Key.SECRET_VALUE_PLACEHOLDER + expected_code = ( + f"Hey {placeholder} & Doge {placeholder} so " + f"like {placeholder}!\n" + "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" + "cxtower.secret.DOGE_KEY" + ) + key_values = ["Pepe Frog", "Doge much"] + + result = self.Key._replace_with_spoiler(code, key_values) + self.assertEqual(result, expected_code, "Result doesn't match expected code") + + # -------------------------------------- + # Check with some random key values now + # Original code should rename unchanged + # -------------------------------------- + + key_values = ["Wow much", "No like"] + result = self.Key._replace_with_spoiler(code, key_values) + self.assertEqual(result, code, "Result doesn't match expected code") + + def test_user_access(self): + """Test that regular users have no access to keys""" + user_key = self.Key.with_user(self.user) + + # Create test key + key = self.Key.create( + {"name": "Test Key", "secret_value": "test value", "key_type": "s"} + ) + + # Test CRUD operations + with self.assertRaises(AccessError): + user_key.create( + {"name": "New Key", "secret_value": "secret", "key_type": "s"} + ) + with self.assertRaises(AccessError): + user_key.browse(key.id).read(["name"]) + with self.assertRaises(AccessError): + user_key.browse(key.id).write({"name": "Updated Name"}) + with self.assertRaises(AccessError): + user_key.browse(key.id).unlink() + + def test_manager_read_access(self): + """Test manager read access rules""" + manager_key = self.Key.with_user(self.manager) + + # Create test keys + key_secret = self.Key.create( + {"name": "Secret Key", "secret_value": "secret value", "key_type": "s"} + ) + key_ssh = self.Key.create( + {"name": "SSH Key", "secret_value": "ssh key", "key_type": "k"} + ) + + # Test read access for secret key - should read (all managers can read secrets) + self.assertTrue(manager_key.search([("id", "=", key_secret.id)])) + + # Test read access for SSH key without server access - should not find + self.assertFalse(manager_key.search([("id", "=", key_ssh.id)])) + + # Add manager to server users and set SSH key - should find SSH key + self.write_and_invalidate( + self.server_1, + **{"user_ids": [(4, self.manager.id)], "ssh_key_id": key_ssh.id}, + ) + self.assertTrue(manager_key.search([("id", "=", key_ssh.id)])) + + # Remove key from server - should not find again + self.server_1.write({"ssh_key_id": False}) + self.assertFalse(manager_key.search([("id", "=", key_ssh.id)])) + + # Add as key user - should find both + key_secret.write({"user_ids": [(4, self.manager.id)]}) + key_ssh.write({"user_ids": [(4, self.manager.id)]}) + self.assertTrue(manager_key.search([("id", "=", key_secret.id)])) + self.assertTrue(manager_key.search([("id", "=", key_ssh.id)])) + + def test_manager_write_access(self): + """Test manager write/create access rules""" + manager_key = self.Key.with_user(self.manager) + + # Create test keys as root and ensure manager is not in manager_ids + key_secret = self.Key.create( + { + "name": "Secret Key", + "secret_value": "secret value", + "key_type": "s", + "manager_ids": [(5, 0)], # Clear manager_ids + } + ) + key_ssh = self.Key.create( + { + "name": "SSH Key", + "secret_value": "ssh key", + "key_type": "k", + "manager_ids": [(5, 0)], # Clear manager_ids + } + ) + + # Try write without being manager - should fail + with self.assertRaises(AccessError): + manager_key.browse(key_secret.id).write({"name": "Updated Secret"}) + with self.assertRaises(AccessError): + manager_key.browse(key_ssh.id).write({"name": "Updated SSH"}) + + # Add as key manager - should write to secret + key_secret.write({"manager_ids": [(4, self.manager.id)]}) + manager_key.browse(key_secret.id).write({"name": "Updated Secret"}) + self.assertEqual(key_secret.name, "Updated Secret") + + # Add as server manager and set SSH key - should write to SSH key + self.server_1.write( + {"manager_ids": [(4, self.manager.id)], "ssh_key_id": key_ssh.id} + ) + manager_key.browse(key_ssh.id).write({"name": "Updated SSH"}) + self.assertEqual(key_ssh.name, "Updated SSH") + + def test_manager_create_access(self): + """Test manager create access rules""" + manager_key = self.Key.with_user(self.manager) + manager_2_key = self.Key.with_user(self.manager_2) + + # Try create secret key when not a manager - should fail + with self.assertRaises(AccessError): + manager_2_key.create( + { + "name": "New Secret", + "secret_value": "secret", + "key_type": "s", + "manager_ids": [(5, 0)], # Prevent automatic manager addition + } + ) + + # Try create SSH key when not a server manager - should fail + with self.assertRaises(AccessError): + manager_2_key.create( + { + "name": "New SSH", + "secret_value": "ssh key", + "key_type": "k", + "manager_ids": [(5, 0)], # Prevent automatic manager addition + } + ) + + # Add as server manager - should create SSH key + self.server_1.write({"manager_ids": [(4, self.manager.id)]}) + new_ssh_key = manager_key.create( + {"name": "New SSH", "secret_value": "ssh key", "key_type": "k"} + ) + # Link key to server + self.server_1.write({"ssh_key_id": new_ssh_key.id}) + self.assertTrue(new_ssh_key.exists()) + + def test_manager_unlink_access(self): + """Test manager unlink access rules""" + manager_key = self.Key.with_user(self.manager) + + # Create keys as root + key_secret = self.Key.create( + {"name": "Secret Key", "secret_value": "secret value", "key_type": "s"} + ) + key_ssh = self.Key.create( + {"name": "SSH Key", "secret_value": "ssh key", "key_type": "k"} + ) + # Link SSH key to server + self.server_1.write({"ssh_key_id": key_ssh.id}) + + # Try delete without being manager and creator - should fail + with self.assertRaises(AccessError): + manager_key.browse(key_secret.id).unlink() + with self.assertRaises(AccessError): + manager_key.browse(key_ssh.id).unlink() + + # Add as manager but not creator - should still fail + key_secret.write({"manager_ids": [(4, self.manager.id)]}) + self.server_1.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_key.browse(key_secret.id).unlink() + with self.assertRaises(AccessError): + manager_key.browse(key_ssh.id).unlink() + + # Create own keys - should delete + own_secret = manager_key.create( + { + "name": "Own Secret", + "secret_value": "secret", + "key_type": "s", + "manager_ids": [(4, self.manager.id)], + } + ) + own_ssh = manager_key.create( + {"name": "Own SSH", "secret_value": "ssh key", "key_type": "k"} + ) + # Link own SSH key to server + self.server_1.write({"ssh_key_id": own_ssh.id}) + + own_secret.unlink() + own_ssh.unlink() + self.assertFalse(own_secret.exists()) + self.assertFalse(own_ssh.exists()) + + def test_root_access(self): + """Test root access rules""" + root_key = self.Key.with_user(self.root) + + # Create + key = root_key.create( + {"name": "Root Key", "secret_value": "root secret", "key_type": "s"} + ) + self.assertTrue(key.exists()) + + # Read + self.assertEqual(root_key.browse(key.id).name, "Root Key") + + # Write + root_key.browse(key.id).write({"name": "Updated Root Key"}) + self.assertEqual(key.name, "Updated Root Key") + + # Delete + key.unlink() + self.assertFalse(key.exists()) + + def test_key_value_user_access(self): + """Test that regular users have no access to key values""" + user_key_value = self.KeyValue.with_user(self.user) + + # Create test key and key value + key = self.Key.create({"name": "Test Key", "key_type": "s"}) + key_value = self.KeyValue.create( + {"key_id": key.id, "secret_value": "test value"} + ) + + # Test CRUD operations + with self.assertRaises(AccessError): + user_key_value.create({"key_id": key.id, "secret_value": "new value"}) + with self.assertRaises(AccessError): + user_key_value.browse(key_value.id).read(["secret_value"]) + with self.assertRaises(AccessError): + user_key_value.browse(key_value.id).write({"secret_value": "updated value"}) + with self.assertRaises(AccessError): + user_key_value.browse(key_value.id).unlink() + + def test_key_value_manager_read_access(self): + """Test manager read access rules for key values""" + manager_key_value = self.KeyValue.with_user(self.manager) + + # Create test key and key values + key = self.Key.create({"name": "Test Key", "key_type": "s"}) + global_value = self.KeyValue.create( + {"key_id": key.id, "secret_value": "global value"} + ) + server_value = self.KeyValue.create( + { + "key_id": key.id, + "secret_value": "server value", + "server_id": self.server_1.id, + } + ) + + # Test read access - should not find without proper access + self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) + self.assertFalse(manager_key_value.search([("id", "=", server_value.id)])) + + # Add as key user - should find global value and server value for that key + key.write({"user_ids": [(4, self.manager.id)]}) + self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) + self.assertTrue(manager_key_value.search([("id", "=", server_value.id)])) + + # Remove from key users + key.write({"user_ids": [(3, self.manager.id)]}) + self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) + self.assertFalse(manager_key_value.search([("id", "=", server_value.id)])) + + # Add as server user - should find server value + self.server_1.write({"user_ids": [(4, self.manager.id)]}) + self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) + self.assertTrue(manager_key_value.search([("id", "=", server_value.id)])) + + def test_key_value_manager_write_access(self): + """Test manager write/create access rules for key values""" + manager_key_value = self.KeyValue.with_user(self.manager) + + # Create test key and key values + key = self.Key.create({"name": "Test Key", "key_type": "s"}) + global_value = self.KeyValue.create( + {"key_id": key.id, "secret_value": "global value"} + ) + server_value = self.KeyValue.create( + { + "key_id": key.id, + "secret_value": "server value", + "server_id": self.server_1.id, + } + ) + + # Try write without proper access - should fail + with self.assertRaises(AccessError): + manager_key_value.browse(global_value.id).write( + {"secret_value": "new value"} + ) + with self.assertRaises(AccessError): + manager_key_value.browse(server_value.id).write( + {"secret_value": "new value"} + ) + + # Add as key manager - should write to global value + key.write({"manager_ids": [(4, self.manager.id)]}) + manager_key_value.browse(global_value.id).write( + {"secret_value": "updated global"} + ) + self.assertEqual( + global_value._get_secret_value("secret_value"), "updated global" + ) + + # Add as server manager - should write to server value + self.server_1.write({"manager_ids": [(4, self.manager.id)]}) + manager_key_value.browse(server_value.id).write( + {"secret_value": "updated server"} + ) + self.assertEqual( + server_value._get_secret_value("secret_value"), "updated server" + ) + + # Test create access + for_bob = manager_key_value.create( + { + "key_id": key.id, + "secret_value": "for bob", + "partner_id": self.user_bob.partner_id.id, + } + ) + self.assertTrue(for_bob.exists()) + + def test_key_value_manager_unlink_access(self): + """Test manager unlink access rules for key values""" + manager_key_value = self.KeyValue.with_user(self.manager) + + # Create test key and key values + key = self.Key.create({"name": "Test Key", "key_type": "s"}) + + # Create values as root + global_value = self.KeyValue.create( + {"key_id": key.id, "secret_value": "global value"} + ) + server_value = self.KeyValue.create( + { + "key_id": key.id, + "secret_value": "server value", + "server_id": self.server_1.id, + } + ) + + # Try delete without proper access - should fail + with self.assertRaises(AccessError): + manager_key_value.browse(global_value.id).unlink() + with self.assertRaises(AccessError): + manager_key_value.browse(server_value.id).unlink() + + # Add as manager but not creator - should still fail + key.write({"manager_ids": [(4, self.manager.id)]}) + self.server_1.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_key_value.browse(global_value.id).unlink() + with self.assertRaises(AccessError): + manager_key_value.browse(server_value.id).unlink() + + # Create own values - should delete + own_partner_value = manager_key_value.create( + { + "key_id": key.id, + "secret_value": "own partner", + "partner_id": self.user_bob.partner_id.id, + } + ) + + # Unlink server value first to avoid constraint error + server_value.unlink() + + # Create server value + own_server_value = manager_key_value.create( + { + "key_id": key.id, + "secret_value": "own server", + "server_id": self.server_1.id, + } + ) + + own_partner_value.unlink() + own_server_value.unlink() + self.assertFalse(own_partner_value.exists()) + self.assertFalse(own_server_value.exists()) + + def test_key_value_root_access(self): + """Test root access rules for key values""" + root_key_value = self.KeyValue.with_user(self.root) + + # Create test key + key = self.Key.create({"name": "Test Key", "key_type": "s"}) + + # Create + value = root_key_value.create({"key_id": key.id, "secret_value": "root value"}) + self.assertTrue(value.exists()) + + # Read + self.assertEqual( + root_key_value.browse(value.id)._get_secret_value("secret_value"), + "root value", + ) + + # Write + root_key_value.browse(value.id).write({"secret_value": "updated value"}) + self.assertEqual(value._get_secret_value("secret_value"), "updated value") + + # Delete + value.unlink() + self.assertFalse(value.exists()) + + def test_key_value_global_unique(self): + """Test global value uniqueness""" + + # Try to create a value for the same key + with self.assertRaises(ValidationError): + another_global_value = self.KeyValue.create( + {"key_id": self.test_key.id, "secret_value": "another test value"} + ) + # + another_global_value.unlink() + + def test_key_value_server_unique(self): + """Test server value uniqueness""" + # Create server tight value + + self.KeyValue.create( + { + "key_id": self.test_key.id, + "secret_value": "server related", + "server_id": self.server_1.id, + } + ) + + # Try create another value for the same server + with self.assertRaises(ValidationError): + self.KeyValue.create( + { + "key_id": self.test_key.id, + "secret_value": "another server related", + "server_id": self.server_1.id, + } + ) + + def test_key_value_partner_unique(self): + """Test partner value uniqueness""" + # Create partner tight value + self.KeyValue.create( + { + "key_id": self.test_key.id, + "secret_value": "partner related", + "partner_id": self.user_bob.partner_id.id, + } + ) + + # Try create another value for the same partner + with self.assertRaises(ValidationError): + self.KeyValue.create( + { + "key_id": self.test_key.id, + "secret_value": "another partner related", + "partner_id": self.user_bob.partner_id.id, + } + ) + + def test_key_value_server_partner_unique(self): + """Test server and partner value uniqueness""" + + # Create server and partner tight value + self.KeyValue.create( + { + "key_id": self.test_key.id, + "secret_value": "server related", + "server_id": self.server_1.id, + "partner_id": self.user_bob.partner_id.id, + } + ) + + # Try create another value for the same server and partner + with self.assertRaises(ValidationError): + self.KeyValue.create( + { + "key_id": self.test_key.id, + "secret_value": "another server related", + "server_id": self.server_1.id, + "partner_id": self.user_bob.partner_id.id, + } + ) diff --git a/addons/cetmix_tower_server/tests/test_partner_server_btn.py b/addons/cetmix_tower_server/tests/test_partner_server_btn.py new file mode 100644 index 0000000..fcfb2dd --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_partner_server_btn.py @@ -0,0 +1,58 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import tagged + +from .common import TestTowerCommon + + +@tagged("partner_servers_btn") +class TestPartnerServers(TestTowerCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_a = cls.env["res.partner"].create({"name": "Partner A"}) + cls.partner_b = cls.env["res.partner"].create({"name": "Partner B"}) + cls.partner_b_child = cls.env["res.partner"].create( + { + "name": "Partner B Child", + "parent_id": cls.partner_b.id, + } + ) + + cls.server_defaults = { + "name": "Test Server", + "ssh_username": "root", + "ssh_port": 22, + "ssh_password": "Test-P@ssw0rd-123", + "ip_v4_address": "127.0.0.1", + "skip_host_key": True, + } + + cls.Server.create({"partner_id": cls.partner_b.id, **cls.server_defaults}) + cls.Server.create({"partner_id": cls.partner_b.id, **cls.server_defaults}) + cls.Server.create({"partner_id": cls.partner_b_child.id, **cls.server_defaults}) + + key = cls.Key.create({"name": "SSH Token", "key_type": "s"}) + cls.KeyValue.create( + { + "key_id": key.id, + "partner_id": cls.partner_b.id, + "secret_value": "TOPSECRET", + } + ) + + def test_server_count_compute(self): + """Server count: direct + one‑level child + zero if none.""" + self.assertEqual(self.partner_b.server_count, 3) + self.assertEqual(self.partner_b_child.server_count, 1) + self.assertEqual(self.partner_a.server_count, 0) + + def test_parent_with_only_child_servers(self): + """Parent without servers directs and with child_of.""" + parent = self.env["res.partner"].create({"name": "Parent Only"}) + child = self.env["res.partner"].create( + {"name": "Child with Server", "parent_id": parent.id} + ) + self.Server.create({"partner_id": child.id, **self.server_defaults}) + self.assertEqual(parent.server_count, 1) diff --git a/addons/cetmix_tower_server/tests/test_plan.py b/addons/cetmix_tower_server/tests/test_plan.py new file mode 100644 index 0000000..da8970e --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_plan.py @@ -0,0 +1,2899 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from unittest.mock import patch + +from odoo import _, fields +from odoo.exceptions import AccessError, ValidationError +from odoo.tools.misc import mute_logger + +from ..models.constants import ( + ANOTHER_PLAN_RUNNING, + GENERAL_ERROR, + PLAN_IS_EMPTY, + PLAN_LINE_CONDITION_CHECK_FAILED, + PLAN_NOT_COMPATIBLE_WITH_SERVER, + PLAN_STOPPED, +) +from .common import TestTowerCommon + + +class TestTowerPlan(TestTowerCommon): + """Test the cx.tower.plan model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Commands + cls.command_run_flight_plan_1 = cls.Command.create( + { + "name": "Run Flight Plan", + "action": "plan", + "flight_plan_id": cls.plan_1.id, + } + ) + cls.command_python_custom_variable_values_1 = cls.Command.create( + { + "name": "Python command to set custom variable values", + "action": "python_code", + "code": """ +custom_values['test_path_'] = '/test_path' +custom_values['test_dir'] = 'test_dir' +custom_values['_my_value'] = 'Just To Test' +""", + } + ) + cls.command_python_custom_variable_values_2 = cls.Command.create( + { + "name": "Python command to update custom variable values", + "action": "python_code", + "code": f""" +custom_values['test_path_'] = '/another_test_path' +custom_values['random_var_reference'] = 'random_var_value' +custom_values['{cls.variable_url.reference}'] = 'https://www.cetmix.com' +""", + } + ) + # Flight plan + cls.plan_2 = cls.Plan.create( + { + "name": "Test plan 2", + "note": "Run another flight plan", + } + ) + cls.plan_2_line_1 = cls.plan_line.create( + { + "sequence": 5, + "plan_id": cls.plan_2.id, + "command_id": cls.command_run_flight_plan_1.id, + } + ) + cls.plan_2_line_2 = cls.plan_line.create( + { + "sequence": 10, + "plan_id": cls.plan_2.id, + "command_id": cls.command_create_dir.id, + } + ) + # Flight plan with access level 1 to test user access rights + cls.plan_3 = cls.Plan.create( + { + "name": "Test plan 3", + "note": "Test user access rights", + "access_level": "1", + "line_ids": [ + (0, 0, {"command_id": cls.command_create_dir.id, "sequence": 1}), + ], + } + ) + # Create line for plan 3 + cls.plan_3_line_1 = cls.plan_line.create( + { + "plan_id": cls.plan_3.id, + "command_id": cls.command_create_dir.id, + "sequence": 10, + } + ) + cls.plan_3_line_1_action = cls.env["cx.tower.plan.line.action"].create( + { + "line_id": cls.plan_3_line_1.id, + "condition": "==", + "value_char": "test", + "action": "e", + } + ) + cls.variable_value = cls.env["cx.tower.variable.value"].create( + { + "variable_id": cls.variable_os.id, + "value_char": "Windows 2k", + "plan_line_action_id": cls.plan_3_line_1_action.id, + } + ) + cls.server = cls.Server.create( + { + "name": "Plan Test Server", + "ssh_username": "test", + "ssh_password": "test", + "ip_v4_address": "localhost", + "ssh_port": 22, + "user_ids": [(6, 0, [cls.user.id])], + "manager_ids": [(6, 0, [cls.manager.id])], + "skip_host_key": True, + } + ) + + def _create_plan(self, **kwargs): + """Helper method to create a flight plan.""" + vals = { + "name": "Test Flight Plan", + "access_level": "1", # override default for user tests + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [])], + } + if kwargs: + vals.update(kwargs) + return self.Plan.create(vals) + + def test_user_read_access(self): + """ + For a user: + Read access is allowed if access_level == "1" and + either the plan's own user_ids includes the user + OR at least one related server (via server_ids) + includes the user in its user_ids. + """ + # Case 1: Plan with access_level "1" and user + # included in plan.user_ids. + plan1 = self._create_plan( + **{ + "access_level": "1", + "user_ids": [(6, 0, [self.user.id])], + } + ) + recs1 = self.Plan.with_user(self.user).search([("id", "=", plan1.id)]) + self.assertIn( + plan1, + recs1, + "User should see the plan if in " "plan.user_ids and access_level == '1'.", + ) + + # Case 2: Plan with access_level "1" with no direct user_ids, + # but with a related server that grants access. + plan2 = self._create_plan( + **{ + "access_level": "1", + "user_ids": [(6, 0, [])], + "server_ids": [(6, 0, [self.server.id])], + } + ) + recs2 = self.Plan.with_user(self.user).search([("id", "=", plan2.id)]) + self.assertIn( + plan2, + recs2, + "User should see the plan if a " + "related server.user_ids includes the user.", + ) + + # Negative: Plan with access_level "1" + # with neither direct nor server-based access. + plan3 = self._create_plan( + **{ + "access_level": "1", + "user_ids": [(6, 0, [])], + "server_ids": [(6, 0, [])], + } + ) + recs3 = self.Plan.with_user(self.user).search([("id", "=", plan3.id)]) + self.assertNotIn( + plan3, + recs3, + "User should not see the plan if not granted access.", + ) + + # Also, a user should not be allowed to create a plan. + with self.assertRaises(AccessError): + self.Plan.with_user(self.user).create( + { + "name": "Test Plan", + "access_level": "1", + "user_ids": [(6, 0, [self.user.id])], + } + ) + # ...and modify a plan that they have access to. + with self.assertRaises(AccessError): + plan1.with_user(self.user).write({"name": "User Updated Plan"}) + + def test_manager_read_access(self): + """ + For a manager: + Read access is allowed if access_level <= "2" AND + EITHER the plan itself grants access + (its user_ids or manager_ids includes the manager) + OR either there are no related servers OR a related server + grants access (its user_ids or manager_ids includes the manager). + """ + # Case 1: Plan with access_level "2" and plan.manager_ids + # includes the manager. + plan1 = self._create_plan( + **{ + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + recs1 = self.Plan.with_user(self.manager).search([("id", "=", plan1.id)]) + self.assertIn( + plan1, + recs1, + "Manager should see the plan if in " + "plan.manager_ids and access_level <= '2'.", + ) + + # Case 2: Plan with access_level "2" that does not grant direct access, + # but a related server grants access via its manager_ids. + plan2 = self._create_plan( + **{ + "access_level": "2", + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [self.server.id])], + } + ) + recs2 = self.Plan.with_user(self.manager).search([("id", "=", plan2.id)]) + self.assertIn( + plan2, + recs2, + "Manager should see the plan if related " + "server.manager_ids includes the manager.", + ) + + # Case 3 negative: Plan with access_level "2" with no granted access + # if it's linked to a server that does not grant access. + plan3 = self._create_plan( + **{ + "access_level": "2", + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + recs3 = self.Plan.with_user(self.manager).search([("id", "=", plan3.id)]) + self.assertNotIn( + plan3, + recs3, + "Manager should not see the plan " + "if not granted access to related server.", + ) + + # Case 4 positive: Plan with access_level "2" with no linked servers + # and no related servers that grant access. + plan4 = self._create_plan( + **{ + "access_level": "2", + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + "server_ids": [(6, 0, [])], + } + ) + recs4 = self.Plan.with_user(self.manager).search([("id", "=", plan4.id)]) + self.assertIn( + plan4, + recs4, + "Manager should see the plan if not linked to any servers.", + ) + + # Case 5 negative: raise access level to 3 + # and check if manager can see the plan + plan4.access_level = "3" + recs5 = self.Plan.with_user(self.manager).search([("id", "=", plan4.id)]) + self.assertNotIn( + plan4, + recs5, + "Manager should not see the plan " "if access level is raised to 3.", + ) + + def test_manager_write_create_access(self): + """ + For a manager: + Write (update) and create access are allowed if access_level <= "2" AND + the plan's own manager_ids includes the manager. + """ + # Case 1: Plan with access_level "2" and plan.manager_ids + # includes the manager should allow to update the plan. + plan1 = self._create_plan( + **{ + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + try: + plan1.with_user(self.manager).write({"name": "Manager Updated Plan"}) + except AccessError: + self.fail( + "Manager should be able to update the plan if " "in plan.manager_ids.", + ) + self.assertEqual( + plan1.with_user(self.manager).name, + "Manager Updated Plan", + ) + + # Case 2: Attempt to create a plan as a manager without + # including their ID in manager_ids should fail. + with self.assertRaises(AccessError): + self.Plan.with_user(self.manager).create( + { + "name": "Manager Created Plan", + "access_level": "2", + "manager_ids": [(6, 0, [])], + } + ) + + # Case 3: Create a plan with manager added to manager_ids + # should be allowed. + try: + self.Plan.with_user(self.manager).create( + { + "name": "Manager Created Plan", + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + except AccessError: + self.fail( + "Manager should be able to create a plan " + "with himself added to manager_ids.", + ) + + def test_manager_unlink_access(self): + """ + For a manager: + Unlink (delete) access is allowed if access_level <= "2", + the current user is the record creator, + AND the plan's own manager_ids includes the manager. + """ + # Scenario 1: Plan created by the manager with plan.manager_ids + # including the manager. + plan1 = self.Plan.with_user(self.manager).create( + { + "name": "Manager Created Plan", + "access_level": "2", + } + ) + try: + plan1.unlink() + except AccessError: + self.fail( + "Manager should be able to delete the plan " + "they created if in plan.manager_ids.", + ) + + # Scenario 2: Plan created by another user, even if + # plan.manager_ids includes the manager. + plan2 = self._create_plan( + **{ + "access_level": "2", + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + with self.assertRaises(AccessError): + plan2.with_user(self.manager).unlink() + + def test_root_unrestricted_access(self): + """ + For a root user: + Unlimited access: root can read, write, create, and delete plans + regardless of access_level or related servers. + """ + plan = self._create_plan( + **{ + "access_level": "3", # above threshold for managers + } + ) + recs = self.Plan.with_user(self.root).search([("id", "=", plan.id)]) + self.assertIn( + plan, + recs, + "Root should see the plan regardless of restrictions.", + ) + try: + plan.with_user(self.root).write({"name": "Root Updated Plan"}) + except AccessError: + self.fail("Root should be able to update the plan without restrictions.") + self.assertEqual(plan.with_user(self.root).name, "Root Updated Plan") + plan2 = self.Plan.with_user(self.root).create( + { + "name": "Root Created Plan", + "access_level": "3", + } + ) + self.assertTrue( + plan2, + "Root should be able to create a plan without restrictions.", + ) + plan2.with_user(self.root).unlink() + recs_after = self.Plan.with_user(self.root).search([("id", "=", plan2.id)]) + self.assertFalse( + recs_after, + "Root should be able to delete the plan without restrictions.", + ) + + def test_plan_line_action_name(self): + """Test plan line action naming""" + + # Add new line + plan_line_1 = self.plan_line.create( + { + "plan_id": self.plan_1.id, + "command_id": self.command_create_dir.id, + "sequence": 10, + } + ) + + # Add new action with custom + action_1 = self.plan_line_action.create( + { + "line_id": plan_line_1.id, + "condition": "==", + "value_char": "35", + "action": "e", + } + ) + + # Check if action name is composed correctly + expected_action_string = _( + "If exit code == 35 then Exit with command exit code" + ) + self.assertEqual( + action_1.name, + expected_action_string, + msg="Action name doesn't match expected one", + ) + + def test_plan_get_next_action_values(self): + """Test _get_next_action_values() + + NB: This test relies on demo data and might fail if it is modified + """ + # Ensure demo date integrity just in case demo date is modified + self.assertEqual( + self.plan_1.line_ids[0].action_ids[1].custom_exit_code, + 255, + "Plan 1 line #1 action #2 custom exit code must be equal to 255", + ) + + # Create a new plan log. + plan_line_1 = self.plan_1.line_ids[0] # Using command 1 from Plan 1 + plan_log = self.PlanLog.create( + { + "server_id": self.server_test_1.id, + "plan_id": self.plan_1.id, + "is_running": True, + "start_date": fields.Datetime.now(), + "plan_line_executed_id": plan_line_1.id, + } + ) + + # ************************ + # Test with exit code == 0 + # Must run the next command + # ************************ + command_log = self.CommandLog.create( + { + "plan_log_id": plan_log.id, + "server_id": self.server_test_1.id, + "command_id": plan_line_1.command_id.id, + "command_response": "Ok", + "command_status": 0, # Error code + } + ) + action, exit_code, next_line_id = self.plan_1._get_next_action_values( + command_log + ) + self.assertEqual(action, "n", msg="Action must be 'Run next action'") + self.assertEqual(exit_code, 0, msg="Exit code must be equal to 0") + self.assertEqual( + next_line_id, + self.plan_line_2, + msg="Next line must be Line #2", + ) + + # ************************ + # Test with exit code == 8 + # Must exit with custom code + # ************************ + command_log.command_status = 8 + + action, exit_code, next_line_id = self.plan_1._get_next_action_values( + command_log + ) + self.assertEqual(action, "ec", msg="Action must be 'Exit with custom code'") + self.assertEqual(exit_code, 255, msg="Exit code must be equal to 255") + self.assertIsNone(next_line_id, msg="Next line must be None") + + # ************************ + # Test with exit code == -12 + # Plan on error action must be triggered because no action condition is matched + # ************************ + command_log.command_status = -12 + + action, exit_code, next_line_id = self.plan_1._get_next_action_values( + command_log + ) + self.assertEqual(action, "e", msg="Action must be 'Exit with command code'") + self.assertEqual(exit_code, -12, msg="Exit code must be equal to -12") + self.assertIsNone(next_line_id, msg="Next line must be None") + + # ************************ + # Change Plan 'On error action' of the plan to 'Run next command' + # Next line must be Line #2 + # ************************ + + command_log.command_status = -12 + self.plan_1.on_error_action = "n" + + action, exit_code, next_line_id = self.plan_1._get_next_action_values( + command_log + ) + self.assertEqual(action, "n", msg="Action must be 'Run next action'") + self.assertEqual(exit_code, -12, msg="Exit code must be equal to -12") + self.assertEqual( + next_line_id, + self.plan_line_2, + msg="Next line must be Line #2", + ) + + # ************************ + # Run Line 2 (the last one). + # Action 2 will be triggered which is "Run next line". + # However because this is the last line of the plan must exit with command code. + # ************************ + + plan_line_2 = self.plan_1.line_ids[1] + plan_log.plan_line_executed_id = plan_line_2.id + command_log.command_status = 3 + + action, exit_code, next_line_id = self.plan_1._get_next_action_values( + command_log + ) + self.assertEqual(action, "e", msg="Action must be 'Exit with command code'") + self.assertEqual(exit_code, 3, msg="Exit code must be equal to 3") + self.assertIsNone(next_line_id, msg="Next line must be None") + + # ************************ + # Run Line 2 (the last one). + # Fallback plan action must be triggered because no action condition is matched + # However because this is the last line of the plan must exit with command code. + # ************************ + + command_log.command_status = 1 + + action, exit_code, next_line_id = self.plan_1._get_next_action_values( + command_log + ) + self.assertEqual(action, "e", msg="Action must be 'Exit with command code'") + self.assertEqual(exit_code, 1, msg="Exit code must be equal to 1") + self.assertIsNone(next_line_id, msg="Next line must be None") + + def test_plan_run_single(self): + """Test plan execution results""" + + # Add user as user to Server1 + self.server_test_1.user_ids = [(4, self.user_bob.id)] + + # Ensure that access error is raised + # Because user_bob is not in any Tower group + with self.assertRaises(AccessError): + self.plan_1.with_user(self.user_bob)._run_single(self.server_test_1) + + # Add user to the "User" group + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + + # Ensure that access error is raised + # Because plan access level is "Manager" and user_bob is in "User" group + with self.assertRaises(AccessError): + self.plan_1.with_user(self.user_bob)._run_single(self.server_test_1) + + # Set access level to 1 and link to server1 + # so Bob can execute the plan + self.write_and_invalidate( + self.plan_1, + **{"access_level": "1", "server_ids": [(4, self.server_test_1.id)]}, + ) + + self.env["ir.rule"].invalidate_model() + # Run plan + self.plan_1.with_user(self.user_bob)._run_single(self.server_test_1) + + # Check plan log + plan_log_rec = self.PlanLog.search([("server_id", "=", self.server_test_1.id)]) + + # Must be a single record + self.assertEqual(len(plan_log_rec), 1, msg="Must be a single plan record") + + # Ensure all commands were triggered + expected_command_count = 2 + self.assertEqual( + len(plan_log_rec.command_log_ids), + expected_command_count, + msg=f"Must run {expected_command_count} commands", + ) + + # Check plan status + expected_plan_status = 0 + self.assertEqual( + plan_log_rec.plan_status, + expected_plan_status, + msg=f"Plan status must be equal to {expected_plan_status}", + ) + + # ************************ + # Change condition in line #1. + # Action 1 will be triggered which is "Exit with custom code" 29. + # ************************ + action_to_tweak = self.plan_line_1_action_1 + action_to_tweak.write({"custom_exit_code": 29, "action": "ec"}) + + # Run plan + self.plan_1._run_single(self.server_test_1) + + # Check plan log + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + + # Must be two plan log record + self.assertEqual(len(plan_log_records), 2, msg="Must be 2 plan log records") + plan_log_rec = plan_log_records[0] + + # Ensure all commands were triggered + expected_command_count = 1 + self.assertEqual( + len(plan_log_rec.command_log_ids), + expected_command_count, + msg=f"Must run {expected_command_count} commands", + ) + + # Check plan status + expected_plan_status = 29 + self.assertEqual( + plan_log_rec.plan_status, + expected_plan_status, + msg=f"Plan status must be equal to {expected_plan_status}", + ) + + # Ensure 'path' was substituted with the plan line custom 'path' + self.assertEqual( + self.plan_line_1.path, + plan_log_rec.command_log_ids.path, + "Path in command log must be the same as in the flight plan line", + ) + + def test_plan_and_command_access_level(self): + # Remove userbob from all cxtower_server groups + self.remove_from_group( + self.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + + # Add user_bob to group_manager + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + + # Add user_bob as manager to the plan + self.plan_1.manager_ids = [(4, self.user_bob.id)] + + # check if plan and commands included has same access level + self.assertEqual(self.plan_1.access_level, "2") + self.assertEqual(self.command_create_dir.access_level, "2") + self.assertEqual(self.command_list_dir.access_level, "2") + + # check that if we modify plan access level to make it lower than the + # access_level of the commands related with it access level, + # access_level_warn_msg will be created + self.plan_1.with_user(self.user_bob).write({"access_level": "1"}) + self.assertTrue(self.plan_1.access_level_warn_msg) + + # Add user_bob to group_root + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + + # check if user_bob can make plan access leve higher than commands access level + self.plan_1.with_user(self.user_bob).write({"access_level": "3"}) + self.assertEqual(self.plan_1.access_level, "3") + + # check that if we create a new plan with an access_level lower than + # the access_level of the command related with access_level_warn_msg + # will be created + command_1 = self.Command.create( + {"name": "New Test Command", "access_level": "3"} + ) + + self.plan_2 = self.Plan.create( + { + "name": "Test plan 2", + "note": "Create directory and list its content", + } + ) + self.plan_line_2_1 = self.plan_line.create( + { + "sequence": 5, + "plan_id": self.plan_2.id, + "command_id": command_1.id, + } + ) + self.assertTrue(self.plan_2.access_level_warn_msg) + + def test_multiple_plan_create_write(self): + """Test multiple plan create/write cases""" + # Create multiple plans at once + plans_data = [ + { + "name": "Test Plan 1", + "note": "Plan 1 Note", + "tag_ids": [(6, 0, [self.tag_test_staging.id])], + }, + { + "name": "Test Plan 2", + "note": "Plan 2 Note", + "tag_ids": [(6, 0, [self.tag_test_production.id])], + }, + { + "name": "Test Plan 3", + "note": "Plan 3 Note", + "tag_ids": [(6, 0, [self.tag_test_staging.id])], + }, + ] + created_plans = self.Plan.create(plans_data) + # Check that all plans are created successfully + self.assertTrue(all(created_plans)) + # Update the access level of the created plans + created_plans.write({"access_level": "3"}) + # Check that all plans are updated successfully + self.assertTrue(all(plan.access_level == "3" for plan in created_plans)) + + def test_plan_with_first_not_executable_condition(self): + """ + Test plan with not executable condition for first plan line + """ + # Add condition for the first plan line + self.plan_line_1.condition = "{{ odoo_version }} == '14.0'" + # Run plan + self.plan_1._run_single(self.server_test_1) + # Check plan log + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual( + len(plan_log_records.command_log_ids), + 2, + msg="Must be two command records", + ) + self.assertTrue( + plan_log_records.command_log_ids[0].is_skipped, + msg="First command must be skipped", + ) + self.assertFalse( + plan_log_records.command_log_ids[1].is_skipped, + msg="Second command not must be skipped", + ) + + def test_plan_with_second_not_executable_condition(self): + """ + Test plan with not executable condition for second plan line + """ + # Add condition for second plan line + self.plan_line_2.condition = "{{ odoo_version }} == '14.0'" + # Run plan + self.plan_1._run_single(self.server_test_1) + # Check plan log + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual( + len(plan_log_records.command_log_ids), + 2, + msg="Must be two command records", + ) + self.assertTrue( + plan_log_records.command_log_ids[1].is_skipped, + msg="Second command must be skipped", + ) + self.assertFalse( + plan_log_records.command_log_ids[0].is_skipped, + msg="First command not must be skipped", + ) + + def test_plan_with_executable_condition(self): + """ + Test plan with executable condition for plan line + """ + # Add condition for first plan line + self.plan_line_1.condition = "1 == 1" + # Create a global value for the 'Version' variable + self.VariableValue.create( + {"variable_id": self.variable_version.id, "value_char": "14.0"} + ) + # Add condition with variable + self.plan_line_2.condition = ( + "{{ " + self.variable_version.name + " }} == '14.0'" + ) + # Run plan + self.plan_1._run_single(self.server_test_1) + # Check commands + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual( + len(plan_log_records.command_log_ids), + 2, + msg="Must be two command records", + ) + self.assertTrue( + all(not command.is_skipped for command in plan_log_records.command_log_ids), + msg="All command should be executed", + ) + + def test_plan_with_update_variables(self): + """ + Test plan updates custom (in-flight) values + """ + # Add new variable to server + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "value_char": "14.0", + "server_id": self.server_test_1.id, + } + ) + # Create new variable value on action + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "value_char": "16.0", + "plan_line_action_id": self.plan_line_1_action_1.id, + } + ) + # Add a new variable value on action for a variable absent on the server + self.VariableValue.create( + { + "variable_id": self.variable_os.id, + "value_char": "Ubuntu", + "plan_line_action_id": self.plan_line_1_action_1.id, + } + ) + # Pre-run sanity: server holds initial value and no OS value + exist_server_values = self.server_test_1.variable_value_ids.filtered( + lambda rec: rec.variable_id == self.variable_version + ) + self.assertEqual( + len(exist_server_values), + 1, + "The server should have only one value for the variable", + ) + self.assertEqual( + exist_server_values.value_char, + "14.0", + "The server variable value should be '14.0'", + ) + exist_server_values = self.server_test_1.variable_value_ids.filtered( + lambda rec: rec.variable_id == self.variable_os + ) + self.assertFalse( + exist_server_values, "The server should not have this variable" + ) + # Run plan + self.plan_1._run_single(self.server_test_1) + # After run: server values MUST remain unchanged + server_version_val = self.server_test_1.variable_value_ids.filtered( + lambda rec: rec.variable_id == self.variable_version + ) + self.assertEqual( + server_version_val.value_char, + "14.0", + "Server variable value must remain unchanged", + ) + self.assertFalse( + self.server_test_1.variable_value_ids.filtered( + lambda rec: rec.variable_id == self.variable_os + ), + "Server must not receive new variable from action", + ) + + # But custom (in-flight) values MUST be updated in logs + plan_log = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)], order="id desc", limit=1 + ) + self.assertTrue(plan_log, "Plan log should exist after run") + self.assertEqual( + plan_log.variable_values[self.variable_version.reference], + "16.0", + "Plan log must contain updated custom value", + ) + self.assertEqual( + plan_log.variable_values[self.variable_os.reference], + "Ubuntu", + "Plan log must contain new custom value", + ) + + last_command_log = plan_log.command_log_ids and plan_log.command_log_ids[-1] + self.assertTrue(last_command_log, "Command log should exist after run") + self.assertEqual( + last_command_log.variable_values[self.variable_version.reference], + "16.0", + "Command log must contain updated custom value", + ) + + def test_plan_with_action_variables_for_condition(self): + """ + Test plan with update server variables and use new + value as condition for next plan line + """ + # Add new variable to server + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "value_char": "14.0", + "server_id": self.server_test_1.id, + } + ) + # Create new variable value to action to update existing server variable + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "value_char": "16.0", + "plan_line_action_id": self.plan_line_1_action_1.id, + } + ) + # Add condition with variable + self.plan_line_2.condition = ( + "{{ " + self.variable_version.name + " }} == '14.0'" + ) + # Run plan + self.plan_1._run_single(self.server_test_1) + # Check commands + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + # The second line of the plan should be skipped because the + # first line of the plan updated the value of the variable + self.assertTrue( + plan_log_records.command_log_ids[1].is_skipped, + msg="Second command must be skipped", + ) + + # Change condition for plan line + self.plan_line_2.condition = ( + "{{ " + self.variable_version.name + " }} == '16.0'" + ) + # Run plan + self.plan_1._run_single(self.server_test_1) + # Check commands + new_plan_log_records = ( + self.PlanLog.search([("server_id", "=", self.server_test_1.id)]) + - plan_log_records + ) + # The second line of the plan should be skipped because the + # first line of the plan updated the value of the variable + self.assertFalse( + new_plan_log_records.command_log_ids[1].is_skipped, + msg="The second plan line should not be skipped", + ) + + def test_flight_plan_copy(self): + """Test duplicating a Flight Plan with lines, actions, and variable values""" + + # Create a Flight Plan + plan = self.Plan.create( + { + "name": "Test Flight Plan", + "note": "Test Note", + } + ) + + # Create a command for the plan line + command = self.Command.create( + { + "name": "Test Command", + # Command to get Linux kernel version + "code": "uname -r", + } + ) + + # Create a Flight Plan Line + plan_line = self.plan_line.create( + { + "plan_id": plan.id, + "command_id": command.id, + "path": "/test/path", + # Condition based on Linux version + "condition": '{{ test_linux_version }} >= "5.0"', + } + ) + + # Create a variable for the action + variable = self.Variable.create({"name": "test_linux_version"}) + + # Create an Action for the Plan Line + action = self.plan_line_action.create( + { + "line_id": plan_line.id, + "action": "n", # next action + "condition": "==", + "value_char": "0", # condition for success + } + ) + + # Create a Variable Value for the Action + self.env["cx.tower.variable.value"].create( + { + "variable_id": variable.id, + "value_char": "5.0", + "plan_line_action_id": action.id, + } + ) + + # Duplicate the Flight Plan + copied_plan = plan.copy() + + # Ensure the new Flight Plan was created with a new ID + self.assertNotEqual( + copied_plan.id, + plan.id, + "Copied plan should have a different ID from the original", + ) + + # Check that the copied plan has the same number of lines + self.assertEqual( + len(copied_plan.line_ids), + len(plan.line_ids), + "Copied plan should have the same number of lines as the original", + ) + + # Check that the copied plan's lines have the same actions as the original + original_line = plan.line_ids + copied_line = copied_plan.line_ids + + # Ensure the command, condition, and custom path are copied correctly + self.assertEqual( + copied_line.command_id.id, + original_line.command_id.id, + "Command should be the same in copied line", + ) + self.assertEqual( + copied_line.path, + original_line.path, + "Custom path should be the same in copied line", + ) + self.assertEqual( + copied_line.condition, + original_line.condition, + "Condition should be the same in copied line", + ) + + # Ensure actions were copied correctly + self.assertEqual( + len(copied_line.action_ids), + len(original_line.action_ids), + "Number of actions should be the same in the copied line", + ) + self.assertEqual( + copied_line.action_ids.action, + original_line.action_ids.action, + "Action should be the same in the copied line", + ) + self.assertEqual( + copied_line.action_ids.condition, + original_line.action_ids.condition, + "Action condition should be the same in the copied line", + ) + self.assertEqual( + copied_line.action_ids.value_char, + original_line.action_ids.value_char, + "Action value should be the same in the copied line", + ) + + # Check that variable values were copied correctly + original_action = original_line.action_ids + copied_action = copied_line.action_ids + + self.assertEqual( + len(copied_action.variable_value_ids), + len(original_action.variable_value_ids), + "Number of variable values should be the same in the copied action", + ) + + self.assertEqual( + copied_action.variable_value_ids.variable_id.id, + original_action.variable_value_ids.variable_id.id, + "Variable should be the same in the copied action", + ) + self.assertEqual( + copied_action.variable_value_ids.value_char, + original_action.variable_value_ids.value_char, + "Variable value should be the same in the copied action", + ) + + def test_plan_with_another_plan(self): + """ + Test to check running another plan from current plan + """ + # Check plan logs + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") + # Run plan + self.plan_2._run_single(self.server_test_1) + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") + + parent_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_2 + ) + self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") + self.assertEqual( + parent_plan_log.plan_status, 0, "Plan log should success status" + ) + + child_plan_log = plan_log_records - parent_plan_log + self.assertEqual( + child_plan_log.parent_flight_plan_log_id, + parent_plan_log, + "Second plan log should contain parent log link", + ) + triggering = parent_plan_log.command_log_ids.filtered( + lambda log: log.triggered_plan_log_id + ) + self.assertEqual( + len(triggering), 1, "Expected exactly one triggering command log" + ) + self.assertEqual( + child_plan_log.plan_status, + triggering.command_status, + "Parent run-plan command status must equal child plan status", + ) + self.assertEqual( + parent_plan_log.command_log_ids.triggered_plan_log_id, + child_plan_log, + "The command triggered plan line should be equal to child plan", + ) + + # Check that we cannot add recursive plan + with self.assertRaisesRegex( + ValidationError, "Recursive plan call detected in plan.*" + ): + self.plan_line.create( + { + "sequence": 20, + "plan_id": self.plan_1.id, + "command_id": self.command_run_flight_plan_1.id, + } + ) + + # Delete plan lines from first plan + self.plan_1.line_ids = False + # Run plan + self.plan_2._run_single(self.server_test_1) + plan_log_records = ( + self.PlanLog.search([("server_id", "=", self.server_test_1.id)]) + - plan_log_records + ) + + parent_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_2 + ) + self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") + self.assertEqual( + parent_plan_log.plan_status, PLAN_IS_EMPTY, "Plan log should failed status" + ) + + child_plan_log = plan_log_records - parent_plan_log + self.assertEqual( + child_plan_log.parent_flight_plan_log_id, + parent_plan_log, + "Second plan log should contain parent log link", + ) + self.assertEqual( + child_plan_log.plan_status, + parent_plan_log.command_log_ids.command_status, + "The command status of parent plan should be equal " + "of status second flight plan", + ) + + def test_plan_with_two_plans(self): + """ + Test to check two plans from plan + """ + self.plan_line.create( + { + "sequence": 15, + "plan_id": self.plan_2.id, + "command_id": self.command_run_flight_plan_1.id, + } + ) + # Check plan logs + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") + # Run plan + self.plan_2._run_single(self.server_test_1) + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 3, msg="Should be 3 plan logs") + + def test_plan_with_nested_plans(self): + """ + Test to check two plans from plan + """ + command_run_flight_plan_2 = self.Command.create( + { + "name": "Run Flight Plan", + "action": "plan", + "flight_plan_id": self.plan_2.id, + } + ) + plan_3 = self.Plan.create( + { + "name": "Test plan 3", + "note": "Run flight plan 2", + } + ) + self.plan_line.create( + { + "sequence": 5, + "plan_id": plan_3.id, + "command_id": command_run_flight_plan_2.id, + } + ) + # Check plan logs + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") + # Run plan + plan_3._run_single(self.server_test_1) + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 3, msg="Should be 3 plan logs") + + last_child_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_1 + ) + self.assertTrue(last_child_plan_log, "The log for Plan 1 must exist!") + self.assertEqual( + last_child_plan_log.plan_status, 0, "Plan log should success status" + ) + + self.assertIn( + last_child_plan_log.parent_flight_plan_log_id, + plan_log_records, + "Parent plan logs should exist", + ) + self.assertEqual( + last_child_plan_log.parent_flight_plan_log_id.plan_id, + self.plan_2, + "Parent plan should be equal to plan 2", + ) + + child_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_2 + ) + self.assertIn( + child_plan_log.parent_flight_plan_log_id, + plan_log_records, + "Parent plan logs should exist", + ) + self.assertEqual( + child_plan_log.parent_flight_plan_log_id.plan_id, + plan_3, + "Parent plan should be equal to plan 3", + ) + self.assertEqual( + child_plan_log.command_log_ids.triggered_plan_log_id, + last_child_plan_log, + "The command triggered plan line should be equal to last child plan", + ) + self.assertEqual( + child_plan_log.command_log_ids.triggered_plan_log_id, + last_child_plan_log, + "The command triggered plan line should be equal to last child plan", + ) + parent_plan_log = plan_log_records - child_plan_log - last_child_plan_log + self.assertEqual( + parent_plan_log.command_log_ids.triggered_plan_log_id, + child_plan_log, + "The command triggered plan line from parent plan " + "should be equal to child plan", + ) + + # Check that we cannot change command with existing plan, + # because it's recursive plan + with self.assertRaisesRegex( + ValidationError, "Recursive plan call detected in plan.*" + ): + self.plan_line_1.write( + { + "command_id": command_run_flight_plan_2.id, + } + ) + + # Set the previous command back + + self.plan_line_1.write( + { + "command_id": self.command_create_dir.id, + } + ) + # --- Check server dependency handling + + # Remove all existing flight plan logs + self.PlanLog.search([]).unlink() + + # Set server dependency for plan 2 + self.plan_2.write( + { + "server_ids": [(6, 0, [self.server.id])], + } + ) + plan_log = self.server_test_1.run_flight_plan(self.plan_2) + self.assertEqual(plan_log.plan_status, PLAN_NOT_COMPATIBLE_WITH_SERVER) + + # Run plan on allowed server + plan_log = self.server.run_flight_plan(self.plan_2) + self.assertEqual(plan_log.plan_status, 0) + + def test_failed_first_child_plan_with_another_plan(self): + """ + Check that child plan was failed then parent plan is failed too + """ + # Add new plan line + self.plan_line.create( + { + "sequence": 15, + "plan_id": self.plan_2.id, + "command_id": self.command_run_flight_plan_1.id, + } + ) + # Check plan logs + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") + + # Simulate a failed Plan 1. To achieve this, we need to update the command + # associated with Plan 1 to apply the desired side effect. + self.plan_1.line_ids.command_id[0].code = "fail" + + # Run plan + self.plan_2._run_single(self.server_test_1) + + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + # 2 logs only because plan should exist with error after first failed command + self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") + + parent_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_2 + ) + self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") + self.assertEqual( + parent_plan_log.plan_status, GENERAL_ERROR, "Plan log should failed status" + ) + + child_plan_log = plan_log_records - parent_plan_log + self.assertEqual( + child_plan_log.parent_flight_plan_log_id, + parent_plan_log, + "Second plan log should contain parent log link", + ) + self.assertEqual( + child_plan_log.plan_status, + parent_plan_log.command_log_ids.command_status, + "The command status of main plan should be equal " + "of status second flight plan", + ) + + def test_failed_second_child_plan_with_another_plan(self): + """ + Check that child plan was failed then parent plan is failed too + """ + # Add new plan line + line = self.plan_line.create( + { + "sequence": 15, + "plan_id": self.plan_2.id, + "command_id": self.command_run_flight_plan_1.id, + } + ) + + cx_tower_plan_obj = self.registry["cx.tower.plan"] + _run_single_super = cx_tower_plan_obj._run_single + + def _run_single(this, *args, **kwargs): + if ( + this == self.plan_1 + and this.env["cx.tower.plan.log"] + .browse(kwargs["log"]["plan_log_id"]) + .plan_line_executed_id + == line + ): + # Simulate a failed Plan 1. To achieve this, we need to update + # the command associated with Plan 1 to apply the desired side effect. + self.plan_1.line_ids.command_id[0].code = "fail" + return _run_single_super(this, *args, **kwargs) + + with patch.object(cx_tower_plan_obj, "_run_single", _run_single): + # Run plan + self.plan_2._run_single(self.server_test_1) + + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + # 3 logs because plan should exist with error after second failed command + self.assertEqual(len(plan_log_records), 3, msg="Should be 3 plan logs") + + parent_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_2 + ) + self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") + self.assertEqual( + parent_plan_log.plan_status, GENERAL_ERROR, "Plan log should failed status" + ) + + child_plan_log = plan_log_records - parent_plan_log + self.assertEqual( + child_plan_log.parent_flight_plan_log_id, + parent_plan_log, + "Second plan log should contain parent log link", + ) + self.assertEqual( + len(child_plan_log), + 2, + "Must be 2 child plan logs", + ) + self.assertIn( + GENERAL_ERROR, + child_plan_log.mapped("plan_status"), + "One of plan status of child plan must be GENERAL_ERROR", + ) + self.assertIn( + 0, + child_plan_log.mapped("plan_status"), + "One of plan status of child plan must be GENERAL_ERROR", + ) + + def test_plan_with_another_plan_with_condition(self): + """ + Test that parent plan will success finished + if child plan executable by condition + """ + # Add condition for first plan line + self.plan_line_1.condition = "1 == 1" + # Check plan logs + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") + # Run plan + self.plan_2._run_single(self.server_test_1) + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + + self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") + + parent_plan_log = plan_log_records.filtered( + lambda rec: rec.plan_id == self.plan_2 + ) + self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") + self.assertEqual( + parent_plan_log.plan_status, 0, "Plan log should success status" + ) + + child_plan_log = plan_log_records - parent_plan_log + self.assertEqual( + child_plan_log.parent_flight_plan_log_id, + parent_plan_log, + "Second plan log should contain parent log link", + ) + self.assertEqual( + child_plan_log.plan_status, + parent_plan_log.command_log_ids.filtered( + lambda log: log.triggered_plan_log_id + ).command_status, + "The command status of main plan should be equal " + "of status second flight plan", + ) + + def test_plan_with_another_plan_with_not_executable_condition(self): + """ + Test plan with not executable condition for second plan line + """ + # Add condition for first plan line + self.plan_line_1.condition = "{{ odoo_version }} == '14.0'" + # Check plan logs + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") + # Run plan + self.plan_2._run_single(self.server_test_1) + + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + + self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") + + self.assertIn( + PLAN_LINE_CONDITION_CHECK_FAILED, + plan_log_records.command_log_ids.mapped("command_status"), + "One of commands should be skipped", + ) + + def test_plan_with_another_plan_with_all_not_executable_condition(self): + """ + Test plan with not executable condition for second plan line + """ + # Add condition for all plan lines + self.plan_line_1.condition = "{{ odoo_version }} == '14.0'" + self.plan_line_2.condition = "{{ odoo_version }} == '14.0'" + + self.plan_2_line_1.condition = "{{ odoo_version }} == '14.0'" + self.plan_2_line_2.condition = "{{ odoo_version }} == '14.0'" + + self.plan_2._run_single(self.server_test_1) + + # Check plan logs after execute command with plan action + plan_log_records = self.PlanLog.search( + [("server_id", "=", self.server_test_1.id)] + ) + + self.assertEqual(len(plan_log_records), 1, msg="Should be 1 plan logs") + self.assertEqual( + PLAN_LINE_CONDITION_CHECK_FAILED, + plan_log_records.command_log_ids.filtered( + lambda log: log.command_id == self.command_run_flight_plan_1 + ).command_status, + "Command status should be skipped", + ) + + def test_plan_unlink(self): + plan = self.plan_1.copy() + plan_id = plan.id + plan_line_ids = plan.line_ids + plan_line_action_ids = plan.mapped("line_ids.action_ids") + + plan.unlink() + + self.assertFalse( + self.Plan.search([("id", "=", plan_id)]), msg="Plan should be deleted" + ) + self.assertFalse( + self.plan_line.search([("id", "in", plan_line_ids.ids)]), + msg="Plan line should be deleted when Plan is deleted", + ) + self.assertFalse( + self.plan_line_action.search([("id", "in", plan_line_action_ids.ids)]), + msg="Plan line action should be deleted when Plan line is deleted", + ) + + def test_plan_command_server_compatibility(self): + """Test plan execution with server-restricted flight plans""" + # Create a new test server + test_server = self.Server.create( + { + "name": "Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "host_key": "test_key", + } + ) + + # Create a flight plan restricted to the test server + plan = self.Plan.create( + { + "name": "Server Restricted Plan", + "server_ids": [(6, 0, [test_server.id])], + "line_ids": [ + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 1}) + ], + } + ) + + # Should fail when executing on non-allowed server + plan_log = plan._run_single(self.server_test_1) + self.assertEqual(plan_log.plan_status, PLAN_NOT_COMPATIBLE_WITH_SERVER) + + # Should work on allowed server + plan._run_single(test_server) + plan_log = self.PlanLog.search( + [("plan_id", "=", plan.id), ("server_id", "=", test_server.id)], limit=1 + ) + self.assertEqual(plan_log.command_log_ids.command_status, 0) + + def test_another_plan_running(self): + """Test the parallel plan running""" + + # Ensure that the plan doesn't allow parallel running + self.plan_1.write({"allow_parallel_run": False}) + + # Create a new plan log with a plan that is already running + self.PlanLog.create( + { + "plan_id": self.plan_1.id, + "server_id": self.server_test_1.id, + "start_date": fields.Datetime.now(), + } + ) + + # Launch the same plan on the same server + plan_log = self.server_test_1.run_flight_plan(self.plan_1) + self.assertEqual(plan_log.plan_status, ANOTHER_PLAN_RUNNING) + + # Now allow parallel running + self.plan_1.write({"allow_parallel_run": True}) + + # Launch the same plan on the same server + plan_log = self.server_test_1.run_flight_plan(self.plan_1) + self.assertEqual(plan_log.plan_status, 0) + + def test_plan_custom_variables(self): + """Test plan with custom variables""" + command_python_1_id = self.command_python_custom_variable_values_1.id + command_python_2_id = self.command_python_custom_variable_values_2.id + + plan = self._create_plan( + **{ + "name": "Plan with custom variables", + "line_ids": [ + ( + 0, + 0, + { + "command_id": command_python_1_id, + "sequence": 1, + }, + ), + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 2}), + ( + 0, + 0, + { + "command_id": command_python_2_id, + "sequence": 3, + }, + ), + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 4}), + ], + } + ) + + # Run plan + plan_log = self.server_test_1.run_flight_plan(plan) + + # Check that custom variable values were updated correctly + # (The log of plan should contain the last updatedvalues) + self.assertEqual(plan_log.variable_values["test_path_"], "/another_test_path") + self.assertEqual(plan_log.variable_values["test_dir"], "test_dir") + self.assertEqual( + plan_log.variable_values["random_var_reference"], "random_var_value" + ) + self.assertEqual(plan_log.variable_values["_my_value"], "Just To Test") + + command_logs = plan_log.command_log_ids + self.assertEqual( + len(command_logs), + len(plan.line_ids), + f"Should be {len(plan.line_ids)} command logs.", + ) + + # Check that custom variable values were created correctly + # in first python command log + command_python_command_1_log = command_logs.filtered( + lambda log: log.command_id.id == command_python_1_id + ) + self.assertEqual( + command_python_command_1_log.variable_values["test_path_"], "/test_path" + ) + self.assertEqual( + command_python_command_1_log.variable_values["test_dir"], "test_dir" + ) + self.assertEqual( + command_python_command_1_log.variable_values["_my_value"], "Just To Test" + ) + + # Check that custom variable values used in rendered command code + command_create_dir_logs = command_logs.filtered( + lambda log: log.command_id == self.command_create_dir + ) + first_command_create_dir_log = command_create_dir_logs[0] + second_command_create_dir_log = command_create_dir_logs[1] + + # the first_command_create_dir_log.code is equal to + # 'cd /test_path && mkdir test_dir' + # because rendered code contains custom variable values updated + # from first python command + self.assertEqual( + first_command_create_dir_log.code, "cd /test_path && mkdir test_dir" + ) + + # Check that custom variable values were updated correctly in command logs + command_python_command_2_log = command_logs.filtered( + lambda log: log.command_id.id == command_python_2_id + ) + self.assertEqual( + command_python_command_2_log.variable_values["test_path_"], + "/another_test_path", + ) + self.assertEqual( + command_python_command_2_log.variable_values["test_dir"], "test_dir" + ) + self.assertEqual( + command_python_command_2_log.variable_values["random_var_reference"], + "random_var_value", + ) + self.assertEqual( + command_python_command_2_log.variable_values["_my_value"], "Just To Test" + ) + self.assertEqual( + command_python_command_2_log.variable_values[self.variable_url.reference], + "https://www.cetmix.com", + ) + + # the second_command_create_dir_log.code is equal to + # 'cd /another_test_path && mkdir test_dir' + # because rendered code contains custom variable values updated + # from second python command + self.assertEqual( + second_command_create_dir_log.code, + "cd /another_test_path && mkdir test_dir", + ) + + def test_plan_custom_variables_wizard(self): + """Test plan with custom variables from wizard""" + command_python_1_id = self.command_python_custom_variable_values_1.id + command_python_2_id = self.command_python_custom_variable_values_2.id + plan = self._create_plan( + **{ + "name": "Plan with custom variables", + "line_ids": [ + ( + 0, + 0, + { + "command_id": command_python_1_id, + "sequence": 1, + }, + ), + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 2}), + ( + 0, + 0, + { + "command_id": command_python_2_id, + "sequence": 3, + }, + ), + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 4}), + ], + } + ) + + # Create wizard with custom variable values + wizard = self.env["cx.tower.plan.run.wizard"].create( + { + "plan_id": plan.id, + "server_ids": [(6, 0, [self.server_test_1.id])], + "custom_variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_version.id, + "value_char": "16.0", + }, + ), + ], + } + ) + + # Run wizard + action = wizard.run_flight_plan() + plan_log = self.PlanLog.search( + [("label", "=", action["context"]["search_default_label"])], + limit=1, + ) + self.assertTrue(plan_log, "Plan log should be created") + + # Check that custom variable values were updated correctly + # (The log of plan should contain the last updated + # values + custom variable value from wizard) + self.assertEqual(plan_log.variable_values["test_path_"], "/another_test_path") + self.assertEqual(plan_log.variable_values["test_dir"], "test_dir") + self.assertEqual( + plan_log.variable_values["random_var_reference"], "random_var_value" + ) + self.assertEqual(plan_log.variable_values["_my_value"], "Just To Test") + self.assertEqual( + plan_log.variable_values[self.variable_version.reference], "16.0" + ) + + def test_plan_with_another_plan_custom_variables(self): + """Test plan with another plan with custom variables""" + # Create plan with next structure: + # Plan 1: + # - Command 1: Run plan 2 + # - Command 2: Run Python command to set custom variable values + # - Command 3: Create directory + # Plan 2: + # - Command 1: Python command to set custom variable values + # - Command 2: Create directory + # - Command 3: Python command to update custom variable values + + command_python_1_id = self.command_python_custom_variable_values_1.id + command_python_2_id = self.command_python_custom_variable_values_2.id + plan2 = self._create_plan( + **{ + "name": "Plan 2", + "line_ids": [ + ( + 0, + 0, + { + "command_id": command_python_1_id, + "sequence": 1, + }, + ), + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 2}), + ( + 0, + 0, + { + "command_id": command_python_2_id, + "sequence": 3, + }, + ), + ], + } + ) + + command_run_plan_2 = self.Command.create( + { + "name": "Run Flight Plan", + "action": "plan", + "flight_plan_id": plan2.id, + } + ) + command_python_custom_variable_values_3 = self.Command.create( + { + "name": "Python command to update custom variable values", + "action": "python_code", + "code": """ +custom_values['random_var_reference'] = 'another_random_var_value' +""", + } + ) + + plan1 = self._create_plan( + **{ + "name": "Plan 1", + "line_ids": [ + (0, 0, {"command_id": command_run_plan_2.id, "sequence": 1}), + ( + 0, + 0, + { + "command_id": command_python_custom_variable_values_3.id, + "sequence": 2, + }, + ), + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 3}), + ], + } + ) + + # Create wizard with custom variable values + wizard = self.env["cx.tower.plan.run.wizard"].create( + { + "plan_id": plan1.id, + "server_ids": [(6, 0, [self.server_test_1.id])], + "custom_variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_version.id, + "value_char": "16.0", + }, + ), + ( + 0, + 0, + { + "variable_id": self.variable_url.id, + "value_char": "https://www.test.com", + }, + ), + ], + } + ) + + # Run wizard + action = wizard.run_flight_plan() + plan_log = self.PlanLog.search( + [("label", "=", action["context"]["search_default_label"])], + limit=1, + ) + self.assertTrue(plan_log, "Plan log should be created") + + command_logs = plan_log.command_log_ids + self.assertEqual( + len(command_logs), + len(plan1.line_ids), + f"Should be {len(plan1.line_ids)} command logs.", + ) + + # First command log is run plan 2 log that contains custom variable values + # updated from plan 2. This command log should contain the same custom + # variable values as from plan 2 log + run_plan2_command_log = command_logs[0] + run_plan2_command_log_variable_values = run_plan2_command_log.variable_values + + plan2_log = run_plan2_command_log.triggered_plan_log_id + plan2_log_variable_values = plan2_log.variable_values + + # check that variable values are the same + self.assertEqual( + run_plan2_command_log_variable_values, plan2_log_variable_values + ) + + # Before finished command (run child plan): we have next variable values: + # {'test_version': '16.0', 'test_url': 'https://www.test.com'} + + # After finished command (run child plan): we have next variable values: + # { + # 'test_version': '16.0', + # 'test_url': 'https://www.cetmix.com', + # 'test_path_': '/another_test_path', + # 'test_dir': 'test_dir', + # '_my_value': 'Just To Test', + # 'random_var_reference': 'random_var_value' + # } + self.assertEqual( + run_plan2_command_log_variable_values["test_path_"], "/another_test_path" + ) + self.assertEqual(run_plan2_command_log_variable_values["test_dir"], "test_dir") + self.assertEqual( + run_plan2_command_log_variable_values["_my_value"], "Just To Test" + ) + self.assertEqual( + run_plan2_command_log_variable_values["random_var_reference"], + "random_var_value", + ) + self.assertEqual( + run_plan2_command_log_variable_values[self.variable_version.reference], + "16.0", + ) + self.assertEqual( + run_plan2_command_log_variable_values[self.variable_url.reference], + "https://www.cetmix.com", + ) + + # After finished main plan: we have next variable values: + # { + # 'test_version': '16.0', + # 'test_url': 'https://www.cetmix.com', + # 'test_path_': '/another_test_path', + # 'test_dir': 'test_dir', + # '_my_value': 'Just To Test', + # 'random_var_reference': 'another_random_var_value' + # } + self.assertEqual(plan_log.variable_values["test_path_"], "/another_test_path") + self.assertEqual(plan_log.variable_values["test_dir"], "test_dir") + self.assertEqual(plan_log.variable_values["_my_value"], "Just To Test") + self.assertEqual( + plan_log.variable_values["random_var_reference"], "another_random_var_value" + ) + self.assertEqual( + plan_log.variable_values[self.variable_version.reference], "16.0" + ) + self.assertEqual( + plan_log.variable_values[self.variable_url.reference], + "https://www.cetmix.com", + ) + + @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") + def test_plan_render_jet_template(self): + """Test plan rendering jet template""" + plan_log_record_count = self.PlanLog.search_count( + [("server_id", "=", self.server_test_1.id)] + ) + self.assertEqual(plan_log_record_count, 0, "Plan logs should be empty") + + # Set variable values for the server + res = self.CetmixTower.server_set_variable_value( + self.server_test_1.reference, "test_path_", "/opt/tower" + ) + self.assertEqual(res["exit_code"], 0, "Variable 'test_path_' not found/updated") + res = self.CetmixTower.server_set_variable_value( + self.server_test_1.reference, "test_dir", "server1" + ) + self.assertEqual(res["exit_code"], 0, "Variable 'test_dir' not found/updated") + + # -- 1-- + # Run plan without jet template + self.server_test_1.run_flight_plan(self.plan_2) + + plan_log = self.PlanLog.search( + [ + ("plan_id", "=", self.plan_2.id), + ("server_id", "=", self.server_test_1.id), + ], + ) + self.assertEqual(len(plan_log), 1, "A single plan log should be created") + self.assertEqual( + len(plan_log.command_log_ids), 2, "Two commands should be executed" + ) + self.assertFalse(plan_log.jet_template_id, "Jet template should be empty") + + # Check the SSH command output. Second command + rendered_code_expected = "cd /opt/tower && mkdir server1" + ssh_command_log = plan_log.command_log_ids[1] + self.assertEqual( + ssh_command_log.code, rendered_code_expected, "SSH command should succeed" + ) + + # Check the nested plan command output. + # This is needed to ensure that the nested plan commands + # are rendered properly. + nested_ssh_command_log = plan_log.command_log_ids[ + 0 + ].triggered_plan_log_id.command_log_ids[0] + self.assertEqual( + nested_ssh_command_log.code, + rendered_code_expected, + "SSH command should succeed", + ) + + # -- 2 -- + # Run plan with jet template + + # Delete previous plan log + plan_log.unlink() + + self.server_test_1.run_flight_plan( + self.plan_2, jet_template=self.jet_template_sample + ) + + plan_log = self.PlanLog.search( + [ + ("plan_id", "=", self.plan_2.id), + ("server_id", "=", self.server_test_1.id), + ], + ) + self.assertEqual(len(plan_log), 1, "A single plan log should be created") + self.assertEqual( + len(plan_log.command_log_ids), 2, "Two commands should be executed" + ) + self.assertEqual( + plan_log.jet_template_id, + self.jet_template_sample, + "Jet template doesn't match", + ) + + # Check the SSH command output. Second command + rendered_code_expected = "cd /jets/templates/template1 && mkdir jet_templates" + ssh_command_log = plan_log.command_log_ids[1] + self.assertEqual( + ssh_command_log.code, rendered_code_expected, "SSH command should succeed" + ) + + # Check the nested plan command output. + # This is needed to ensure that the nested plan commands + # are rendered properly. + nested_ssh_command_log = plan_log.command_log_ids[ + 0 + ].triggered_plan_log_id.command_log_ids[0] + self.assertEqual( + nested_ssh_command_log.code, + rendered_code_expected, + "SSH command should succeed", + ) + + # -- 3 -- + # Run plan with jet + # Delete previous plan log + plan_log.unlink() + + self.server_test_1.run_flight_plan(self.plan_2, jet=self.jet_sample) + + plan_log = self.PlanLog.search( + [ + ("plan_id", "=", self.plan_2.id), + ("server_id", "=", self.server_test_1.id), + ], + ) + self.assertEqual(len(plan_log), 1, "A single plan log should be created") + self.assertEqual( + len(plan_log.command_log_ids), 2, "Two commands should be executed" + ) + self.assertEqual( + plan_log.jet_template_id, + self.jet_template_sample, + "Jet template doesn't match", + ) + self.assertEqual(plan_log.jet_id, self.jet_sample, "Jet doesn't match") + + # Check the SSH command output. Second command + rendered_code_expected = "cd /jets/jet1 && mkdir jet_templates" + ssh_command_log = plan_log.command_log_ids[1] + self.assertEqual( + ssh_command_log.code, rendered_code_expected, "SSH command should succeed" + ) + + # Check the nested plan command output. + # This is needed to ensure that the nested plan commands + # are rendered properly. + nested_ssh_command_log = plan_log.command_log_ids[ + 0 + ].triggered_plan_log_id.command_log_ids[0] + self.assertEqual( + nested_ssh_command_log.code, + rendered_code_expected, + "SSH command should succeed", + ) + + def test_plan_with_custom_values_in_condition(self): + """ + Ensure that plan line conditions see updated custom_values + produced by previous commands. + + 1) python sets test_path_ = '/test_path' + 2) create_dir with condition "{{ test_path_ }} == '/test_path'" -> executes + 3) python updates test_path_ = '/another_test_path' + 4) create_dir with condition "{{ test_path_ }} == '/another_test_path'" + -> executes + Then invert conditions and check both lines are skipped appropriately. + """ + command_python_1_id = self.command_python_custom_variable_values_1.id + command_python_2_id = self.command_python_custom_variable_values_2.id + + plan = self._create_plan( + **{ + "name": "Plan with custom_values in condition", + "line_ids": [ + (0, 0, {"command_id": command_python_1_id, "sequence": 1}), + ( + 0, + 0, + { + "command_id": self.command_create_dir.id, + "sequence": 2, + "condition": "{{ test_path_ }} == '/test_path'", + }, + ), + (0, 0, {"command_id": command_python_2_id, "sequence": 3}), + ( + 0, + 0, + { + "command_id": self.command_create_dir.id, + "sequence": 4, + "condition": "{{ test_path_ }} == '/another_test_path'", + }, + ), + ], + } + ) + + plan_log = self.server_test_1.run_flight_plan(plan) + + logs = plan_log.command_log_ids + self.assertEqual(len(logs), 4, "Should be 4 command logs") + + create_dir_logs = logs.filtered( + lambda line: line.command_id == self.command_create_dir + ) + self.assertEqual(len(create_dir_logs), 2, "Should be 2 create_dir logs") + + self.assertFalse( + create_dir_logs[0].is_skipped, "First create_dir must be executed" + ) + self.assertFalse( + create_dir_logs[1].is_skipped, "Second create_dir must be executed" + ) + + self.assertIn("/test_path", create_dir_logs[0].code) + self.assertIn("/another_test_path", create_dir_logs[1].code) + + def test_plan_stop_mid_execution(self): + """ + Test that plan is correctly marked as stopped and + further commands are not executed. + """ + plan = self._create_plan( + name="Test Plan Stop", + line_ids=[ + (0, 0, {"command_id": self.command_create_dir.id, "sequence": 1}), + (0, 0, {"command_id": self.command_list_dir.id, "sequence": 2}), + ], + ) + server = self.server_test_1 + + cx_tower_plan_line_obj = self.registry["cx.tower.plan.line"] + _run_super = cx_tower_plan_line_obj._run + + # Save plan_log for control is_running + plan_log_holder = {} + + def fake_run(self, server, plan_log_record, **kwargs): + # Save plan_log for control is_running + plan_log_holder["log"] = plan_log_record + + # Call stop() after first command + if len(plan_log_record.command_log_ids) == 0: + plan_log_record.stop() + # After this call plan_log should be stopped, + # and finish_date should be filled + # Continue execution in standard way + return _run_super(self, server, plan_log_record, **kwargs) + + with patch.object(cx_tower_plan_line_obj, "_run", new=fake_run): + plan_log = plan._run_single(server) + + self.assertTrue(plan_log.is_stopped, "Plan should be stopped") + self.assertFalse(plan_log.is_running, "Plan should not be in running status") + self.assertEqual( + plan_log.plan_status, PLAN_STOPPED, "Status should be PLAN_STOPPED" + ) + self.assertTrue(plan_log.finish_date, "Finish date should be filled") + self.assertLessEqual( + len(plan_log.command_log_ids), + 1, + "There should be maximum one command in the log", + ) + + def test_flight_plan_reference_update(self): + """Test flight plan reference update cascades to dependent models""" + # 1. Add a variable value to plan_line_1_action_2 + variable_value = self.VariableValue.create( + { + "variable_id": self.variable_os.id, + "value_char": "Ubuntu 20.04", + "plan_line_action_id": self.plan_line_1_action_2.id, + } + ) + + # Store original references for comparison + original_plan_reference = self.plan_1.reference + original_plan_line_1_reference = self.plan_line_1.reference + original_plan_line_2_reference = self.plan_line_2.reference + original_plan_line_1_action_1_reference = self.plan_line_1_action_1.reference + original_plan_line_1_action_2_reference = self.plan_line_1_action_2.reference + original_plan_line_2_action_1_reference = self.plan_line_2_action_1.reference + original_plan_line_2_action_2_reference = self.plan_line_2_action_2.reference + original_variable_value_reference = variable_value.reference + + # 2. Change the reference for plan_1 to "nice_new_plan" + self.plan_1.write({"reference": "nice_new_plan"}) + + # 3. Verify that references are updated for plan lines + # Invalidate models to refresh all references + self.env["cx.tower.plan"].invalidate_model(["reference"]) + self.env["cx.tower.plan.line"].invalidate_model(["reference"]) + self.env["cx.tower.plan.line.action"].invalidate_model(["reference"]) + self.env["cx.tower.variable.value"].invalidate_model(["reference"]) + + # Check that plan reference was updated + self.assertEqual(self.plan_1.reference, "nice_new_plan") + self.assertNotEqual(self.plan_1.reference, original_plan_reference) + + # Check that plan line references were updated to include the new plan reference + self.assertIn("nice_new_plan", self.plan_line_1.reference) + self.assertIn("nice_new_plan", self.plan_line_2.reference) + self.assertNotEqual(self.plan_line_1.reference, original_plan_line_1_reference) + self.assertNotEqual(self.plan_line_2.reference, original_plan_line_2_reference) + + # Check that plan line action references were updated + self.assertIn("nice_new_plan", self.plan_line_1_action_1.reference) + self.assertIn("nice_new_plan", self.plan_line_1_action_2.reference) + self.assertIn("nice_new_plan", self.plan_line_2_action_1.reference) + self.assertIn("nice_new_plan", self.plan_line_2_action_2.reference) + self.assertNotEqual( + self.plan_line_1_action_1.reference, original_plan_line_1_action_1_reference + ) + self.assertNotEqual( + self.plan_line_1_action_2.reference, original_plan_line_1_action_2_reference + ) + self.assertNotEqual( + self.plan_line_2_action_1.reference, original_plan_line_2_action_1_reference + ) + self.assertNotEqual( + self.plan_line_2_action_2.reference, original_plan_line_2_action_2_reference + ) + + # Check that variable value reference was updated + # to include the new plan reference + self.assertIn("nice_new_plan", variable_value.reference) + self.assertNotEqual(variable_value.reference, original_variable_value_reference) + + # Verify the reference pattern for variable value follows the expected format: + # ___ # noqa: E501 + expected_pattern = ( + f"{self.variable_os.reference}_variable_value_plan_line_action_" + f"{self.plan_line_1_action_2.reference}" + ) + self.assertEqual(variable_value.reference, expected_pattern) + + def test_flight_plan_with_child_plan_command_exception(self): + """ + Test flight plan with child plan where command exception occurs. + + Scenario: + - Main flight plan has 2 commands: + 1. Simple python command (success) + 2. Child flight plan with 2 commands where first fails with command exception + - Verify error propagation: command -> child plan -> main plan + - The command exception is simulated using the existing mocking system + that raises exceptions when commands contain "raise" + """ + + # Create child flight plan with 2 commands + child_plan = self.Plan.create( + { + "name": "Child Plan with Error", + "note": "Child plan that will fail on first command", + } + ) + + # Command 1 of child plan - will fail with command exception + child_command_1 = self.Command.create( + { + "name": "Child Command 1 - Command Exception", + "action": "ssh_command", + "code": "raise", # This will trigger command exception in mock + } + ) + + # Command 2 of child plan - should not execute due to error in command 1 + child_command_2 = self.Command.create( + { + "name": "Child Command 2 - Should Not Run", + "action": "python_code", + "code": """ +result = { + "exit_code": 0, + "message": "This should not execute" +} + """, + } + ) + + # Create plan lines for child plan + self.plan_line.create( + { + "sequence": 10, + "plan_id": child_plan.id, + "command_id": child_command_1.id, + } + ) + self.plan_line.create( + { + "sequence": 20, + "plan_id": child_plan.id, + "command_id": child_command_2.id, + } + ) + + # Create command to run child plan + run_child_plan_command = self.Command.create( + { + "name": "Run Child Plan", + "action": "plan", + "flight_plan_id": child_plan.id, + } + ) + + # Create main flight plan with 2 commands + main_plan = self.Plan.create( + { + "name": "Main Plan with Child Plan", + "note": "Main plan with python command and child plan", + } + ) + + # Command 1 of main plan - simple python command (should succeed) + main_command_1 = self.Command.create( + { + "name": "Main Command 1 - Python Success", + "action": "python_code", + "code": """ +result = { + "exit_code": 0, + "message": "Main plan python command executed successfully" +} + """, + } + ) + + # Command 2 of main plan - run child plan (will fail) + main_command_2 = run_child_plan_command + + # Create plan lines for main plan + self.plan_line.create( + { + "sequence": 10, + "plan_id": main_plan.id, + "command_id": main_command_1.id, + } + ) + self.plan_line.create( + { + "sequence": 20, + "plan_id": main_plan.id, + "command_id": main_command_2.id, + } + ) + # Run the first command again + self.plan_line.create( + { + "sequence": 30, + "plan_id": main_plan.id, + "command_id": main_command_1.id, + } + ) + + # Run the main flight plan + plan_log = self.server_test_1.run_flight_plan(main_plan) + + # Verify main plan finished with error + self.assertNotEqual( + plan_log.plan_status, 0, "Main plan should not finish successfully" + ) + + # Get all plan logs for verification + all_plan_logs = plan_log | self.PlanLog.search( + [("parent_flight_plan_log_id", "=", plan_log.id)] + ) + + # Should have 2 plan logs: main plan and child plan + self.assertEqual( + len(all_plan_logs), 2, "Should have 2 plan logs: main and child" + ) + + main_plan_log = all_plan_logs.filtered(lambda log: log.plan_id == main_plan) + child_plan_log = all_plan_logs.filtered( + lambda log: log.parent_flight_plan_log_id == main_plan_log + ) + + self.assertTrue(main_plan_log, "Main plan log should exist") + self.assertTrue(child_plan_log, "Child plan log should exist") + + # Verify child plan finished with error + # The child plan should finish with an error + # (either SSH_CONNECTION_ERROR or GENERAL_ERROR) + self.assertNotEqual( + child_plan_log.plan_status, + 0, + "Child plan should not finish successfully", + ) + + # Get command logs for verification + all_command_logs = self.CommandLog.search( + [("plan_log_id", "in", all_plan_logs.ids)] + ) + + # Should have 3 command logs: main python, + # run child plan, child command exception + self.assertEqual(len(all_command_logs), 3, "Should have 3 command logs") + + # Find specific command logs + main_python_log = all_command_logs.filtered( + lambda log: log.command_id == main_command_1 + ) + run_child_plan_log = all_command_logs.filtered( + lambda log: log.command_id == main_command_2 + ) + child_ssh_error_log = all_command_logs.filtered( + lambda log: log.command_id == child_command_1 + ) + + # Verify main python command succeeded + self.assertEqual( + main_python_log.command_status, 0, "Main python command should succeed" + ) + self.assertEqual( + main_python_log.command_response, + "Main plan python command executed successfully", + "Main python command should have correct response", + ) + + # Verify run child plan command failed + # The command should fail with an error + # (either SSH_CONNECTION_ERROR or GENERAL_ERROR) + self.assertNotEqual( + run_child_plan_log.command_status, + 0, + "Run child plan command should fail", + ) + + # Verify child SSH command failed + # The SSH command should fail with an error status + # (could be GENERAL_ERROR -100 or 255 depending on how the exception is handled) + self.assertNotEqual( + child_ssh_error_log.command_status, 0, "Child SSH command should fail" + ) + # The error message should contain information about + # the SSH connection failure + # The exact error message may vary depending + # on how the exception is handled + self.assertTrue( + child_ssh_error_log.command_error, + "Child SSH command should have an error message", + ) + + # Verify that child command 2 was not executed (no log for it) + child_command_2_log = all_command_logs.filtered( + lambda log: log.command_id == child_command_2 + ) + self.assertFalse( + child_command_2_log, "Child command 2 should not have been executed" + ) + + # Verify plan log relationships + self.assertEqual( + main_plan_log.command_log_ids, + main_python_log | run_child_plan_log, + "Main plan should have correct command logs", + ) + + self.assertEqual( + child_plan_log.command_log_ids, + child_ssh_error_log, + "Child plan should have only the failed command log", + ) + + # Verify that the error propagated correctly through the hierarchy + # The error should propagate from command -> child plan -> main plan + # The specific error codes may vary depending + # on how the system handles the error + self.assertNotEqual( + main_plan_log.plan_status, + 0, + "Error should propagate from child to main plan", + ) + self.assertNotEqual( + child_plan_log.plan_status, 0, "Error should be present in child plan" + ) + self.assertNotEqual( + child_ssh_error_log.command_status, + 0, + "SSH command should have an error status", + ) + self.assertEqual( + child_ssh_error_log.command_status, + child_plan_log.plan_status, + "Child plan should have the same error status as the SSH command", + ) + self.assertEqual( + child_ssh_error_log.command_status, + main_plan_log.plan_status, + "Main plan should have the same error status as the SSH command", + ) + + def test_skip_command_error_flow(self): + """Plan flow: + 1) success, 2) success, 3) error -> sets command_error variable, + 4) skipped if not var, 5) runs if var and exits -1. + """ + # Create commands + command_success = self.Command.create( + { + "name": "Command -> Success", + "action": "python_code", + "code": "# Just return default values", + } + ) + command_error = self.Command.create( + { + "name": "Command -> Error", + "action": "python_code", + "code": "result = {'exit_code': -100, 'message': 'Error'}", + } + ) + command_after_failed = self.Command.create( + { + "name": "Command -> After failed", + "action": "python_code", + "code": ( + "name = server.name + ' --after-failed-- '\n" + "server.write({'name': name})" + ), + } + ) + command_last_one = self.Command.create( + { + "name": "Command -> The last one", + "action": "python_code", + "code": ( + "name = server.name + ' --last-one-- '\n" + "server.write({'name': name})" + ), + } + ) + + # Variable used in conditions + variable_command_error = self.Variable.create( + { + "name": "command_error", + "reference": "test_command_error", + "variable_type": "s", + } + ) + + # Plan and lines + plan = self.Plan.create( + { + "name": "Test skip command error", + "on_error_action": "e", + "custom_exit_code": 0, + } + ) + + self.plan_line.create( + {"sequence": 10, "plan_id": plan.id, "command_id": command_success.id} + ) + self.plan_line.create( + {"sequence": 20, "plan_id": plan.id, "command_id": command_success.id} + ) + + line3 = self.plan_line.create( + {"sequence": 30, "plan_id": plan.id, "command_id": command_error.id} + ) + action3 = self.plan_line_action.create( + { + "line_id": line3.id, + "sequence": 10, + "condition": "!=", + "value_char": "0", + "action": "n", + } + ) + + self.VariableValue.create( + { + "variable_id": variable_command_error.id, + "value_char": "1", + "plan_line_action_id": action3.id, + } + ) + + self.plan_line.create( + { + "sequence": 40, + "plan_id": plan.id, + "command_id": command_after_failed.id, + "condition": "not {{ test_command_error }}", + "variable_ids": [(6, 0, [variable_command_error.id])], + } + ) + + line5 = self.plan_line.create( + { + "sequence": 50, + "plan_id": plan.id, + "command_id": command_last_one.id, + "condition": "{{ test_command_error }}", + "variable_ids": [(6, 0, [variable_command_error.id])], + } + ) + self.plan_line_action.create( + { + "line_id": line5.id, + "sequence": 10, + "condition": "==", + "value_char": "0", + "action": "ec", + "custom_exit_code": -1, + } + ) + + plan_log = self.server_test_1.run_flight_plan(plan) + + self.assertEqual(len(plan_log.command_log_ids), 5) + logs = plan_log.command_log_ids + self.assertTrue( + all( + log.command_status == 0 + for log in logs.filtered(lambda log: log.command_id == command_success) + ) + ) + + error_log = logs.filtered(lambda log: log.command_id == command_error) + self.assertIn(variable_command_error.reference, error_log.variable_values) + self.assertTrue(error_log.command_status == GENERAL_ERROR) + + self.assertTrue( + logs.filtered(lambda log: log.command_id == command_after_failed).mapped( + "command_status" + )[0] + == PLAN_LINE_CONDITION_CHECK_FAILED + ) + self.assertTrue( + logs.filtered(lambda log: log.command_id == command_last_one).mapped( + "command_status" + )[0] + == 0 + ) + + # Final plan status must be custom exit code -1 from line 5 action + self.assertEqual(plan_log.plan_status, -1) + + def test_plan_line_condition_error(self): + """Test plan line condition error + First line is skipped because of condition error + Second line is executed successfully + """ + # Create commands + command_success = self.Command.create( + { + "name": "Command -> Success", + "action": "python_code", + "code": "# Just return default values", + } + ) + + # Plan and lines + plan = self.Plan.create( + { + "name": "Test plan line condition error", + } + ) + + self.plan_line.create( + { + "sequence": 10, + "plan_id": plan.id, + "command_id": command_success.id, + "condition": "=q", + }, + ) + self.plan_line.create( + {"sequence": 20, "plan_id": plan.id, "command_id": command_success.id} + ) + + with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_plan_line"): + plan_log = self.server_test_1.run_flight_plan(plan) + + # Must be 2 command logs + self.assertEqual(len(plan_log.command_log_ids), 2) + logs = plan_log.command_log_ids + self.assertTrue(logs[0].is_skipped) + self.assertTrue(logs[1].command_status == 0) + + def test_custom_values_not_defined_but_updated(self): + """Test custom values not defined but updated + First command is executed successfully + Second command is executed successfully and updates custom values + """ + # Create commands + command_1 = self.Command.create( + { + "name": "Command -> Success", + "action": "python_code", + "code": "# Just return default values", + } + ) + command_2 = self.Command.create( + { + "name": "Command -> Success", + "action": "python_code", + "code": "custom_values.update({'some_value': '1'})", + } + ) + + # Plan and lines + plan = self.Plan.create( + { + "name": "Test custom values not defined but updated", + } + ) + + self.plan_line.create( + { + "sequence": 10, + "plan_id": plan.id, + "command_id": command_1.id, + }, + ) + + self.plan_line.create( + { + "sequence": 20, + "plan_id": plan.id, + "command_id": command_2.id, + }, + ) + plan_log = self.server_test_1.run_flight_plan(plan) + + # Must be 2 command logs + self.assertEqual(len(plan_log.command_log_ids), 2) + logs = plan_log.command_log_ids + # Both commands should be successful + self.assertEqual(logs[0].command_status, 0) + self.assertEqual(logs[1].command_status, 0) + # Custom values should be updated + self.assertEqual(plan_log.variable_values, {"some_value": "1"}) + + def test_last_flight_plan_line_post_run_action_is_executed(self): + """ + Test last flight plan line post run action is executed + """ + # Create commands + command_error = self.Command.create( + { + "name": "Command -> Error", + "action": "python_code", + "code": "result = {'exit_code': -100, 'message': 'Error'}", + } + ) + + # Plan and lines + plan = self.Plan.create( + { + "name": "Test post run action", + "on_error_action": "e", + "custom_exit_code": 0, + } + ) + + line1 = self.plan_line.create( + {"sequence": 10, "plan_id": plan.id, "command_id": command_error.id} + ) + self.plan_line_action.create( + { + "line_id": line1.id, + "sequence": 10, + "condition": "!=", + "value_char": "0", + "action": "n", + } + ) + line2 = self.plan_line.create( + {"sequence": 20, "plan_id": plan.id, "command_id": command_error.id} + ) + self.plan_line_action.create( + { + "line_id": line2.id, + "sequence": 10, + "condition": "!=", + "value_char": "0", + "action": "ec", + "custom_exit_code": 0, + } + ) + + plan_log = self.server_test_1.run_flight_plan(plan) + + self.assertEqual(len(plan_log.command_log_ids), 2) + + # Final plan status must be custom exit code 0 + self.assertEqual(plan_log.plan_status, 0) diff --git a/addons/cetmix_tower_server/tests/test_plan_line.py b/addons/cetmix_tower_server/tests/test_plan_line.py new file mode 100644 index 0000000..7025a85 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_plan_line.py @@ -0,0 +1,540 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common import TestTowerCommon + + +class TestTowerPlanLine(TestTowerCommon): + """Test the cx.tower.plan.line model access rights.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a test plan with access level 1 for user tests + cls.test_plan = cls.Plan.create( + { + "name": "Test Access Plan", + "access_level": "1", + "user_ids": [(6, 0, [cls.user.id])], + "manager_ids": [(6, 0, [cls.manager.id])], + } + ) + + # Create a test plan line + cls.test_line = cls.plan_line.create( + { + "plan_id": cls.test_plan.id, + "command_id": cls.command_create_dir.id, + "sequence": 10, + } + ) + + # Create additional servers for testing server-based access + cls.server_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "test2", + "ssh_password": "test2", + "ssh_port": 22, + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + } + ) + + cls.server_3 = cls.Server.create( + { + "name": "Test Server 3", + "ip_v4_address": "localhost", + "ssh_username": "test3", + "ssh_password": "test3", + "ssh_port": 22, + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + } + ) + + def test_user_read_access(self): + """Test user read access to plan lines""" + # Case 1: User should be able to read line when: + # - access_level == "1" + # - user is in plan's user_ids OR server's user_ids + recs = self.plan_line.with_user(self.user).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "User should be able to read line when conditions are met", + ) + + # Case 2: User should not be able to read when access_level > "1" + self.test_plan.write( + { + "access_level": "2", + } + ) + recs = self.plan_line.with_user(self.user).search( + [("id", "=", self.test_line.id)] + ) + self.assertNotIn( + self.test_line, + recs, + "User should not be able to read line when access_level > '1'", + ) + + # Case 3: User should be able to read when in server's user_ids + self.test_plan.write( + { + "access_level": "1", + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + recs = self.plan_line.with_user(self.user).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "User should be able to read line when in server's user_ids", + ) + + def test_user_write_create_unlink_access(self): + """Test user write/create/unlink access restrictions""" + # Users should not be able to create lines + with self.assertRaises(AccessError): + self.plan_line.with_user(self.user).create( + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + "sequence": 20, + } + ) + + # Users should not be able to write lines + with self.assertRaises(AccessError): + self.test_line.with_user(self.user).write({"sequence": 30}) + + # Users should not be able to unlink lines + with self.assertRaises(AccessError): + self.test_line.with_user(self.user).unlink() + + def test_manager_read_access(self): + """Test manager read access to plan lines""" + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in plan's manager_ids OR user_ids + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Manager should be able to read line when conditions are met", + ) + + # Case 2: Manager should not be able to read when access_level > "2" + self.test_plan.write( + { + "access_level": "3", + "manager_ids": [(5, 0, 0)], # Remove all managers + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertNotIn( + self.test_line, + recs, + "Manager should not be able to read line when access_level > '2'", + ) + + # Case 2.5: Manager not not be able to read when not in plan managers + self.test_plan.write( + { + "access_level": "2", + "manager_ids": [(5, 0, 0)], # Remove all managers + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + self.server_test_1.write( + { + "user_ids": [(5, 0, 0)], # Remove all users + "manager_ids": [(5, 0, 0)], # Remove all managers + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertNotIn( + self.test_line, + recs, + "Manager should not be able to read line when access_level > '2'", + ) + + # Case 3: Manager should be able to read when in server's manager_ids + self.test_plan.write( + { + "access_level": "2", + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + self.server_test_1.write( + { + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Manager should be able to read line when in server's manager_ids", + ) + + def test_manager_write_create_access(self): + """Test manager write/create access to plan lines""" + # Case 1: Manager should be able to create/write when: + # - access_level <= "2" + # - manager is in plan's manager_ids + try: + # Test create + self.plan_line.with_user(self.manager).create( + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + "sequence": 20, + } + ) + # Test write + self.test_line.with_user(self.manager).write({"sequence": 30}) + except AccessError: + self.fail("Manager should be able to create/write when conditions are met") + + # Case 2: Manager should not be able to create/write when access_level > "2" + self.test_plan.write( + { + "access_level": "3", + } + ) + with self.assertRaises(AccessError): + self.plan_line.with_user(self.manager).create( + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + "sequence": 40, + } + ) + with self.assertRaises(AccessError): + self.test_line.with_user(self.manager).write({"sequence": 50}) + + def test_manager_unlink_access(self): + """Test manager unlink access to plan lines""" + # Create line as manager to test unlink rights + line = self.plan_line.with_user(self.manager).create( + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + "sequence": 20, + } + ) + + # Case 1: Manager should be able to unlink when: + # - access_level <= "2" + # - manager is the creator + # - manager is in plan's manager_ids + try: + line.unlink() + except AccessError: + self.fail("Manager should be able to unlink when conditions are met") + + # Case 2: Manager should not be able to unlink lines created by others + line = self.test_line # Created by admin in setUp + with self.assertRaises(AccessError): + line.with_user(self.manager).unlink() + + def test_root_unrestricted_read_access(self): + """Test root user unrestricted read access""" + # Set most restrictive conditions + self.test_plan.write( + { + "access_level": "3", + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], + } + ) + + # Root should still be able to read + recs = self.plan_line.with_user(self.root).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Root should be able to read regardless of access restrictions", + ) + + # Root should be able to read all records + all_recs = self.plan_line.with_user(self.root).search([]) + self.assertIn( + self.test_line, + all_recs, + "Root should be able to read all records", + ) + + def test_root_unrestricted_write_access(self): + """Test root user unrestricted write access""" + # Set most restrictive conditions + self.test_plan.write( + { + "access_level": "3", + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], + } + ) + + try: + # Test single field update + self.test_line.with_user(self.root).write({"sequence": 100}) + + # Test multiple field update + self.test_line.with_user(self.root).write( + { + "sequence": 200, + "path": "/test/path", + "use_sudo": True, + } + ) + except AccessError: + self.fail("Root should be able to write regardless of access restrictions") + + def test_root_unrestricted_create_access(self): + """Test root user unrestricted create access""" + # Set most restrictive conditions + self.test_plan.write( + { + "access_level": "3", + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], + } + ) + + try: + # Test create with minimal values + new_line_1 = self.plan_line.with_user(self.root).create( + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + } + ) + + # Test create with all values + new_line_2 = self.plan_line.with_user(self.root).create( + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + "sequence": 300, + "path": "/another/test/path", + "use_sudo": True, + "condition": "{{ test_condition }}", + } + ) + + # Verify created records are readable + recs = self.plan_line.with_user(self.root).search( + [("id", "in", [new_line_1.id, new_line_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Root should be able to read newly created records", + ) + except AccessError: + self.fail("Root should be able to create regardless of access restrictions") + + def test_root_unrestricted_unlink_access(self): + """Test root user unrestricted unlink access""" + # Set most restrictive conditions + self.test_plan.write( + { + "access_level": "3", + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], + } + ) + + # Create test records + test_lines = self.plan_line.with_user(self.root).create( + [ + { + "plan_id": self.test_plan.id, + "command_id": self.command_create_dir.id, + "sequence": seq, + } + for seq in range(400, 403) + ] + ) + + try: + # Test single record unlink + test_lines[0].with_user(self.root).unlink() + + # Test multiple record unlink + test_lines[1:].with_user(self.root).unlink() + + # Verify records are deleted + recs = self.plan_line.with_user(self.root).search( + [("id", "in", test_lines.ids)] + ) + self.assertEqual( + len(recs), + 0, + "Root should be able to delete records completely", + ) + except AccessError: + self.fail("Root should be able to unlink regardless of access restrictions") + + def test_manager_server_based_read_access(self): + """Test manager read access based on server relationships""" + # Remove direct manager access from plan + self.test_plan.write( + { + "manager_ids": [(5, 0, 0)], # Clear manager_ids + "access_level": "2", + } + ) + + # Case 1: No servers linked - should have access + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Manager should be able to read when no servers are linked", + ) + + # Case 2: Server linked but manager not in server's users/managers + self.test_plan.write( + { + "server_ids": [(6, 0, [self.server_2.id])], + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertNotIn( + self.test_line, + recs, + "Manager should not be able to read when not in server's users/managers", + ) + + # Case 3: Manager in server's user_ids + self.server_2.write( + { + "user_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Manager should be able to read when in server's user_ids", + ) + + # Case 4: Manager in server's manager_ids + self.server_2.write( + { + "user_ids": [(5, 0, 0)], + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Manager should be able to read when in server's manager_ids", + ) + + # Case 5: Multiple servers - access through one server + self.test_plan.write( + { + "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertIn( + self.test_line, + recs, + "Manager should be able to read when in at least one server's manager_ids", + ) + + # Case 6: Multiple servers - no access + self.server_2.write( + { + "manager_ids": [(5, 0, 0)], + } + ) + recs = self.plan_line.with_user(self.manager).search( + [("id", "=", self.test_line.id)] + ) + self.assertNotIn( + self.test_line, + recs, + "Manager should not be able to read when not " + "in any server's users/managers", + ) + + def test_manager_server_based_write_access(self): + """Test manager write access based on server relationships""" + # Remove direct manager access from plan + self.test_plan.write( + { + "manager_ids": [(5, 0, 0)], # Clear manager_ids + "access_level": "2", + "server_ids": [(6, 0, [self.server_2.id])], + } + ) + + # Case 1: No server access - should not be able to write + with self.assertRaises(AccessError): + self.test_line.with_user(self.manager).write({"sequence": 40}) + + # Case 2: Manager in server's manager_ids - still should not be able to write + self.server_2.write( + { + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + with self.assertRaises(AccessError): + self.test_line.with_user(self.manager).write({"sequence": 50}) + + # Case 3: Manager in plan's manager_ids - should be able to write + self.test_plan.write( + { + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + try: + self.test_line.with_user(self.manager).write({"sequence": 60}) + except AccessError: + self.fail("Manager should be able to write when in plan's manager_ids") diff --git a/addons/cetmix_tower_server/tests/test_plan_line_action.py b/addons/cetmix_tower_server/tests/test_plan_line_action.py new file mode 100644 index 0000000..ddd694d --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_plan_line_action.py @@ -0,0 +1,255 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common import TestTowerCommon + + +class TestTowerPlanLineAction(TestTowerCommon): + """Test the cx.tower.plan.line.action model access rights.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a test server + cls.server = cls.Server.create( + { + "name": "Test Server", + "ip_v4_address": "localhost", + "ssh_username": "test", + "ssh_password": "test", + "ssh_port": 22, + "user_ids": [(6, 0, [cls.user.id])], + "manager_ids": [(6, 0, [cls.manager.id])], + } + ) + + # Create a test plan with access level 1 for user tests + cls.test_plan = cls.Plan.create( + { + "name": "Test Access Plan", + "access_level": "1", + "user_ids": [(6, 0, [cls.user.id])], + "manager_ids": [(6, 0, [cls.manager.id])], + } + ) + + # Create a test plan line + cls.test_plan_line = cls.plan_line.create( + { + "plan_id": cls.test_plan.id, + "command_id": cls.command_create_dir.id, + "sequence": 10, + } + ) + + # Create a test action + cls.test_action = cls.plan_line_action.create( + { + "line_id": cls.test_plan_line.id, + "condition": "==", + "value_char": "0", + "action": "n", + } + ) + + def test_user_read_access(self): + """Test user read access to plan line actions""" + # Case 1: User should be able to read action when: + # - access_level == "1" + # - user is in plan's user_ids OR server's user_ids + recs = self.plan_line_action.with_user(self.user).search( + [("id", "=", self.test_action.id)] + ) + self.assertIn( + self.test_action, + recs, + "User should be able to read action when conditions are met", + ) + + # Case 2: User should not be able to read when access_level > "1" + self.test_plan.access_level = "2" + recs = self.plan_line_action.with_user(self.user).search( + [("id", "=", self.test_action.id)] + ) + self.assertNotIn( + self.test_action, + recs, + "User should not be able to read action when access_level > '1'", + ) + + # Case 3: User should not be able to read when not in user_ids + self.test_plan.access_level = "1" + self.test_plan.user_ids = [(5, 0, 0)] # Remove all users + recs = self.plan_line_action.with_user(self.user).search( + [("id", "=", self.test_action.id)] + ) + self.assertNotIn( + self.test_action, + recs, + "User should not be able to read action when not in user_ids", + ) + + # Case 4: User should be able to read when in server's user_ids + self.test_plan.server_ids = [(6, 0, [self.server.id])] + recs = self.plan_line_action.with_user(self.user).search( + [("id", "=", self.test_action.id)] + ) + self.assertIn( + self.test_action, + recs, + "User should be able to read action when in server's user_ids", + ) + + def test_user_write_create_unlink_access(self): + """Test user write/create/unlink access restrictions""" + # Users should not be able to create actions + with self.assertRaises(AccessError): + self.plan_line_action.with_user(self.user).create( + { + "line_id": self.test_plan_line.id, + "condition": "==", + "value_char": "0", + "action": "n", + } + ) + + # Users should not be able to write actions + with self.assertRaises(AccessError): + self.test_action.with_user(self.user).write({"value_char": "1"}) + + # Users should not be able to unlink actions + with self.assertRaises(AccessError): + self.test_action.with_user(self.user).unlink() + + def test_manager_read_access(self): + """Test manager read access to plan line actions""" + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in plan's manager_ids + recs = self.plan_line_action.with_user(self.manager).search( + [("id", "=", self.test_action.id)] + ) + self.assertIn( + self.test_action, + recs, + "Manager should be able to read action when conditions are met", + ) + + # Case 2: Manager should not be able to read when access_level > "2" + self.test_plan.access_level = "3" + recs = self.plan_line_action.with_user(self.manager).search( + [("id", "=", self.test_action.id)] + ) + self.assertNotIn( + self.test_action, + recs, + "Manager should not be able to read action when access_level > '2'", + ) + + # Case 3: Manager should be able to read when in server's manager_ids + self.test_plan.access_level = "2" + self.test_plan.manager_ids = [(5, 0, 0)] # Remove all managers + self.test_plan.server_ids = [(6, 0, [self.server.id])] + recs = self.plan_line_action.with_user(self.manager).search( + [("id", "=", self.test_action.id)] + ) + self.assertIn( + self.test_action, + recs, + "Manager should be able to read when in server's manager_ids", + ) + + def test_manager_write_create_access(self): + """Test manager write/create access to plan line actions""" + # Case 1: Manager should be able to create/write when: + # - access_level <= "2" + # - manager is in plan's manager_ids + try: + # Test create + self.plan_line_action.with_user(self.manager).create( + { + "line_id": self.test_plan_line.id, + "condition": "==", + "value_char": "1", + "action": "n", + } + ) + # Test write + self.test_action.with_user(self.manager).write({"value_char": "2"}) + except AccessError: + self.fail("Manager should be able to create/write when conditions are met") + + # Case 2: Manager should not be able to create/write when access_level > "2" + self.test_plan.access_level = "3" + with self.assertRaises(AccessError): + self.plan_line_action.with_user(self.manager).create( + { + "line_id": self.test_plan_line.id, + "condition": "==", + "value_char": "1", + "action": "n", + } + ) + with self.assertRaises(AccessError): + self.test_action.with_user(self.manager).write({"value_char": "3"}) + + def test_manager_unlink_access(self): + """Test manager unlink access to plan line actions""" + # Create action as manager to test unlink rights + action = self.plan_line_action.with_user(self.manager).create( + { + "line_id": self.test_plan_line.id, + "condition": "==", + "value_char": "0", + "action": "n", + } + ) + + # Case 1: Manager should be able to unlink when: + # - access_level <= "2" + # - manager is the creator + # - manager is in plan's manager_ids + try: + action.unlink() + except AccessError: + self.fail("Manager should be able to unlink when conditions are met") + + # Case 2: Manager should not be able to unlink actions created by others + action = self.test_action # Created by admin in setUp + with self.assertRaises(AccessError): + action.with_user(self.manager).unlink() + + def test_root_unrestricted_access(self): + """Test root user unrestricted access""" + # Root should have full access regardless of conditions + try: + # Test read + recs = self.plan_line_action.with_user(self.root).search( + [("id", "=", self.test_action.id)] + ) + self.assertIn( + self.test_action, + recs, + "Root should be able to read action without restrictions", + ) + + # Test create + new_action = self.plan_line_action.with_user(self.root).create( + { + "line_id": self.test_plan_line.id, + "condition": "==", + "value_char": "1", + "action": "n", + } + ) + + # Test write + self.test_action.with_user(self.root).write({"value_char": "2"}) + + # Test unlink + new_action.unlink() + except AccessError: + self.fail("Root user should have unrestricted access") diff --git a/addons/cetmix_tower_server/tests/test_plan_log.py b/addons/cetmix_tower_server/tests/test_plan_log.py new file mode 100644 index 0000000..a80f934 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_plan_log.py @@ -0,0 +1,274 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.exceptions import AccessError + +from .common import TestTowerCommon + + +class TestTowerPlanLog(TestTowerCommon): + """Test the cx.tower.plan.log model access rights.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create plans with different access levels + cls.plan_level_1 = cls.Plan.create( + { + "name": "Test Plan L1", + "access_level": "1", + } + ) + + cls.plan_level_2 = cls.Plan.create( + { + "name": "Test Plan L2", + "access_level": "2", + } + ) + + cls.plan_level_3 = cls.Plan.create( + { + "name": "Test Plan L3", + "access_level": "3", + } + ) + + # Create test plan logs with specific users + cls.plan_log_1 = ( + cls.PlanLog.with_user(cls.user) + .sudo() + .create( + { + "server_id": cls.server_test_1.id, + "plan_id": cls.plan_level_1.id, + "start_date": fields.Datetime.now(), + } + ) + ) + + cls.plan_log_2 = ( + cls.PlanLog.with_user(cls.manager) + .sudo() + .create( + { + "server_id": cls.server_test_1.id, + "plan_id": cls.plan_level_1.id, + "start_date": fields.Datetime.now(), + } + ) + ) + + # Create additional server for testing + cls.server_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "test2", + "ssh_password": "test2", + "ssh_port": 22, + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + } + ) + + def test_user_read_access(self): + """Test user read access to plan logs""" + # Add user to server's user_ids to isolate creator check + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + + # Case 1: User should be able to read when: + # - access_level == "1" + # - created by user + # - user is in server's user_ids + recs = self.PlanLog.with_user(self.user).search( + [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] + ) + self.assertEqual( + len(recs), + 1, + "User should only be able to read their own logs", + ) + self.assertIn( + self.plan_log_1, + recs, + "User should be able to read own logs when conditions are met", + ) + self.assertNotIn( + self.plan_log_2, + recs, + "User should not be able to read logs created by others", + ) + + # Case 2: User should not be able to read when not in server's user_ids + self.server_test_1.write( + { + "user_ids": [(5, 0, 0)], # Remove all users + } + ) + recs = self.PlanLog.with_user(self.user).search( + [("id", "=", self.plan_log_1.id)] + ) + self.assertNotIn( + self.plan_log_1, + recs, + "User should not be able to read when not in server's user_ids", + ) + + # Case 3: User should not be able to read when access_level > "1" + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + high_access_log = ( + self.PlanLog.with_user(self.user) + .sudo() + .create( + { + "server_id": self.server_test_1.id, + "plan_id": self.plan_level_2.id, + "start_date": fields.Datetime.now(), + } + ) + ) + recs = self.PlanLog.with_user(self.user).search( + [("id", "=", high_access_log.id)] + ) + self.assertNotIn( + high_access_log, + recs, + "User should not be able to read logs with access_level > '1'" + " even if created by them", + ) + + def test_manager_read_access(self): + """Test manager read access to plan logs""" + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in server's manager_ids + self.server_test_1.write( + { + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.PlanLog.with_user(self.manager).search( + [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when in server's manager_ids", + ) + + # Case 2: Manager should be able to read when in server's user_ids + self.server_test_1.write( + { + "manager_ids": [(5, 0, 0)], # Remove all managers + "user_ids": [(6, 0, [self.manager.id])], + } + ) + recs = self.PlanLog.with_user(self.manager).search( + [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when in server's user_ids", + ) + + # Case 3: Manager should not be able to read when access_level > "2" + high_access_log = ( + self.PlanLog.with_user(self.manager) + .sudo() + .create( + { + "server_id": self.server_test_1.id, + "plan_id": self.plan_level_3.id, + "start_date": fields.Datetime.now(), + } + ) + ) + recs = self.PlanLog.with_user(self.manager).search( + [("id", "=", high_access_log.id)] + ) + self.assertNotIn( + high_access_log, + recs, + "Manager should not be able to read logs with access_level > '2'", + ) + + # Case 4: Manager should not be able to read when he is not + # in users_ids or manager_ids + self.server_test_1.write( + { + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + } + ) + recs = self.PlanLog.with_user(self.manager).search( + [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] + ) + self.assertNotIn( + self.plan_log_1, + recs, + "Manager should not be able to read logs when he is not" + " in users_ids or manager_ids", + ) + + def test_root_read_only_access(self): + """Root can read all plan logs, but cannot create/modify/delete""" + # Create test logs with sudo() + test_logs = self.PlanLog.sudo().create( + [ + { + "server_id": self.server_2.id, + "plan_id": plan.id, + "start_date": fields.Datetime.now(), + } + for plan in [self.plan_level_1, self.plan_level_2, self.plan_level_3] + ] + ) + + # Root should be able to read all logs regardless of: + # - access_level + # - server relationships + # - who created them + recs = self.PlanLog.with_user(self.root).search([("id", "in", test_logs.ids)]) + self.assertEqual( + len(recs), + 3, + "Root should have unrestricted read access to all logs", + ) + + # Root can't create logs + with self.assertRaises(AccessError): + self.PlanLog.with_user(self.root).create( + { + "server_id": self.server_2.id, + "plan_id": self.plan_level_1.id, + "start_date": fields.Datetime.now(), + } + ) + + # Root cannot modify logs + with self.assertRaises(AccessError): + test_logs.with_user(self.root).write({"start_date": fields.Datetime.now()}) + + # Root cannot delete logs + with self.assertRaises(AccessError): + test_logs.with_user(self.root).unlink() + + # Test read on all records + all_recs = self.PlanLog.with_user(self.root).search([]) + self.assertGreater( + len(all_recs), + 0, + "Root should be able to read all plan logs", + ) diff --git a/addons/cetmix_tower_server/tests/test_reference_mixin.py b/addons/cetmix_tower_server/tests/test_reference_mixin.py new file mode 100644 index 0000000..e2dad2c --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_reference_mixin.py @@ -0,0 +1,310 @@ +import re + +from .common import TestTowerCommon + + +class TestTowerReference(TestTowerCommon): + """Test reference generation. + We are using ServerTemplate for that. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.plan_test_mixin = cls.Plan.create( + {"name": "Test Plan reference mixin", "note": "Test Note reference mixin"} + ) + + cls.plan_line_reference_mixin = cls.plan_line.create( + { + "plan_id": cls.plan_test_mixin.id, + "sequence": 1, + "command_id": cls.command_list_dir.id, + } + ) + + def test_reference_generation(self): + """Test reference generation""" + + # --- 1 --- + # Check if auto generated reference matches the pattern + reference_pattern = self.ServerTemplate._get_reference_pattern() + self.assertTrue( + re.match(rf"{reference_pattern}", self.server_template_sample.reference), + "Reference doesn't match template", + ) + + # --- 2 --- + # Create a new server template with custom reference + # and ensure that it's fixed according to the pattern + new_template = self.ServerTemplate.create( + {"name": "Such Much Template", "reference": " Some reference x*((*)) "} + ) + self.assertEqual(new_template.reference, "some_reference_x") + + # --- 3 --- + # Try to create another server template with the same reference and ensure + # that its reference is corrected automatically + yet_another_template = self.ServerTemplate.create( + {"name": "Yet another template", "reference": "some_reference_x"} + ) + self.assertEqual(yet_another_template.reference, "some_reference_x_2") + + # -- 4 --- + # Duplicate the server template and ensure that its name and reference + # are generated properly + yet_another_template_copy = yet_another_template.copy() + self.assertEqual(yet_another_template_copy.name, "Yet another template (copy)") + self.assertEqual( + yet_another_template_copy.reference, "yet_another_template_copy" + ) + + # -- 5 --- + # Update reference and ensure that updated value is correct + yet_another_template_copy.write({"reference": " Some reference x*((*)) "}) + self.assertEqual(yet_another_template_copy.reference, "some_reference_x_3") + + # -- 6 --- + # Update template with a new name and remove reference simultaneously + yet_another_template_copy.write({"name": "Doge so like", "reference": False}) + self.assertEqual(yet_another_template_copy.reference, "doge_so_like") + + # -- 7 --- + # Rename the template and ensure reference is not affected + yet_another_template_copy.write({"name": "Chad"}) + self.assertEqual(yet_another_template_copy.reference, "doge_so_like") + + # -- 8 --- + # Remove the reference and ensure it's regenerated from the name + yet_another_template_copy.write({"reference": False}) + self.assertEqual(yet_another_template_copy.reference, "chad") + + # -- 9 -- + # Update record with the same reference name and ensure it remains the same + yet_another_template_copy.write({"reference": "chad"}) + self.assertEqual(yet_another_template_copy.reference, "chad") + + # -- 10 -- + # Create new template with reference set to False + expected_reference = self.ServerTemplate._generate_or_fix_reference( + "Such Much False Template" + ) + new_template_with_false = self.ServerTemplate.create( + {"name": "Such Much False Template", "reference": False} + ) + self.assertEqual( + new_template_with_false.reference, + expected_reference, + "Reference doesn't match expected one", + ) + + # -- 11 -- + # Create new template with reference and name set to a non valid symbol + # Generic model reference should be used as a reference + expected_reference = self.ServerTemplate._get_model_generic_reference() + new_template_with_non_valid_reference = self.ServerTemplate.create( + {"name": "/", "reference": "/"} + ) + self.assertEqual( + new_template_with_non_valid_reference.reference, + expected_reference, + "Reference doesn't match expected one", + ) + + def test_search_by_reference(self): + """Search record by its reference""" + + # Create a new server template with custom reference + server_template = self.ServerTemplate.create( + {"name": "Such Much Template", "reference": "such_much_template"} + ) + + # Search using correct template reference + search_result = self.ServerTemplate.get_by_reference("such_much_template") + self.assertEqual(server_template, search_result, "Template must be found") + + # Search using malformed (case sensitive) + search_result = self.ServerTemplate.get_by_reference("not_much_template") + self.assertEqual(len(search_result), 0, "Result should be empty") + + def test_prepare_references_valid_input(self): + """ + Ensure references are correctly prepared for valid input. + """ + + vals_list = [{"plan_id": self.plan_test_mixin.id}] + result = self.plan_line._prepare_references( + "cx.tower.plan", "plan_id", vals_list + ) + + # Verify the result contains the expected reference + self.assertIn( + self.plan_test_mixin.id, + result, + "The reference ID should be in the result.", + ) + self.assertEqual( + result[self.plan_test_mixin.id], + self.plan_test_mixin.reference, + "The reference should match the expected value.", + ) + + def test_prepare_references_invalid_model_name(self): + """ + Check that an error is raised for an invalid model name. + """ + + vals_list = [{"plan_id": self.plan_test_mixin.id}] + with self.assertRaises(ValueError) as cm: + self.plan_line._prepare_references("invalid.model", "plan_id", vals_list) + + # Confirm the exception message is as expected + self.assertEqual( + str(cm.exception), + "Model 'invalid.model' does not exist. Please provide a valid model name.", + "The error message should indicate an invalid model name.", + ) + + def test_prepare_references_empty_vals_list(self): + """ + Verify that an empty vals_list returns an empty dictionary. + """ + result = self.plan_line._prepare_references("cx.tower.plan", "plan_id", []) + self.assertEqual( + result, + {}, + "The result should be an empty dictionary when vals_list is empty.", + ) + + def test_populate_references_with_valid_input(self): + """ + Ensure references are populated correctly in the provided values list. + """ + vals_list = [{"plan_id": self.plan_test_mixin.id}] + updated_vals = self.plan_line._pre_populate_references( + "cx.tower.plan", "plan_id", vals_list + ) + + # Check the updated values contain the expected reference format + self.assertEqual( + updated_vals[0]["reference"], + f"{self.plan_test_mixin.reference}_plan_line_1", + "The reference should be correctly populated with the suffix.", + ) + + def test_populate_references_missing_field(self): + """ + Confirm that entries missing the required field are handled properly. + """ + + vals_list_with_missing_field = [{"another_key": 123}] + updated_vals_with_missing = self.plan_line._pre_populate_references( + "cx.tower.plan", "plan_id", vals_list_with_missing_field + ) + self.assertEqual( + updated_vals_with_missing[0]["reference"], + "no_plan_line_1", + "Entries missing the required field should have a default reference.", + ) + + def test_populate_references_duplicate_ids(self): + """ + Ensure that duplicate IDs in the input list are correctly + handled and referenced. + """ + vals_list = [ + {"plan_id": self.plan_test_mixin.id}, + {"plan_id": self.plan_test_mixin.id}, + ] + updated_vals = self.plan_line._pre_populate_references( + "cx.tower.plan", "plan_id", vals_list + ) + + # Verify that each duplicate entry has a unique suffix + self.assertEqual( + updated_vals[0]["reference"], + f"{self.plan_test_mixin.reference}_plan_line_1", + "The first duplicate reference should have the correct suffix.", + ) + self.assertEqual( + updated_vals[1]["reference"], + f"{self.plan_test_mixin.reference}_plan_line_2", + "The second duplicate reference should have the correct suffix.", + ) + + def test_populate_references_empty_vals_list(self): + """ + Check that an empty input list returns an empty result + when populating references. + """ + updated_vals = self.plan_line._pre_populate_references( + "cx.tower.plan", "plan_id", [] + ) + self.assertEqual( + updated_vals, + [], + "The result should be an empty list when vals_list is empty.", + ) + + def test_populate_references_reference_present(self): + """ + Check that reference is preserver when present in vals + """ + + vals_list = [ + {"reference": "my_custom_line_1"}, + {"reference": "my_custom_line_2"}, + ] + updated_vals = self.plan_line._pre_populate_references( + "cx.tower.plan", "plan_id", vals_list + ) + self.assertEqual( + updated_vals[0]["reference"], + "my_custom_line_1", + "Original reference must be preserved", + ) + self.assertEqual( + updated_vals[1]["reference"], + "my_custom_line_2", + "Original reference must be preserved", + ) + + def test_populate_references_mixed_scenarios(self): + """Test mixed scenarios with existing and missing references""" + vals_list = [ + {"reference": "my_custom_line_1"}, + {"plan_id": self.plan_test_mixin.id}, # No reference + {"reference": " "}, # Whitespace reference + {"reference": ""}, # Empty reference + {"reference": "\n_"}, # Some irrelevant symbols + ] + updated_vals = self.plan_line._pre_populate_references( + "cx.tower.plan", "plan_id", vals_list + ) + + self.assertEqual( + updated_vals[0]["reference"], + "my_custom_line_1", + "Original reference must be preserved", + ) + self.assertEqual( + updated_vals[1]["reference"], + f"{self.plan_test_mixin.reference}_plan_line_1", + "Missing reference should be generated", + ) + self.assertEqual( + updated_vals[2]["reference"], + "no_plan_line_1", + "Missing reference should be generated", + ) + self.assertEqual( + updated_vals[3]["reference"], + "no_plan_line_2", + "Missing reference should be generated", + ) + self.assertEqual( + updated_vals[4]["reference"], + "no_plan_line_3", + "Missing reference should be generated", + ) diff --git a/addons/cetmix_tower_server/tests/test_scheduled_task.py b/addons/cetmix_tower_server/tests/test_scheduled_task.py new file mode 100644 index 0000000..f93f5a7 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_scheduled_task.py @@ -0,0 +1,893 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import datetime + +from odoo import fields +from odoo.exceptions import AccessError, ValidationError + +from .common import TestTowerCommon + + +class TestTowerScheduledTask(TestTowerCommon): + """Test the cx.tower.scheduled.task model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create an additional server for multi-server command test + cls.server_test_2 = cls.Server.create( + { + "name": "Test 2", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "host_key": "test_key", + "os_id": cls.os_debian_10.id, + } + ) + + # Scheduled task: command (multi-server) + cls.command_scheduled_task = cls.ScheduledTask.create( + { + "name": "Test Command Scheduled Task", + "action": "command", + "command_id": cls.command_list_dir.id, + "interval_number": 1, + "interval_type": "days", + "next_call": fields.Datetime.now(), + "server_ids": [(6, 0, [cls.server_test_1.id, cls.server_test_2.id])], + } + ) + + # Scheduled task: plan (single server) + cls.plan_scheduled_task = cls.ScheduledTask.create( + { + "name": "Test Plan Scheduled Task", + "action": "plan", + "plan_id": cls.plan_1.id, + "interval_number": 1, + "interval_type": "days", + "next_call": fields.Datetime.now(), + "server_ids": [(6, 0, [cls.server_test_1.id])], + } + ) + + # Custom variable for task (option type) + cls.variable_odoo_versions = cls.Variable.create( + { + "name": "odoo_versions", + "variable_type": "o", + } + ) + cls.variable_option_16_0 = cls.VariableOption.create( + { + "name": "16.0", + "value_char": "16.0", + "variable_id": cls.variable_odoo_versions.id, + } + ) + + # Add custom variables to tasks + cls.scheduled_task_cv_os = cls.ScheduledTaskCv.create( + { + "scheduled_task_id": cls.command_scheduled_task.id, + "variable_id": cls.variable_os.id, + "value_char": "Windows 2k", + } + ) + cls.scheduled_task_cv_version = cls.ScheduledTaskCv.create( + { + "scheduled_task_id": cls.command_scheduled_task.id, + "variable_id": cls.variable_odoo_versions.id, + "option_id": cls.variable_option_16_0.id, + } + ) + cls.scheduled_task_cv_version_plan = cls.ScheduledTaskCv.create( + { + "scheduled_task_id": cls.plan_scheduled_task.id, + "variable_id": cls.variable_odoo_versions.id, + "option_id": cls.variable_option_16_0.id, + } + ) + + # Create additional Jet Template for access testing + cls.jet_template_test_access = cls.JetTemplate.create( + { + "name": "Test Jet Template for Access", + "server_ids": [(4, cls.server_test_1.id)], + } + ) + + # Create additional Jet for access testing + cls.jet_test_access = cls.Jet.create( + { + "name": "Test Jet for Access", + "jet_template_id": cls.jet_template_test_access.id, + "server_id": cls.server_test_1.id, + } + ) + + # Scheduled task with Jet and Jet Template for access testing + cls.jet_scheduled_task = cls.ScheduledTask.create( + { + "name": "Test Jet Scheduled Task", + "action": "command", + "command_id": cls.command_list_dir.id, + "interval_number": 1, + "interval_type": "days", + "next_call": fields.Datetime.now(), + "jet_ids": [(6, 0, [cls.jet_test_access.id])], + "jet_template_ids": [(6, 0, [cls.jet_template_test_access.id])], + } + ) + + def _assert_log_records(self, log_model, scheduled_task, expected_count): + """Helper: Assert that log records exist for the task""" + logs = log_model.search([("scheduled_task_id", "=", scheduled_task.id)]) + self.assertTrue(logs, f"{log_model._name} logs should be created after run.") + self.assertEqual( + len(logs), + expected_count, + f"Expected {expected_count} logs for {scheduled_task.display_name}, " + f"got {len(logs)}.", + ) + + def _assert_next_and_last_call_changed( + self, task, last_call_before, next_call_before + ): + """Helper: Assert next_call and last_call changed after run""" + task.invalidate_recordset() + self.assertNotEqual( + task.last_call, last_call_before, "last_call must be changed after run." + ) + self.assertNotEqual( + task.next_call, next_call_before, "next_call must be changed after run." + ) + + def _clear_all_access( + self, + scheduled_task, + jet=None, + jet_template=None, + server=None, + server_template=None, + ): + """Helper: Clear all access paths for a scheduled task and related objects.""" + scheduled_task.manager_ids = [(5, 0, 0)] + scheduled_task.user_ids = [(5, 0, 0)] + if jet: + jet.manager_ids = [(5, 0, 0)] + jet.user_ids = [(5, 0, 0)] + if jet_template: + jet_template.manager_ids = [(5, 0, 0)] + jet_template.user_ids = [(5, 0, 0)] + if server: + server.manager_ids = [(5, 0, 0)] + server.user_ids = [(5, 0, 0)] + if server_template: + server_template.manager_ids = [(5, 0, 0)] + server_template.user_ids = [(5, 0, 0)] + + def test_reserve_tasks_atomic(self): + """Scheduled Task: reserve_tasks must only lock available""" + tasks = self.command_scheduled_task + self.plan_scheduled_task + reserved = tasks._reserve_tasks() + self.assertEqual( + set(reserved.ids), set(tasks.ids), "Both tasks should be reserved" + ) + # Repeated reservation should return empty (already running) + tasks.invalidate_recordset() + reserved_again = tasks._reserve_tasks() + self.assertFalse( + reserved_again, "Already reserved tasks must not be reserved again" + ) + + def test_run_task_command(self): + """Running a scheduled command task creates logs per server.""" + logs_before = self.CommandLog.search( + [("scheduled_task_id", "=", self.command_scheduled_task.id)] + ) + self.assertFalse(logs_before, "No command logs should exist before run.") + + last_call_before = self.command_scheduled_task.last_call + next_call_before = self.command_scheduled_task.next_call + + self.command_scheduled_task._run() + self._assert_next_and_last_call_changed( + self.command_scheduled_task, last_call_before, next_call_before + ) + self._assert_log_records( + self.CommandLog, + self.command_scheduled_task, + expected_count=len(self.command_scheduled_task.server_ids), + ) + + def test_run_task_plan(self): + """Running a scheduled plan task creates one log per server.""" + logs_before = self.PlanLog.search( + [("scheduled_task_id", "=", self.plan_scheduled_task.id)] + ) + self.assertFalse(logs_before, "No plan logs should exist before run.") + + last_call_before = self.plan_scheduled_task.last_call + next_call_before = self.plan_scheduled_task.next_call + + self.plan_scheduled_task._run() + self._assert_next_and_last_call_changed( + self.plan_scheduled_task, last_call_before, next_call_before + ) + self._assert_log_records( + self.PlanLog, + self.plan_scheduled_task, + expected_count=len(self.plan_scheduled_task.server_ids), + ) + + def test_user_write_create_unlink_access(self): + """User: cannot create, write or unlink scheduled tasks.""" + with self.assertRaises(AccessError): + self.ScheduledTask.with_user(self.user).create( + { + "name": "Test", + "action": "command", + "command_id": self.command_list_dir.id, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + with self.assertRaises(AccessError): + self.command_scheduled_task.with_user(self.user).write({"sequence": 33}) + with self.assertRaises(AccessError): + self.command_scheduled_task.with_user(self.user).unlink() + + def test_manager_read_access(self): + """Manager: can read scheduled task if in manager_ids or in server's + manager_ids/user_ids.""" + self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.command_scheduled_task.id)] + ) + self.assertIn( + self.command_scheduled_task, + tasks, + "Manager should be able to read their task.", + ) + + # Remove from manager_ids, but add to server manager_ids + self.command_scheduled_task.manager_ids = [(5, 0, 0)] + self.server_test_1.manager_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.command_scheduled_task.id)] + ) + self.assertIn( + self.command_scheduled_task, + tasks, + "Manager should be able to read task via server manager_ids.", + ) + + # Test server user_ids access + self.server_test_1.manager_ids = [(5, 0, 0)] + self.server_test_1.user_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.command_scheduled_task.id)] + ) + self.assertIn( + self.command_scheduled_task, + tasks, + "Manager should be able to read task via server user_ids.", + ) + + # Remove manager from everywhere + self._clear_all_access(self.command_scheduled_task, server=self.server_test_1) + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.command_scheduled_task.id)] + ) + self.assertNotIn( + self.command_scheduled_task, + tasks, + "Manager should NOT be able to read task without relation.", + ) + + def test_manager_read_access_via_jet(self): + """Manager: can read scheduled task if in jet's user_ids/manager_ids.""" + # Test access via jet manager_ids + self.jet_test_access.manager_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.jet_scheduled_task.id)] + ) + self.assertIn( + self.jet_scheduled_task, + tasks, + "Manager should be able to read task via jet manager_ids.", + ) + + # Test access via jet user_ids + self.jet_test_access.manager_ids = [(5, 0, 0)] + self.jet_test_access.user_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.jet_scheduled_task.id)] + ) + self.assertIn( + self.jet_scheduled_task, + tasks, + "Manager should be able to read task via jet user_ids.", + ) + + # Test access via jet_template manager_ids + self.jet_test_access.user_ids = [(5, 0, 0)] + self.jet_template_test_access.manager_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.jet_scheduled_task.id)] + ) + self.assertIn( + self.jet_scheduled_task, + tasks, + "Manager should be able to read task via jet_template manager_ids.", + ) + + # Test access via jet_template user_ids + self.jet_template_test_access.manager_ids = [(5, 0, 0)] + self.jet_template_test_access.user_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.jet_scheduled_task.id)] + ) + self.assertIn( + self.jet_scheduled_task, + tasks, + "Manager should be able to read task via jet_template user_ids.", + ) + + # Remove manager from everywhere + self._clear_all_access( + self.jet_scheduled_task, + jet=self.jet_test_access, + jet_template=self.jet_template_test_access, + server=self.server_test_1, + ) + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", self.jet_scheduled_task.id)] + ) + self.assertNotIn( + self.jet_scheduled_task, + tasks, + "Manager should NOT be able to read task without relation.", + ) + + def test_manager_read_access_via_server_template(self): + """Manager: can read scheduled task if in server_template's + user_ids/manager_ids.""" + # Create scheduled task with server template + server_template_task = self.ScheduledTask.create( + { + "name": "Test Server Template Scheduled Task", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_number": 1, + "interval_type": "days", + "next_call": fields.Datetime.now(), + "server_template_ids": [(6, 0, [self.server_template_sample.id])], + } + ) + + # Test access via server_template manager_ids + self.server_template_sample.manager_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", server_template_task.id)] + ) + self.assertIn( + server_template_task, + tasks, + "Manager should be able to read task via server_template manager_ids.", + ) + + # Test access via server_template user_ids + self.server_template_sample.manager_ids = [(5, 0, 0)] + self.server_template_sample.user_ids = [(6, 0, [self.manager.id])] + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", server_template_task.id)] + ) + self.assertIn( + server_template_task, + tasks, + "Manager should be able to read task via server_template user_ids.", + ) + + # Remove manager from everywhere + self._clear_all_access( + server_template_task, + server_template=self.server_template_sample, + server=self.server_test_1, + ) + tasks = self.ScheduledTask.with_user(self.manager).search( + [("id", "=", server_template_task.id)] + ) + self.assertNotIn( + server_template_task, + tasks, + "Manager should NOT be able to read task without relation.", + ) + + def test_manager_write_create_access(self): + """Manager: can create/write if in manager_ids, else denied.""" + # Create as manager + task = self.ScheduledTask.with_user(self.manager).create( + { + "name": "Test", + "action": "command", + "command_id": self.command_list_dir.id, + "manager_ids": [(6, 0, [self.manager.id])], + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + try: + task.with_user(self.manager).write({"sequence": 77}) + except AccessError: + self.fail("Manager should be able to write their own scheduled tasks.") + + # Should fail if not in manager_ids + self.command_scheduled_task.manager_ids = [(5, 0, 0)] + with self.assertRaises(AccessError): + self.command_scheduled_task.with_user(self.manager).write({"sequence": 11}) + + def test_manager_unlink_access(self): + """Manager: can unlink only their own tasks (in manager_ids & creator).""" + # Create as manager + task = self.ScheduledTask.with_user(self.manager).create( + { + "name": "Test", + "action": "command", + "command_id": self.command_list_dir.id, + "manager_ids": [(6, 0, [self.manager.id])], + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + try: + task.with_user(self.manager).unlink() + except AccessError: + self.fail("Manager should be able to unlink their own task.") + + # Not creator + with self.assertRaises(AccessError): + self.command_scheduled_task.with_user(self.manager).unlink() + + def test_root_unrestricted_access(self): + """Root: full unrestricted access to all scheduled tasks.""" + # Read + tasks = self.ScheduledTask.with_user(self.root).search( + [("id", "=", self.command_scheduled_task.id)] + ) + self.assertIn( + self.command_scheduled_task, tasks, "Root should be able to read any task." + ) + + # Create + task = self.ScheduledTask.with_user(self.root).create( + { + "name": "Test", + "action": "command", + "command_id": self.command_list_dir.id, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + try: + task.with_user(self.root).write({"sequence": 123}) + task.with_user(self.root).unlink() + except AccessError: + self.fail("Root should be able to write/unlink any scheduled task.") + + def test_get_next_call_dow_wednesday(self): + """Test _get_next_call_dow when today is Wednesday. + Task runs Monday, Wednesday, Friday -> should return Friday.""" + # Create task with Monday, Wednesday, Friday selected + task = self.ScheduledTask.create( + { + "name": "Test DOW Task", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_type": "dow", + "monday": True, + "wednesday": True, + "friday": True, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + + # Create a Wednesday datetime (2024-01-03 is a Wednesday) + # Set time to 10:30:45 + wednesday_date = datetime(2024, 1, 3, 10, 30, 45) + + # Calculate next call + next_call = task._get_next_call_dow(task, wednesday_date) + + # Should be Friday (2 days ahead) at the same time + expected_friday = datetime(2024, 1, 5, 10, 30, 45) + self.assertEqual( + next_call, + expected_friday, + "Next call from Wednesday should be Friday at the same time.", + ) + + def test_get_next_call_dow_friday(self): + """Test _get_next_call_dow when today is Friday. + Task runs Monday, Wednesday, Friday -> should return Monday (next week).""" + # Create task with Monday, Wednesday, Friday selected + task = self.ScheduledTask.create( + { + "name": "Test DOW Task", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_type": "dow", + "monday": True, + "wednesday": True, + "friday": True, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + + # Create a Friday datetime (2024-01-05 is a Friday) + # Set time to 14:15:30 + friday_date = datetime(2024, 1, 5, 14, 15, 30) + + # Calculate next call + next_call = task._get_next_call_dow(task, friday_date) + + # Should be Monday next week (3 days ahead) at the same time + expected_monday = datetime(2024, 1, 8, 14, 15, 30) + self.assertEqual( + next_call, + expected_monday, + "Next call from Friday should be Monday next week at the same time.", + ) + + def test_check_days_of_week_constraint(self): + """ + Test _check_days_of_week constraint: + no days selected should raise ValidationError. + """ + # Try to create a task with interval_type="dow" but no days selected + with self.assertRaises(ValidationError) as context: + self.ScheduledTask.create( + { + "name": "Test DOW Task No Days", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_type": "dow", + "monday": False, + "tuesday": False, + "wednesday": False, + "thursday": False, + "friday": False, + "saturday": False, + "sunday": False, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + self.assertIn( + "At least one day of week must be selected", + str(context.exception), + "ValidationError should mention that at " "least one day must be selected.", + ) + + # Try to update an existing task to have no days selected + task = self.ScheduledTask.create( + { + "name": "Test DOW Task", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_type": "dow", + "monday": True, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + with self.assertRaises(ValidationError): + task.write( + { + "monday": False, + "tuesday": False, + "wednesday": False, + "thursday": False, + "friday": False, + "saturday": False, + "sunday": False, + } + ) + + def test_get_next_call_dow_single_day_monday(self): + """Test _get_next_call_dow edge case: only Monday selected, + current day is Monday. + Should wrap to next week's Monday.""" + # Create task with only Monday selected + task = self.ScheduledTask.create( + { + "name": "Test DOW Task Single Day", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_type": "dow", + "monday": True, + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + + # Create a Monday datetime (2024-01-01 is a Monday) + # Set time to 09:00:00 + monday_date = datetime(2024, 1, 1, 9, 0, 0) + + # Calculate next call + next_call = task._get_next_call_dow(task, monday_date) + + # Should be Monday next week (7 days ahead) at the same time + expected_next_monday = datetime(2024, 1, 8, 9, 0, 0) + self.assertEqual( + next_call, + expected_next_monday, + "Next call from Monday (only day selected) should be" + " next Monday at the same time.", + ) + + def test_scheduled_task_cv_manager_read_access(self): + """Manager: can read scheduled task CV if in scheduled task's + manager_ids/user_ids or via server's manager_ids/user_ids.""" + # Test access via scheduled task manager_ids + self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", self.scheduled_task_cv_os.id)] + ) + self.assertIn( + self.scheduled_task_cv_os, + cvs, + "Manager should be able to read CV via scheduled task manager_ids.", + ) + + # Test access via scheduled task user_ids + self.command_scheduled_task.manager_ids = [(5, 0, 0)] + self.command_scheduled_task.user_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", self.scheduled_task_cv_os.id)] + ) + self.assertIn( + self.scheduled_task_cv_os, + cvs, + "Manager should be able to read CV via scheduled task user_ids.", + ) + + # Test access via server manager_ids + self.command_scheduled_task.user_ids = [(5, 0, 0)] + self.server_test_1.manager_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", self.scheduled_task_cv_os.id)] + ) + self.assertIn( + self.scheduled_task_cv_os, + cvs, + "Manager should be able to read CV via server manager_ids.", + ) + + # Test access via server user_ids + self.server_test_1.manager_ids = [(5, 0, 0)] + self.server_test_1.user_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", self.scheduled_task_cv_os.id)] + ) + self.assertIn( + self.scheduled_task_cv_os, + cvs, + "Manager should be able to read CV via server user_ids.", + ) + + # Remove manager from everywhere + self.server_test_1.user_ids = [(5, 0, 0)] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", self.scheduled_task_cv_os.id)] + ) + self.assertNotIn( + self.scheduled_task_cv_os, + cvs, + "Manager should NOT be able to read CV without relation.", + ) + + def test_scheduled_task_cv_manager_read_access_via_jet(self): + """Manager: can read scheduled task CV if in jet's user_ids/manager_ids.""" + # Create CV for jet scheduled task + jet_cv = self.ScheduledTaskCv.create( + { + "scheduled_task_id": self.jet_scheduled_task.id, + "variable_id": self.variable_os.id, + "value_char": "Linux", + } + ) + + # Test access via jet manager_ids + self.jet_test_access.manager_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", jet_cv.id)] + ) + self.assertIn( + jet_cv, + cvs, + "Manager should be able to read CV via jet manager_ids.", + ) + + # Test access via jet user_ids + self.jet_test_access.manager_ids = [(5, 0, 0)] + self.jet_test_access.user_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", jet_cv.id)] + ) + self.assertIn( + jet_cv, + cvs, + "Manager should be able to read CV via jet user_ids.", + ) + + # Test access via jet_template manager_ids + self.jet_test_access.user_ids = [(5, 0, 0)] + self.jet_template_test_access.manager_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", jet_cv.id)] + ) + self.assertIn( + jet_cv, + cvs, + "Manager should be able to read CV via jet_template manager_ids.", + ) + + # Test access via jet_template user_ids + self.jet_template_test_access.manager_ids = [(5, 0, 0)] + self.jet_template_test_access.user_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", jet_cv.id)] + ) + self.assertIn( + jet_cv, + cvs, + "Manager should be able to read CV via jet_template user_ids.", + ) + + # Remove manager from everywhere + self._clear_all_access( + self.jet_scheduled_task, + jet=self.jet_test_access, + jet_template=self.jet_template_test_access, + server=self.server_test_1, + ) + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", jet_cv.id)] + ) + self.assertNotIn( + jet_cv, + cvs, + "Manager should NOT be able to read CV without relation.", + ) + + def test_scheduled_task_cv_manager_read_access_via_server_template(self): + """Manager: can read scheduled task CV if in server_template's + user_ids/manager_ids.""" + # Create scheduled task with server template + server_template_task = self.ScheduledTask.create( + { + "name": "Test Server Template Scheduled Task for CV", + "action": "command", + "command_id": self.command_list_dir.id, + "interval_number": 1, + "interval_type": "days", + "next_call": fields.Datetime.now(), + "server_template_ids": [(6, 0, [self.server_template_sample.id])], + } + ) + server_template_cv = self.ScheduledTaskCv.create( + { + "scheduled_task_id": server_template_task.id, + "variable_id": self.variable_os.id, + "value_char": "Debian", + } + ) + + # Test access via server_template manager_ids + self.server_template_sample.manager_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", server_template_cv.id)] + ) + self.assertIn( + server_template_cv, + cvs, + "Manager should be able to read CV via server_template manager_ids.", + ) + + # Test access via server_template user_ids + self.server_template_sample.manager_ids = [(5, 0, 0)] + self.server_template_sample.user_ids = [(6, 0, [self.manager.id])] + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", server_template_cv.id)] + ) + self.assertIn( + server_template_cv, + cvs, + "Manager should be able to read CV via server_template user_ids.", + ) + + # Remove manager from everywhere + self._clear_all_access( + server_template_task, + server_template=self.server_template_sample, + server=self.server_test_1, + ) + cvs = self.ScheduledTaskCv.with_user(self.manager).search( + [("id", "=", server_template_cv.id)] + ) + self.assertNotIn( + server_template_cv, + cvs, + "Manager should NOT be able to read CV without relation.", + ) + + def test_scheduled_task_cv_manager_write_create_access(self): + """Manager: can create/write CV if in scheduled task's manager_ids.""" + # Create CV as manager + self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] + cv = self.ScheduledTaskCv.with_user(self.manager).create( + { + "scheduled_task_id": self.command_scheduled_task.id, + "variable_id": self.variable_os.id, + "value_char": "Ubuntu", + } + ) + try: + cv.with_user(self.manager).write({"value_char": "Fedora"}) + except AccessError: + self.fail( + "Manager should be able to write CV if in scheduled task manager_ids." + ) + + # Should fail if not in manager_ids + self.command_scheduled_task.manager_ids = [(5, 0, 0)] + with self.assertRaises(AccessError): + self.scheduled_task_cv_os.with_user(self.manager).write( + {"value_char": "CentOS"} + ) + + def test_scheduled_task_cv_manager_unlink_access(self): + """Manager: can unlink CV only if in scheduled task's manager_ids & creator.""" + # Create CV as manager + self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] + cv = self.ScheduledTaskCv.with_user(self.manager).create( + { + "scheduled_task_id": self.command_scheduled_task.id, + "variable_id": self.variable_os.id, + "value_char": "Arch", + } + ) + try: + cv.with_user(self.manager).unlink() + except AccessError: + self.fail("Manager should be able to unlink CV they created.") + + # Not creator + self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] + with self.assertRaises(AccessError): + self.scheduled_task_cv_os.with_user(self.manager).unlink() + + def test_scheduled_task_cv_root_unrestricted_access(self): + """Root: full unrestricted access to all scheduled task CVs.""" + # Read + cvs = self.ScheduledTaskCv.with_user(self.root).search( + [("id", "=", self.scheduled_task_cv_os.id)] + ) + self.assertIn( + self.scheduled_task_cv_os, + cvs, + "Root should be able to read any CV.", + ) + + # Create + cv = self.ScheduledTaskCv.with_user(self.root).create( + { + "scheduled_task_id": self.command_scheduled_task.id, + "variable_id": self.variable_os.id, + "value_char": "SUSE", + } + ) + try: + cv.with_user(self.root).write({"value_char": "OpenSUSE"}) + cv.with_user(self.root).unlink() + except AccessError: + self.fail("Root should be able to write/unlink any scheduled task CV.") diff --git a/addons/cetmix_tower_server/tests/test_server.py b/addons/cetmix_tower_server/tests/test_server.py new file mode 100644 index 0000000..d279423 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_server.py @@ -0,0 +1,890 @@ +from odoo.exceptions import AccessError, ValidationError + +from ..models.constants import COMMAND_NOT_COMPATIBLE_WITH_SERVER +from .common import TestTowerCommon + + +class TestTowerServer(TestTowerCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.os_ubuntu_20_04 = cls.env["cx.tower.os"].create({"name": "Ubuntu 20.04"}) + + # Define model variables to avoid unsubscriptable errors + Key = cls.env["cx.tower.key"] + Server = cls.env["cx.tower.server"] + + secret_1 = Key.create( + { + "name": "Secret 1", + "secret_value": "secret_value_1", + "key_type": "s", + }, + ) + secret_2 = Key.create( + { + "name": "Secret 2", + "secret_value": "secret_value_2", + "key_type": "s", + }, + ) + cls.server_test_2 = Server.create( + { + "name": "Test Server #2", + "color": 2, + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "k", + "host_key": "test_key", + "use_sudo": "p", + "ssh_key_id": cls.key_1.id, + "os_id": cls.os_ubuntu_20_04.id, + "secret_ids": [ + ( + 0, + 0, + { + "key_id": secret_1.id, + "secret_value": "secret_value_1", + }, + ), + ( + 0, + 0, + { + "key_id": secret_2.id, + "secret_value": "secret_value_2", + }, + ), + ], + "tag_ids": [(6, 0, [cls.tag_test_production.id])], + } + ) + + # Files + File = cls.env["cx.tower.file"] + cls.server_test_2_file = File.create( + { + "name": "tower_demo_without_template_{{ branch }}.txt", + "source": "tower", + "server_id": cls.server_test_2.id, + "server_dir": "{{ test_path }}", + "code": "Please, check url: {{ url }}", + } + ) + + # Flight plan to delete the server + Command = cls.env["cx.tower.command"] + Plan = cls.env["cx.tower.plan"] + + # Add a command to delete the server + cls.command_delete_server = Command.create( + { + "name": "Python command for deleting server", + "action": "python_code", + "code": """ +partner = env["res.partner"].create({"name": "Partner 1", "ref": "delete_server"}) +result = { + "exit_code": 0, + "message": partner.name, +} + """, + } + ) + + cls.plan_delete_server = Plan.create( + { + "name": "Delete server", + "line_ids": [ + (0, 0, {"command_id": cls.command_delete_server.id, "sequence": 1}), + ], + } + ) + + # Create two test users that belong only to the "User" group. + cls.user1 = cls.Users.create( + { + "name": "Test User 1", + "login": "test_user1", + "email": "test_user1@example.com", + "groups_id": [(6, 0, [cls.group_user.id])], + } + ) + cls.user2 = cls.Users.create( + { + "name": "Test User 2", + "login": "test_user2", + "email": "test_user2@example.com", + "groups_id": [(6, 0, [cls.group_user.id])], + } + ) + # Create two "Manager" group users. + cls.manager1 = cls.Users.create( + { + "name": "Manager 1", + "login": "manager1", + "email": "manager1@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + cls.manager2 = cls.Users.create( + { + "name": "Manager 2", + "login": "manager2", + "email": "manager2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + def test_server_copy(self): + """Test server copy""" + + # Let's say we have auto sync enabled on one of the files in server 2 + self.server_test_2_file.auto_sync = True + fields_to_check = [ + "ip_v4_address", + "ip_v6_address", + "ssh_username", + "ssh_password", + "ssh_key_id", + ] + + # Crete a log from file of type 'server' + file_for_log = self.File.create( + { + "source": "server", + "name": "test.log", + "server_dir": "/tmp", + "server_id": self.server_test_2.id, + "code": "Some log record - server", + } + ) + + server_log_server = self.ServerLog.create( + { + "name": "Log from file", + "server_id": self.server_test_2.id, + "log_type": "file", + "file_id": file_for_log.id, + } + ) + # Add variable values to server + self.env["cx.tower.variable.value"].create( + { + "server_id": self.server_test_2.id, + "variable_id": self.variable_dir.id, + "value_char": "test", + } + ) + + # Copy server 2 + server_test_2_copy = self.server_test_2.copy() + + # The name of copy should contain '~ (copy)' suffix + self.assertTrue( + server_test_2_copy.name == self.server_test_2.name + " (copy)", + msg="Server name should contain '~ (copy)' suffix!", + ) + + # Check server logs + # Check that the copied server has the same number of server logs + self.assertEqual( + len(server_test_2_copy.server_log_ids), + len(self.server_test_2.server_log_ids), + ( + "Copied template should have the same " + "number of server logs as the original" + ), + ) + + # Ensure the first server log in the copied server matches the original + copied_log = server_test_2_copy.server_log_ids + self.assertEqual( + copied_log.name, + server_log_server.name, + "Server log name should be the same in the copied server", + ) + self.assertEqual( + copied_log.command_id.id, + server_log_server.command_id.id, + "Command ID should be the same in the copied server log", + ) + self.assertEqual( + copied_log.command_id.code, + server_log_server.command_id.code, + "Command code should be the same in the copied server log", + ) + + # Check fields match list + for field_ in fields_to_check: + self.assertTrue( + getattr(server_test_2_copy, field_) + == getattr(self.server_test_2, field_), + msg=( + f"Field {field_} value on server copy " + "does not match with the source!" + ), + ) + + # Check if auto sync is disabled on the all the files + # in the copied server + self.assertTrue( + all([not file.auto_sync for file in server_test_2_copy.file_ids]), + msg="Auto sync should be disabled on all the files in the copied server!", + ) + + # Check if 'keep_when_deleted' option is enabled on all the files + # in the copied server + self.assertTrue( + all([file.keep_when_deleted for file in server_test_2_copy.file_ids]), + msg=( + "keep_when_deleted option should be enabled on all the files " + "in the copied server!" + ), + ) + + # Check if secret values of keys in the copied server are the same + # as in source server + self.assertTrue( + all( + [ + key_copy.secret_value == key_src.secret_value + for key_src, key_copy in zip( # noqa: B905 we need to run on Python 3.10 + self.server_test_2.secret_ids.sudo(), + server_test_2_copy.secret_ids.sudo(), + ) + ] + ), + msg=( + "Secret values of keys in the copied server " + "should be the same as in source server!" + ), + ) + + # Variable names and values in server copy should be the same + # as in source server + self.assertTrue( + all( + [ + var_copy.variable_reference == var_src.variable_reference + and var_copy.value_char == var_src.value_char + for var_src, var_copy in zip( # noqa: B905 we need to run on Python 3.10 + self.server_test_2.variable_value_ids, + server_test_2_copy.variable_value_ids, + ) + ] + ), + msg=( + "Variable names and values in server copy " + "should be the same as in source server!" + ), + ) + + # Copy copied server + server_test_2_new_copy = server_test_2_copy.copy() + # Variable names and values in server copy should be the same + # as in source server + self.assertTrue( + all( + [ + var_copy.variable_reference == var_src.variable_reference + and var_copy.value_char == var_src.value_char + and var_copy.reference == f"{var_src.reference}_copy" + for var_src, var_copy in zip( # noqa: B905 we need to run on Python 3.10 + server_test_2_copy.variable_value_ids, + server_test_2_new_copy.variable_value_ids, + ) + ] + ), + msg=( + "Variable names and values in server copy " + "should be the same as in source server!" + ), + ) + + def test_server_archive_unarchive(self): + """Test Server archived/unarchived""" + server = self.server_test_1.copy() + self.assertTrue(server, msg="Server must be unarchived") + server.toggle_active() + server.toggle_active() + self.assertTrue(server, msg="Server must be unarchived") + + def test_server_unlink(self): + """ + Test cascading deletion of server and its related records. + """ + secret_1 = self.Key.create( + { + "name": "Secret 1", + "secret_value": "secret_value_1", + "key_type": "s", + }, + ) + # Create a test server + server = self.Server.create( + { + "name": "Test Server #3", + "color": 3, + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "k", + "use_sudo": "p", + "ssh_key_id": self.key_1.id, + "host_key": "test_key", + "os_id": self.os_ubuntu_20_04.id, + "secret_ids": [ + ( + 0, + 0, + { + "key_id": secret_1.id, + "secret_value": "secret_value_1", + }, + ), + ], + } + ) + + # Create related file + file = self.File.create( + {"name": "Test File", "server_id": server.id, "source": "server"} + ) + + # Related secret + secret = server.secret_ids[0] + + variable_meme = self.Variable.create({"name": "meme"}) + + # Create related variable value + variable_value = self.env["cx.tower.variable.value"].create( + { + "variable_id": variable_meme.id, # Replace with valid reference + "value_char": "Test Value", + "server_id": server.id, + } + ) + plan_1 = self.Plan.create( + { + "name": "Test plan", + "note": "Create directory and list its content", + } + ) + # Create a related plan log + plan_log = self.PlanLog.create( + { + "server_id": server.id, + "plan_id": plan_1.id, # Replace with valid reference + } + ) + + # Check that all records are created + self.assertTrue(server, "Server should be created successfully") + self.assertTrue(file, "File should be created successfully") + self.assertTrue(secret, "Secret should be created successfully") + self.assertTrue(variable_value, "Variable Value should be created successfully") + self.assertTrue(plan_log, "Plan Log should be created successfully") + + # Collect IDs for verification post-deletion + file_id = file.id + variable_value_id = variable_value.id + plan_log_id = plan_log.id + + # Delete the server + server.unlink() + + # Verify that the server is deleted + self.assertFalse( + self.Server.search([("id", "=", server.id)]), + msg="Server should be deleted", + ) + # Verify that related records are deleted + self.assertFalse( + self.File.search([("id", "=", file_id)]), + msg="File should be deleted when server is deleted", + ) + # Verify that unrelated records are not affected + self.assertTrue( + self.Plan.search([("id", "=", plan_1.id)]), + msg="Unrelated plan should not be deleted when server is deleted", + ) + self.assertFalse( + self.KeyValue.search([("id", "=", secret.id)]), + msg="Secret should be deleted when server is deleted", + ) + self.assertFalse( + self.VariableValue.search([("id", "=", variable_value_id)]), + msg="Variable Value should be deleted when server is deleted", + ) + self.assertFalse( + self.PlanLog.search([("id", "=", plan_log_id)]), + msg="Plan Log should be deleted when server is deleted", + ) + + def test_server_delete_plan_success(self): + """Test server delete plan""" + + # Set plan to delete the server + self.server_test_2.plan_delete_id = self.plan_delete_server.id + + # Delete the server + self.server_test_2.unlink() + + # Check if the server has been deleted + self.assertFalse( + self.server_test_2.exists(), + msg="Server should be deleted", + ) + + # Check if the partner has been created + self.assertTrue( + self.env["res.partner"].search([("ref", "=", "delete_server")]), + msg="Partner should be created", + ) + + def test_server_delete_plan_error(self): + """Test server delete plan error""" + + # Modify the command to fail + self.command_delete_server.code = """ +result = { + "exit_code": 4, + "message": 'Such much error', +} + """ + # Set plan to delete the server + self.server_test_2.plan_delete_id = self.plan_delete_server.id + + # Delete the server + self.server_test_2.unlink() + + # Check if the server has been deleted + self.assertTrue( + self.server_test_2.exists(), + msg="Server should not be deleted", + ) + + self.assertEqual( + self.server_test_2.status, + "delete_error", + msg="Server status should be delete_error", + ) + + # ------------------------------------------------------------ + # ---- Access + # ------------------------------------------------------------ + def test_user_record_not_visible_without_user_ids(self): + """ + Test that a user in the 'cetmix_tower_server.group_user' group cannot see + a Tower Server record if not added to user_ids. + """ + # Create a Tower Server record without any user_ids. + record = self.Server.create( + { + "name": "User Visibility Test", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "user_ids": [(5, 0, 0)], + } + ) + # As user1, search for the record. Since user1's partner is not subscribed, + # the record should not be returned. + records = self.Server.with_user(self.user1).search([("id", "=", record.id)]) + self.assertFalse( + records, + "User1 should not see the record if not added to user_ids.", + ) + + def test_user_record_visible_after_added_to_user_ids(self): + """ + Test that a user sees a Tower Server record after being added to user_ids. + """ + record = self.Server.create( + { + "name": "User Visibility Test", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "user_ids": [(4, self.user1.id)], + } + ) + # Now, as user1 the record should be visible. + records = self.Server.with_user(self.user1).search([("id", "=", record.id)]) + self.assertTrue( + records, + "User1 should see the record after being added to message_partner_ids.", + ) + + def test_only_added_user_can_see(self): + """ + Test that only the added user can see the Tower Server record. + """ + record = self.Server.create( + { + "name": "User Visibility Test", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "user_ids": [(4, self.user1.id)], + } + ) + # Subscribe only user1's partner. + records_user1 = self.Server.with_user(self.user1).search( + [("id", "=", record.id)] + ) + records_user2 = self.Server.with_user(self.user2).search( + [("id", "=", record.id)] + ) + self.assertTrue( + records_user1, "User1 should see the record after being added to user_ids." + ) + self.assertFalse( + records_user2, + "User2 should not see the record if they are not added to user_ids.", + ) + + def test_manager_read_access_as_follower(self): + """A manager should be able to read a record if his partner is a follower.""" + + # Create a record without any managers in manager_ids. + record = self.Server.create( + { + "name": "Test Server (Follower)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + # Explicitly clear manager_ids + "manager_ids": [(6, 0, [])], + } + ) + # Subscribe manager1 to the record so that his partner becomes a follower. + record.write({"user_ids": [(4, self.manager1.id)]}) + + # As manager1 (a follower) the record should be visible. + records = self.Server.with_user(self.manager1).search([("id", "=", record.id)]) + self.assertTrue(records, "Manager1 (user) must be able to read the record.") + + # As manager2 (not a follower and not in manager_ids) + # the record should not be visible. + records = self.Server.with_user(self.manager2).search([("id", "=", record.id)]) + self.assertFalse( + records, + "Manager2 (not user_ids and not in manager_ids) must not see the record.", + ) + + def test_manager_read_access_as_manager_ids(self): + """A manager should be able to read a record if he is added to manager_ids.""" + + # Create a record with manager2 added to manager_ids. + record = self.Server.create( + { + "name": "Test Server (Manager)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [self.manager2.id])], + } + ) + # Without adding to user_ids, manager2 should be able to see the record. + records = self.Server.with_user(self.manager2).search([("id", "=", record.id)]) + self.assertTrue( + records, "Manager2 (in manager_ids) must be able to read the record." + ) + + # Manager1 is not added to user_ids nor in manager_ids + # so should not see the record. + records = self.Server.with_user(self.manager1).search([("id", "=", record.id)]) + self.assertFalse( + records, + "Manager1 (neither user_ids nor in manager_ids) must not see the record.", + ) + + # Add manager1 to user_ids + record.write({"user_ids": [(4, self.manager1.id)]}) + records = self.Server.with_user(self.manager1).search([("id", "=", record.id)]) + self.assertTrue( + records, + "Manager1 (added to user_ids) must be able to see the record.", + ) + + def test_manager_write_access(self): + """A manager should be able to update a record only if he is in manager_ids.""" + + # Create a record with no managers. + record = self.Server.create( + { + "name": "Test Server (Write)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [])], + } + ) + + # Manager1 (not in manager_ids) tries to update: should raise an AccessError. + with self.assertRaises(AccessError): + record.with_user(self.manager1).write({"name": "Updated Name"}) + + # Update the record to include manager1 in manager_ids. + record.write({"manager_ids": [(4, self.manager1.id)]}) + try: + record.with_user(self.manager1).write({"name": "Updated Name"}) + except AccessError: + self.fail( + "Manager1 must be able to update the " + "record after being added to manager_ids." + ) + + def test_manager_create_access(self): + """ + A manager should be allowed to create a record only if he is added + in the "Managers". + """ + # Manager1 attempts to create a record without including himself in manager_ids. + with self.assertRaises(AccessError): + self.Server.with_user(self.manager1).create( + { + "name": "Test Server (Create Denied)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [])], + } + ) + + # Manager1 creates a record with himself added to manager_ids. + try: + record = self.Server.with_user(self.manager1).create( + { + "name": "Test Server (Create Allowed)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [self.manager1.id])], + } + ) + self.assertTrue( + record, + "Manager1 must be able to create the record if he is in manager_ids.", + ) + except AccessError: + self.fail( + "Manager1 should be allowed to create a " + "record when included in manager_ids." + ) + + def test_manager_delete_access(self): + """ + A manager should be allowed to delete a record only if: + - He is in the manager_ids field, and + - He is the creator of the record. + """ + + # -- Scenario 1: Manager1 creates a record with himself in manager_ids. + record = self.Server.with_user(self.manager1).create( + { + "name": "Test Server (Delete Allowed)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [self.manager1.id])], + } + ) + # Manager1 should be able to delete his own record. + try: + record.with_user(self.manager1).unlink() + except AccessError: + self.fail( + "Manager1 must be able to delete his own record if in manager_ids." + ) + + # -- Scenario 2: Manager2 creates a record (with himself in manager_ids). + record2 = self.Server.with_user(self.manager2).create( + { + "name": "Test Server (Delete Denied - Not Creator)", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [self.manager2.id, self.manager1.id])], + } + ) + # Manager1, should not be able to delete record2. + with self.assertRaises(AccessError): + record2.with_user(self.manager1).unlink() + + # Remove manager2 from manager_ids. + record2.write({"manager_ids": [(6, 0, [])]}) + + # Manager2 should not be able to delete record2 now + # because he is not in manager_ids. + with self.assertRaises(AccessError): + record2.with_user(self.manager2).unlink() + + def test_command_server_compatibility(self): + """Test command compatibility with servers""" + # Create a command restricted to specific servers + command = self.Command.create( + { + "name": "Restricted Command", + "action": "ssh_command", + "code": "echo 'test'", + "server_ids": [(6, 0, [self.server_test_1.id])], + } + ) + + # Should work on allowed server + try: + self.server_test_1.run_command(command) + except Exception as e: + self.fail(f"Command should execute on allowed server but failed: {e}") + + # Should fail on non-allowed server + command_result = self.server_test_2.with_context( + no_command_log=True + ).run_command(command) + self.assertEqual( + command_result["status"], + COMMAND_NOT_COMPATIBLE_WITH_SERVER, + "Command should not execute on non-allowed server", + ) + + # Clear all existing command logs + self.CommandLog.search([]).unlink() + # Same test but with command log + self.server_test_2.run_command(command) + + command_log = self.CommandLog.search([]) + self.assertEqual(len(command_log), 1, "Must be a single log record") + self.assertEqual( + command_log.command_status, + COMMAND_NOT_COMPATIBLE_WITH_SERVER, + "Command should not execute on non-allowed server", + ) + + # Command without server restrictions should work on any server + unrestricted_command = self.Command.create( + { + "name": "Unrestricted Command", + "action": "ssh_command", + "code": "echo 'test'", + } + ) + + try: + self.server_test_1.run_command(unrestricted_command) + self.server_test_2.run_command(unrestricted_command) + except Exception as e: + self.fail( + f"Unrestricted command should execute on any server but failed: {e}" + ) + + def test_server_host_key_validation(self): + """Test server host key validation""" + server = self.Server.create( + { + "name": "Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "host_key": "test_key", + "skip_host_key": False, + } + ) + # Test with host key + server.test_ssh_connection() + + # Test without host key + server.host_key = None + with self.assertRaises(ValidationError): + server.test_ssh_connection() + + # Test with skip_host_key + server.skip_host_key = True + server.test_ssh_connection() + + def test_server_reference_update(self): + """Test server reference update cascades to dependent models""" + # 1. Add a variable value to server_test_1 + variable_value = self.VariableValue.create( + { + "variable_id": self.variable_os.id, + "value_char": "Ubuntu 20.04", + "server_id": self.server_test_1.id, + } + ) + + # 2. Add a file to server_test_1 + server_file = self.File.create( + { + "name": "test_file.txt", + "server_id": self.server_test_1.id, + "source": "tower", + "code": "Test file content", + } + ) + + # Store original references for comparison + original_server_reference = self.server_test_1.reference + original_variable_value_reference = variable_value.reference + original_file_reference = server_file.reference + + # 3. Change the reference for server_test_1 to "awesome_server" + self.server_test_1.write({"reference": "awesome_server"}) + + # 4. Verify that references are updated for dependent models + # Invalidate models to refresh all references + self.env["cx.tower.server"].invalidate_model(["reference"]) + self.env["cx.tower.variable.value"].invalidate_model(["reference"]) + self.env["cx.tower.file"].invalidate_model(["reference"]) + + # Check that server reference was updated + self.assertEqual(self.server_test_1.reference, "awesome_server") + self.assertNotEqual(self.server_test_1.reference, original_server_reference) + + # Check that variable value reference was updated + # to include the new server reference + self.assertIn("awesome_server", variable_value.reference) + self.assertNotEqual(variable_value.reference, original_variable_value_reference) + + # Check that file reference was updated to include the new server reference + self.assertIn("awesome_server", server_file.reference) + self.assertNotEqual(server_file.reference, original_file_reference) + + # Verify the reference pattern for variable value follows the expected format: + # ___ # noqa: E501 + expected_variable_pattern = ( + f"{self.variable_os.reference}_variable_value_server_" + f"{self.server_test_1.reference}" + ) + self.assertEqual(variable_value.reference, expected_variable_pattern) + + # Verify the reference pattern for file follows the expected format: + # __ + expected_file_pattern = f"{self.server_test_1.reference}_file_1" + self.assertEqual(server_file.reference, expected_file_pattern) diff --git a/addons/cetmix_tower_server/tests/test_server_jet_action_command.py b/addons/cetmix_tower_server/tests/test_server_jet_action_command.py new file mode 100644 index 0000000..2f20337 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_server_jet_action_command.py @@ -0,0 +1,231 @@ +# 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 ValidationError + +from odoo.addons.cetmix_tower_server.models.constants import ( + GENERAL_ERROR, + JET_NOT_FOUND, + JET_TEMPLATE_NOT_FOUND, +) + +from .common_jets import TestTowerJetsCommon + + +class TestTowerServerJetActionCommand(TestTowerJetsCommon): # pylint: disable=protected-access + """Tests for cx.tower.server._command_runner_jet_action.""" + + def _create_jet_action_command(self, jet_template, jet_action): + """Create a command that triggers a jet action for the given template.""" + return self.Command.create( + { + "name": "Test jet action command", + "action": "jet_action", + "jet_template_id": jet_template.id, + "jet_action_id": jet_action.id, + } + ) + + def _create_jet_action_log(self, jet, command): + """Create a command log bound to a jet and command.""" + return self.CommandLog.create( + { + "server_id": jet.server_id.id, + "command_id": command.id, + "jet_id": jet.id, + } + ) + + def test_command_runner_jet_action_requires_log_record(self): + """Calling without a log record must raise ValidationError.""" + with self.assertRaises(ValidationError): + self.server_test_1._command_runner_jet_action(False) + + def test_command_runner_jet_action_missing_jet_action(self): + """Missing command jet_action_id finishes with GENERAL_ERROR.""" + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + command.write({"jet_action_id": False}) + log = self._create_jet_action_log(self.jet_test, command) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], GENERAL_ERROR) + self.assertEqual(result["response"], None) + self.assertEqual(result["error"], _("Jet action is not found.")) + log.invalidate_recordset() + self.assertEqual(log.command_status, GENERAL_ERROR) + + def test_command_runner_jet_action_missing_jet(self): + """Missing jet on the log finishes with JET_NOT_FOUND.""" + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + log = self.CommandLog.create( + { + "server_id": self.server_test_1.id, + "command_id": command.id, + "jet_id": False, + } + ) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], JET_NOT_FOUND) + self.assertIsNotNone(result["error"]) + + def test_command_runner_jet_action_missing_jet_template(self): + """ + Missing jet_template_id on the command finishes with + JET_TEMPLATE_NOT_FOUND. + """ + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + command.write({"jet_template_id": False}) + log = self._create_jet_action_log(self.jet_test, command) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], JET_TEMPLATE_NOT_FOUND) + self.assertIsNotNone(result["error"]) + + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", + autospec=True, + ) + def test_command_runner_jet_action_success_aggregates_response(self, mock_trigger): + mock_trigger.return_value = {"status": 0, "error": None} + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + log = self._create_jet_action_log(self.jet_test, command) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], 0) + self.assertIsNone(result["error"]) + self.assertTrue(result["response"]) + self.assertIn("Action triggered for", result["response"]) + self.assertIn(self.jet_test.reference, result["response"]) + mock_trigger.assert_called_once() + log.invalidate_recordset() + self.assertEqual(log.command_status, 0) + self.assertIn("Action triggered for", log.command_response) + self.assertFalse(log.command_error) + + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", + autospec=True, + ) + def test_command_runner_jet_action_failure_single_jet_error_message( + self, mock_trigger + ): + mock_trigger.return_value = {"status": 1, "error": "No action found"} + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + log = self._create_jet_action_log(self.jet_test, command) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], GENERAL_ERROR) + self.assertIsNone(result["response"]) + self.assertTrue(result["error"]) + lines = result["error"].split("\n") + self.assertEqual(len(lines), 2) + self.assertIn("Action triggered for", lines[0]) + self.assertIn(self.jet_test.reference, lines[1]) + self.assertIn("No action found", lines[1]) + + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", + autospec=True, + ) + def test_command_runner_jet_action_failure_status_without_error_text( + self, mock_trigger + ): + mock_trigger.return_value = {"status": 99, "error": None} + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + log = self._create_jet_action_log(self.jet_test, command) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], GENERAL_ERROR) + self.assertIn(self.jet_test.reference, result["error"]) + self.assertIn("99", result["error"]) + + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", + autospec=True, + ) + def test_command_runner_jet_action_failure_multiple_jets(self, mock_trigger): + jet_b = self._create_jet( + name="Second Jet", + reference="jet_second", + template=self.jet_template_test, + server=self.server_test_1, + ) + + def side_effect(jet_self, *_args, **_kwargs): + jet_self.ensure_one() + if jet_self.id == self.jet_test.id: + return {"status": 1, "error": "No action found"} + return {"status": 2, "error": "Jet is busy"} + + mock_trigger.side_effect = side_effect + + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + log = self._create_jet_action_log(self.jet_woocommerce, command) + + with patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._get_dependent_jets_by_template", + autospec=True, + return_value=self.jet_test | jet_b, + ): + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], GENERAL_ERROR) + lines = result["error"].split("\n") + self.assertEqual(len(lines), 2) + self.assertIn("Action triggered for", lines[0]) + self.assertIn(self.jet_test.reference, lines[0]) + self.assertIn(jet_b.reference, lines[0]) + agg = lines[1] + self.assertIn(f"{self.jet_test.reference}: No action found", agg) + self.assertIn(f"{jet_b.reference}: Jet is busy", agg) + + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._get_dependent_jets_by_template", + autospec=True, + ) + def test_command_runner_jet_action_no_dependent_jets(self, mock_deps): + mock_deps.return_value = self.Jet.browse() + command = self._create_jet_action_command( + self.jet_template_test, + self.action_stopped_to_running, + ) + log = self._create_jet_action_log(self.jet_woocommerce, command) + + result = self.server_test_1._command_runner_jet_action(log) + + self.assertEqual(result["status"], 0) + self.assertIsNone(result["error"]) + self.assertTrue(result["response"]) + self.assertIn(self.jet_woocommerce.name, result["response"]) + self.assertIn(self.jet_template_test.name, result["response"]) diff --git a/addons/cetmix_tower_server/tests/test_server_log.py b/addons/cetmix_tower_server/tests/test_server_log.py new file mode 100644 index 0000000..86cfa2b --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_server_log.py @@ -0,0 +1,657 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError + +from .common_jets import TestTowerJetsCommon + + +class TestTowerServerLog(TestTowerJetsCommon): + """Test the cx.tower.server.log model access rights.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test server logs + cls.server_log_1 = cls.ServerLog.create( + { + "name": "Test Log 1", + "server_id": cls.server_test_1.id, + "log_type": "file", + "access_level": "1", + } + ) + + cls.server_log_2 = cls.ServerLog.create( + { + "name": "Test Log 2", + "server_id": cls.server_test_1.id, + "log_type": "file", + "access_level": "1", + } + ) + + # Create additional server for testing + cls.server_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "test2", + "ssh_password": "test2", + "ssh_port": 22, + "user_ids": [(6, 0, [])], + "manager_ids": [(6, 0, [])], + } + ) + + # Use pre-created jet_template_test and jet_test from TestTowerJetsCommon + # Ensure jet_template_test has server_test_1 in server_ids + cls.jet_template_test.write({"server_ids": [(4, cls.server_test_1.id)]}) + + # Create server logs linked to Jet + cls.server_log_jet_1 = cls.ServerLog.create( + { + "name": "Test Jet Log 1", + "server_id": cls.server_test_1.id, + "jet_id": cls.jet_test.id, + "log_type": "file", + "access_level": "1", + } + ) + + cls.server_log_jet_2 = cls.ServerLog.create( + { + "name": "Test Jet Log 2", + "server_id": cls.server_test_1.id, + "jet_id": cls.jet_test.id, + "log_type": "file", + "access_level": "2", + } + ) + + # Create server logs linked to Jet Template + cls.server_log_jet_template_1 = cls.ServerLog.create( + { + "name": "Test Jet Template Log 1", + "server_id": cls.server_test_1.id, + "jet_template_id": cls.jet_template_test.id, + "log_type": "file", + "access_level": "1", + } + ) + + cls.server_log_jet_template_2 = cls.ServerLog.create( + { + "name": "Test Jet Template Log 2", + "server_id": cls.server_test_1.id, + "jet_template_id": cls.jet_template_test.id, + "log_type": "file", + "access_level": "2", + } + ) + + def test_user_access(self): + """Test user access to server logs""" + # Add user to server's user_ids + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + + # Case 1: User should be able to read when: + # - access_level == "1" + # - user is in server's user_ids + recs = self.ServerLog.with_user(self.user).search( + [("id", "in", [self.server_log_1.id, self.server_log_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "User should be able to read all logs with access_level '1'" + " when in user_ids", + ) + + # Case 2: User should not be able to read when not in server's user_ids + self.server_test_1.write( + { + "user_ids": [(5, 0, 0)], # Remove all users + } + ) + recs = self.ServerLog.with_user(self.user).search( + [("id", "=", self.server_log_1.id)] + ) + self.assertEqual( + len(recs), + 0, + "User should not be able to read when not in server's user_ids", + ) + + # Case 3: User should not be able to read when access_level > "1" + self.server_test_1.write( + { + "user_ids": [(6, 0, [self.user.id])], + } + ) + high_access_log = ( + self.ServerLog.with_user(self.user) + .sudo() + .create( + { + "name": "High Access Log", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": "2", + } + ) + ) + + recs = self.ServerLog.with_user(self.user).search( + [("id", "=", high_access_log.id)] + ) + self.assertEqual( + len(recs), + 0, + "User should not be able to read logs with access_level > '1'", + ) + + def test_manager_access(self): + """Test manager access to server logs""" + # Add manager to server's manager_ids + self.server_test_1.write( + { + "manager_ids": [(6, 0, [self.manager.id])], + } + ) + + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in server's manager_ids + recs = self.ServerLog.with_user(self.manager).search( + [("id", "in", [self.server_log_1.id, self.server_log_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when in manager_ids", + ) + + # Case 2: Manager should be able to create and write when: + # - access_level <= "2" + # - manager is in server's manager_ids + try: + new_log = self.ServerLog.with_user(self.manager).create( + { + "name": "Manager Test Log", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": "2", + } + ) + except AccessError: + self.fail( + "Manager should be able to create logs when in server's manager_ids" + ) + + try: + new_log.write({"name": "Updated Name"}) + except AccessError: + self.fail( + "Manager should be able to write logs when in server's manager_ids" + ) + self.assertEqual(new_log.name, "Updated Name") + + # Case 3: Manager should be able to unlink when: + # - access_level <= "2" + # - created by manager + # - manager is in server's manager_ids + try: + new_log.unlink() + except AccessError: + self.fail( + "Manager should be able to unlink own logs when in server's manager_ids" + ) + + # Case 4: Manager should not be able to unlink logs created by others + with self.assertRaises(AccessError): + self.server_log_1.with_user(self.manager).unlink() + + # Case 5: Manager should not be able to access logs with access_level > "2" + high_access_log = ( + self.ServerLog.with_user(self.manager) + .sudo() + .create( + { + "name": "High Access Log", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": "3", + } + ) + ) + + recs = self.ServerLog.with_user(self.manager).search( + [("id", "=", high_access_log.id)] + ) + self.assertEqual( + len(recs), + 0, + "Manager should not be able to read logs with access_level > '2'", + ) + + def test_root_access(self): + """Test root user unrestricted access""" + # Create test logs with various conditions + test_logs = self.ServerLog.with_user(self.root).create( + [ + { + "name": f"Root Test Log {level}", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": level, + } + for level in ["1", "2", "3"] + ] + ) + + # Root should be able to read all logs regardless of conditions + recs = self.ServerLog.with_user(self.root).search([("id", "in", test_logs.ids)]) + self.assertEqual( + len(recs), + 3, + "Root should have unrestricted read access to all logs", + ) + + # Root should be able to write all logs + try: + for log in test_logs: + log.write({"name": "Updated by Root"}) + except AccessError: + self.fail("Root should be able to write any logs") + + # Root should be able to unlink all logs + try: + test_logs.unlink() + except AccessError: + self.fail("Root should be able to unlink any logs") + + def test_log_text_access_restrictions(self): + """Test log_text field access controls""" + test_log = self.ServerLog.create( + { + "name": "Access Test Log", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": "1", + "log_text": "

    Test content

    ", + } + ) + + # 1. Verify read access for all roles + for user in (self.root, self.manager, self.user): + content = test_log.with_user(user).log_text + self.assertEqual( + content, "

    Test content

    ", f"{user.name} should read log_text" + ) + + # 2. Verify write prohibition for all roles + for user in (self.root, self.manager, self.user): + with self.assertRaises( + AccessError, msg=f"{user.name} shouldn't modify log_text" + ): + test_log.with_user(user).write({"log_text": "

    Modified

    "}) + + def test_log_text_refresh_mechanism(self): + """Test log_text can only be updated via refresh action""" + test_log = self.ServerLog.create( + { + "name": "Refresh Test Log", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": "1", + "log_text": "

    Initial

    ", + } + ) + + # 1. Direct write attempts should fail + with self.assertRaises(AccessError): + test_log.sudo().write({"log_text": "

    Illegal Update

    "}) + + # 2. Verify refresh action updates content + original_content = test_log.log_text + test_log.action_update_log() + + self.assertNotEqual( + test_log.log_text, + original_content, + "action_update_log() should update log_text", + ) + + def test_log_text_copy(self): + """Duplicating a log must NOT keep the log output""" + original = self.ServerLog.create( + { + "name": "Original Log", + "server_id": self.server_test_1.id, + "log_type": "file", + "access_level": "1", + "log_text": "

    Original content

    ", + } + ) + + copied = original.copy() + + # log_text must be cleared because copy=False + self.assertFalse(copied.log_text, "Copied log must not keep log_text") + self.assertNotEqual(copied.id, original.id) + self.assertTrue(bool(copied.name)) + + def test_jet_user_access(self): + """Test user access to server logs via Jet""" + # Set user to jet's user_ids (replaces any existing users) + self.jet_test.write({"user_ids": [(6, 0, [self.user.id])]}) + + # Case 1: User should be able to read when: + # - access_level == "1" + # - user is in jet's user_ids + recs = self.ServerLog.with_user(self.user).search( + [("id", "in", [self.server_log_jet_1.id, self.server_log_jet_2.id])] + ) + self.assertEqual( + len(recs), + 1, + "User should be able to read logs with access_level '1'" + " when in jet's user_ids", + ) + self.assertEqual(recs.id, self.server_log_jet_1.id) + + # Case 2: User should not be able to read when not in jet's user_ids + self.jet_test.write({"user_ids": [(5, 0, 0)]}) # Remove all users + recs = self.ServerLog.with_user(self.user).search( + [("id", "=", self.server_log_jet_1.id)] + ) + self.assertEqual( + len(recs), + 0, + "User should not be able to read when not in jet's user_ids", + ) + + # Case 3: User should not be able to read when access_level > "1" + # Set user back to jet's user_ids + self.jet_test.write({"user_ids": [(6, 0, [self.user.id])]}) + recs = self.ServerLog.with_user(self.user).search( + [("id", "=", self.server_log_jet_2.id)] + ) + self.assertEqual( + len(recs), + 0, + "User should not be able to read logs with access_level > '1'", + ) + + def test_jet_manager_access(self): + """Test manager access to server logs via Jet""" + # Set manager to jet's manager_ids (replaces any existing managers) + self.jet_test.write({"manager_ids": [(6, 0, [self.manager.id])]}) + + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in jet's user_ids or manager_ids + recs = self.ServerLog.with_user(self.manager).search( + [("id", "in", [self.server_log_jet_1.id, self.server_log_jet_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when in jet's manager_ids", + ) + + # Case 2: Manager should be able to create and write when: + # - access_level <= "2" + # - manager is in jet's manager_ids + try: + new_log = self.ServerLog.with_user(self.manager).create( + { + "name": "Manager Jet Test Log", + "server_id": self.server_test_1.id, + "jet_id": self.jet_test.id, + "log_type": "file", + "access_level": "2", + } + ) + except AccessError: + self.fail("Manager should be able to create logs when in jet's manager_ids") + + try: + new_log.write({"name": "Updated Jet Name"}) + except AccessError: + self.fail("Manager should be able to write logs when in jet's manager_ids") + self.assertEqual(new_log.name, "Updated Jet Name") + + # Case 3: Manager should be able to unlink when: + # - access_level <= "2" + # - created by manager + # - manager is in jet's manager_ids + try: + new_log.unlink() + except AccessError: + self.fail( + "Manager should be able to unlink own logs when in jet's manager_ids" + ) + + # Case 4: Manager should not be able to unlink logs created by others + with self.assertRaises(AccessError): + self.server_log_jet_1.with_user(self.manager).unlink() + + # Case 5: Manager should not be able to access logs with access_level > "2" + high_access_log = ( + self.ServerLog.with_user(self.manager) + .sudo() + .create( + { + "name": "High Access Jet Log", + "server_id": self.server_test_1.id, + "jet_id": self.jet_test.id, + "log_type": "file", + "access_level": "3", + } + ) + ) + + recs = self.ServerLog.with_user(self.manager).search( + [("id", "=", high_access_log.id)] + ) + self.assertEqual( + len(recs), + 0, + "Manager should not be able to read logs with access_level > '2'", + ) + + # Case 6: Manager should be able to read when in jet's user_ids + # Remove managers and add manager to jet's user_ids + self.jet_test.write( + { + "manager_ids": [(5, 0, 0)], # Remove managers + "user_ids": [(6, 0, [self.manager.id])], # Set to users + } + ) + recs = self.ServerLog.with_user(self.manager).search( + [("id", "in", [self.server_log_jet_1.id, self.server_log_jet_2.id])] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read when in jet's user_ids", + ) + + def test_jet_template_user_access(self): + """Test user access to server logs via Jet Template""" + # Set user to jet template's user_ids (replaces any existing users) + self.jet_template_test.write({"user_ids": [(6, 0, [self.user.id])]}) + + # Case 1: User should be able to read when: + # - access_level == "1" + # - user is in jet template's user_ids + recs = self.ServerLog.with_user(self.user).search( + [ + ( + "id", + "in", + [ + self.server_log_jet_template_1.id, + self.server_log_jet_template_2.id, + ], + ) + ] + ) + self.assertEqual( + len(recs), + 1, + "User should be able to read logs with access_level '1'" + " when in jet template's user_ids", + ) + self.assertEqual(recs.id, self.server_log_jet_template_1.id) + + # Case 2: User should not be able to read when not in jet template's user_ids + self.jet_template_test.write({"user_ids": [(5, 0, 0)]}) # Remove all users + recs = self.ServerLog.with_user(self.user).search( + [("id", "=", self.server_log_jet_template_1.id)] + ) + self.assertEqual( + len(recs), + 0, + "User should not be able to read when not in jet template's user_ids", + ) + + # Case 3: User should not be able to read when access_level > "1" + # Set user back to jet template's user_ids + self.jet_template_test.write({"user_ids": [(6, 0, [self.user.id])]}) + recs = self.ServerLog.with_user(self.user).search( + [("id", "=", self.server_log_jet_template_2.id)] + ) + self.assertEqual( + len(recs), + 0, + "User should not be able to read logs with access_level > '1'", + ) + + def test_jet_template_manager_access(self): + """Test manager access to server logs via Jet Template""" + # Set manager to jet template's manager_ids (replaces any existing managers) + self.jet_template_test.write({"manager_ids": [(6, 0, [self.manager.id])]}) + + # Case 1: Manager should be able to read when: + # - access_level <= "2" + # - manager is in jet template's user_ids or manager_ids + recs = self.ServerLog.with_user(self.manager).search( + [ + ( + "id", + "in", + [ + self.server_log_jet_template_1.id, + self.server_log_jet_template_2.id, + ], + ) + ] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read all logs when" + " in jet template's manager_ids", + ) + + # Case 2: Manager should be able to create and write when: + # - access_level <= "2" + # - manager is in jet template's manager_ids + try: + new_log = self.ServerLog.with_user(self.manager).create( + { + "name": "Manager Jet Template Test Log", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "access_level": "2", + } + ) + except AccessError: + self.fail( + "Manager should be able to create logs when " + "in jet template's manager_ids" + ) + + try: + new_log.write({"name": "Updated Jet Template Name"}) + except AccessError: + self.fail( + "Manager should be able to write logs when " + "in jet template's manager_ids" + ) + self.assertEqual(new_log.name, "Updated Jet Template Name") + + # Case 3: Manager should be able to unlink when: + # - access_level <= "2" + # - created by manager + # - manager is in jet template's manager_ids + try: + new_log.unlink() + except AccessError: + self.fail( + "Manager should be able to unlink own logs" + " when in jet template's manager_ids" + ) + + # Case 4: Manager should not be able to unlink logs created by others + with self.assertRaises(AccessError): + self.server_log_jet_template_1.with_user(self.manager).unlink() + + # Case 5: Manager should not be able to access logs with access_level > "2" + high_access_log = ( + self.ServerLog.with_user(self.manager) + .sudo() + .create( + { + "name": "High Access Jet Template Log", + "server_id": self.server_test_1.id, + "jet_template_id": self.jet_template_test.id, + "log_type": "file", + "access_level": "3", + } + ) + ) + + recs = self.ServerLog.with_user(self.manager).search( + [("id", "=", high_access_log.id)] + ) + self.assertEqual( + len(recs), + 0, + "Manager should not be able to read logs with access_level > '2'", + ) + + # Case 6: Manager should be able to read when in jet template's user_ids + # Remove managers and add manager to jet template's user_ids + self.jet_template_test.write( + { + "manager_ids": [(5, 0, 0)], # Remove managers + "user_ids": [(6, 0, [self.manager.id])], # Set to users + } + ) + recs = self.ServerLog.with_user(self.manager).search( + [ + ( + "id", + "in", + [ + self.server_log_jet_template_1.id, + self.server_log_jet_template_2.id, + ], + ) + ] + ) + self.assertEqual( + len(recs), + 2, + "Manager should be able to read when in jet template's user_ids", + ) diff --git a/addons/cetmix_tower_server/tests/test_server_template.py b/addons/cetmix_tower_server/tests/test_server_template.py new file mode 100644 index 0000000..4d45574 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_server_template.py @@ -0,0 +1,1073 @@ +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import Form + +from .common import TestTowerCommon + + +class TestTowerServerTemplate(TestTowerCommon): + """ + Test the server template model + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create two "Manager" group users + cls.manager1 = cls.Users.create( + { + "name": "Manager 1", + "login": "manager1", + "email": "manager1@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + cls.manager2 = cls.Users.create( + { + "name": "Manager 2", + "login": "manager2", + "email": "manager2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + def test_create_server_from_template(self): + """ + Create new server from template + """ + self.assertFalse( + self.Server.search( + [("server_template_id", "=", self.server_template_sample.id)] + ), + "The servers shouldn't exist", + ) + # add variable values to server template + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "server_template_id": self.server_template_sample.id, + "value_char": "test", + } + ) + + # add delete flight plan + self.server_template_sample.plan_delete_id = self.plan_1.id + + # add server logs to template + command_for_log = self.Command.create( + {"name": "Get system info", "code": "uname -a"} + ) + + server_template_log = self.ServerLog.create( + { + "name": "Log from server template", + "server_template_id": self.server_template_sample.id, + "log_type": "command", + "command_id": command_for_log.id, + } + ) + + self.assertEqual( + len(self.variable_version.value_ids), + 1, + "The variable must have one value only", + ) + + server_log = self.ServerLog.search([("command_id", "=", command_for_log.id)]) + self.assertEqual(len(server_log), 1, "Server log must be one") + + # create new server from template + new_server = self.ServerTemplate.create_server_from_template( + self.server_template_sample.reference, + "server_from_template", + ipv4="0.0.0.0", + ) + + server = self.Server.search( + [("server_template_id", "=", self.server_template_sample.id)] + ) + self.assertEqual(new_server, server, "Servers must be the same") + self.assertEqual( + new_server.name, + "server_from_template", + "Server name must be server_from_template", + ) + self.assertEqual( + new_server.ip_v4_address, "0.0.0.0", "Server IP must be 0.0.0.0" + ) + self.assertEqual( + new_server.os_id, self.os_debian_10, "Server os must be Debian" + ) + self.assertEqual(new_server.ssh_port, 22, "Server SSH Port must be 22") + self.assertEqual( + new_server.ssh_username, "admin", "Server SSH Username must be 'admin'" + ) + self.assertEqual( + new_server._get_secret_value("ssh_password"), + "password", + "Server SSH Password must be 'password'", + ) + self.assertEqual( + new_server.ssh_auth_mode, "p", "Server SSH Auth Mode must be 'p'" + ) + self.assertEqual( + len(self.variable_version.value_ids), + 2, + "The variable must have two value only", + ) + self.assertEqual( + new_server.plan_delete_id, + self.plan_1, + "Server On Delete Plan must be 'Test plan 1'", + ) + + server_log = self.ServerLog.search([("command_id", "=", command_for_log.id)]) + self.assertEqual(len(server_log), 2, "Server log must be two") + + server_log = server_log.filtered(lambda rec: rec.server_id == new_server) + self.assertNotEqual(server_log, server_template_log) + + def test_create_server_from_template_wizard(self): + """ + Create new server from template from wizard + """ + action = self.server_template_sample.action_create_server() + wizard = ( + self.env["cx.tower.server.template.create.wizard"] # pylint: disable=context-overridden we need a new clean context + .with_context(action["context"]) + .new({}) + ) + self.assertEqual( + self.server_template_sample, + wizard.server_template_id, + "Server Templates must be the same", + ) + + self.assertFalse( + self.Server.search( + [("server_template_id", "=", self.server_template_sample.id)] + ), + "The servers shouldn't exist", + ) + + wizard.update( + { + "name": "test", + "ip_v4_address": "0.0.0.0", + "use_sudo": "n", + "partner_id": self.user_bob.partner_id.id, + "os_id": self.os_debian_10.id, + "tag_ids": [(4, self.tag_test_production.id)], + } + ) + action = wizard.action_confirm() + + server = self.Server.search( + [("server_template_id", "=", self.server_template_sample.id)] + ) + self.assertEqual(action["res_id"], server.id, "Server ids must be the same") + self.assertEqual( + server.partner_id, self.user_bob.partner_id, "Partner must be the same" + ) + self.assertEqual(server.os_id, self.os_debian_10, "OS must be the same") + self.assertEqual( + server.tag_ids, self.tag_test_production, "Tag must be the same" + ) + self.assertEqual(server.use_sudo, "n", "Use sudo must be the same") + self.assertEqual(server.ip_v4_address, "0.0.0.0", "IP must be the same") + self.assertEqual(server.name, "test", "Name must be the same") + + def test_create_server_from_template_action(self): + """ + Create new server from action + """ + name = "server from template" + self.assertFalse( + self.Server.search([("name", "=", name)]), + "Server should not exist", + ) + # add variable values to server template + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "server_template_id": self.server_template_sample.id, + "value_char": "test template version", + } + ) + self.VariableValue.create( + { + "variable_id": self.variable_url.id, + "server_template_id": self.server_template_sample.id, + "value_char": "test template url", + } + ) + # add variable option + variable_url_option = self.VariableOption.create( + { + "name": "localhost", + "value_char": "localhost", + "variable_id": self.variable_url.id, + } + ) + + # create new server with new variable + self.ServerTemplate.create_server_from_template( + self.server_template_sample.reference, + "server from template", + ipv4="localhost", + ssh_username="test", + ssh_password="test", + plan_delete_id=self.plan_1.id, + configuration_variables={ + self.variable_version.reference: "test server version", + "new_variable": "new_value", + }, + configuration_variable_options={ + self.variable_url.reference: variable_url_option.reference, + }, + ) + new_server = self.Server.search([("name", "=", name)]) + + self.assertTrue(new_server, "Server must exist!") + self.assertFalse(new_server.plan_delete_id, "On Delete Plan must be empty!") + + self.assertEqual( + len(new_server.variable_value_ids), 3, "Should be 3 variable values!" + ) + + # check variable values + var_version_value = new_server.variable_value_ids.filtered( + lambda rec: rec.variable_id == self.variable_version + ) + self.assertEqual( + var_version_value.value_char, + "test server version", + "Version variable values should be with new values for " + "server from template", + ) + + var_url_value = new_server.variable_value_ids.filtered( + lambda rec: rec.variable_id == self.variable_url + ) + self.assertEqual( + var_url_value.value_char, + variable_url_option.value_char, + "Url variable values should be same as option value", + ) + + var_new_value = new_server.variable_value_ids.filtered( + lambda rec: rec.variable_id.reference == "new_variable" + ) + self.assertTrue(var_new_value, "New variable should exist on the server") + self.assertEqual( + var_new_value.value_char, + "new_value", + "New variable values should be 'new_values'", + ) + + def test_server_template_copy(self): + """ + Test duplicating a Server Template with variable values and server logs + """ + + # A server template + server_template = self.server_template_sample + + # Add variable values to the server template + original_variable_value = self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "server_template_id": server_template.id, + "value_char": "test", + } + ) + + # Create a command for the server log + command_for_log = self.Command.create( + { + "name": "Get system info", + "code": "uname -a", + } + ) + + # Add server logs to the template + original_log = self.ServerLog.create( + { + "name": "Log from server template", + "server_template_id": server_template.id, + "log_type": "command", + "command_id": command_for_log.id, + } + ) + + # Duplicate the server template + copied_template = server_template.copy() + + # Ensure the new server template was created with a new ID + self.assertNotEqual( + copied_template.id, + server_template.id, + "Copied server template should have a different ID from the original", + ) + + # Check that the copied template has the same number of variable values + self.assertEqual( + len(copied_template.variable_value_ids), + len(server_template.variable_value_ids), + ( + "Copied template should have the same " + "number of variable values as the original" + ), + ) + + # Ensure the variable itself was copied (check variable_id) + copied_variable_value = copied_template.variable_value_ids + self.assertEqual( + copied_variable_value.variable_id.id, + original_variable_value.variable_id.id, + "Variable ID should be the same in the copied template", + ) + self.assertEqual( + copied_variable_value.value_char, + original_variable_value.value_char, + "Variable value should be the same in the copied template", + ) + + # Check that the copied template has the same number of server logs + self.assertEqual( + len(copied_template.server_log_ids), + len(server_template.server_log_ids), + ( + "Copied template should have the same " + "number of server logs as the original" + ), + ) + + # Ensure the first server log in the copied template matches the original + copied_log = copied_template.server_log_ids + self.assertEqual( + copied_log.name, + original_log.name, + "Server log name should be the same in the copied template", + ) + self.assertEqual( + copied_log.command_id.id, + original_log.command_id.id, + "Command ID should be the same in the copied server log", + ) + self.assertEqual( + copied_log.command_id.code, + original_log.command_id.code, + "Command code should be the same in the copied server log", + ) + + def test_required_attribute_in_wizard_field(self): + """ + Test that the 'required' attribute + is correctly applied to the 'value_char' field + in the wizard when the variable is marked as required. + """ + # Create a required variable + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "server_template_id": self.server_template_sample.id, + "value_char": "Test Value", + "required": True, + } + ) + + # Open the wizard + wizard = self.env["cx.tower.server.template.create.wizard"].create( + { + "server_template_id": self.server_template_sample.id, + "name": "Test Server", + "ssh_username": "admin", + } + ) + + # Checking that the 'required' flag is passed to the form context + required_fields = [ + line.required + for line in wizard.line_ids + if line.variable_id == self.variable_version + ] + self.assertTrue( + all(required_fields), + "The 'required' attribute should be correctly " + "applied to the 'value_char' field for required variables.", + ) + + def test_successful_server_creation_with_required_variables(self): + """ + Test that a server is successfully created + when all required variables are filled in the wizard. + """ + # Add manager as user of template + self.server_template_sample.user_ids = self.manager + + # Adding a required variable + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "server_template_id": self.server_template_sample.id, + "value_char": "", + "required": True, + } + ) + + # Open the wizard and fill in the data as manager + wizard = ( + self.env["cx.tower.server.template.create.wizard"] + .with_user(self.manager) + .create( + { + "server_template_id": self.server_template_sample.id, + "name": "Test Server With Required Variables", + "ssh_username": "admin", + "line_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_version.id, + "required": True, + }, + ) + ], + } + ) + ) + + # Fill in the value for the required variable + with Form(wizard) as wizard_form: + with wizard_form.line_ids.edit(0) as line: + line.value_char = "Test Value" + wizard_form.save() + + # Checking the successful creation of the server + action = wizard.action_confirm() + self.assertTrue(action, "Server should be created successfully.") + + # Checking that the server has been created + server = self.Server.search( + [ + ("name", "=", "Test Server With Required Variables"), + ("server_template_id", "=", self.server_template_sample.id), + ] + ) + self.assertTrue(server, "Server should exist.") + self.assertEqual( + server.variable_value_ids.filtered( + lambda v: v.variable_id == self.variable_version + ).value_char, + "Test Value", + "The variable value should be saved correctly.", + ) + + def test_optional_variable_with_empty_value(self): + """ + Test that an optional variable + with an empty value is saved correctly + in the wizard and does not block server creation. + """ + # Adding an optional variable + self.VariableValue.create( + { + "variable_id": self.variable_url.id, + "server_template_id": self.server_template_sample.id, + "value_char": "", + "required": False, + } + ) + + # Open the wizard + wizard = self.env["cx.tower.server.template.create.wizard"].create( + { + "server_template_id": self.server_template_sample.id, + "name": "Server With Optional Variable", + "ssh_username": "admin", + "line_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_url.id, + "value_char": "", + "required": False, + }, + ) + ], + } + ) + + # Checking that the wizard is saved without errors + wizard.action_confirm() + + # Checking that the server has been created + server = self.Server.search( + [ + ("name", "=", "Server With Optional Variable"), + ("server_template_id", "=", self.server_template_sample.id), + ] + ) + self.assertTrue( + server, "Server should be created successfully with optional variables." + ) + + # Checking that an optional variable is saved with an empty value + variable = server.variable_value_ids.filtered( + lambda v: v.variable_id == self.variable_url + ) + self.assertTrue(variable, "Optional variable should be attached to the server.") + self.assertEqual( + variable.value_char, "", "Optional variable should have an empty value." + ) + + def test_wizard_without_variables(self): + """ + Test that the wizard does not display + any variables if the server template has none. + """ + # Removing all variables from the template + self.VariableValue.search( + [("server_template_id", "=", self.server_template_sample.id)] + ).unlink() + + # Open the wizard + wizard = self.env["cx.tower.server.template.create.wizard"].create( + { + "server_template_id": self.server_template_sample.id, + "name": "Server Without Variables", + "ssh_username": "admin", + } + ) + + # Checking that the wizard does not contain variables + self.assertFalse(wizard.line_ids, "Wizard should not display any variables.") + + def test_update_required_variable_value(self): + """ + Test that the value of a required variable + can be updated in the wizard and saved correctly. + """ + # Adding a required variable + self.VariableValue.create( + { + "variable_id": self.variable_version.id, + "server_template_id": self.server_template_sample.id, + "value_char": "Old Value", + "required": True, + } + ) + + # Open the wizard and update the variable value + wizard = self.env["cx.tower.server.template.create.wizard"].create( + { + "server_template_id": self.server_template_sample.id, + "name": "Server With Updated Variable", + "ssh_username": "admin", + "line_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_version.id, + "value_char": "New Value", + "required": True, + }, + ) + ], + } + ) + wizard.action_confirm() + + # Checking that the variable value has been updated + server = self.Server.search([("name", "=", "Server With Updated Variable")]) + variable = server.variable_value_ids.filtered( + lambda v: v.variable_id == self.variable_version + ) + self.assertEqual( + variable.value_char, + "New Value", + "The variable value should be updated correctly.", + ) + + def test_optional_variable_handling(self): + """ + Test that optional variables do not block server creation, + even if their values are empty or missing. + """ + # Adding an optional variable to the template + self.VariableValue.create( + { + "variable_id": self.variable_url.id, + "server_template_id": self.server_template_sample.id, + "value_char": "", + "required": False, + } + ) + + # Specify an optional variable with an empty value + values = self.server_template_sample._prepare_server_values( + configuration_variables={self.variable_url.reference: ""} + ) + + # Checking that the optional variable is processed correctly + variable_data = next( + ( + v + for v in values["variable_value_ids"] + if v[2]["variable_id"] == self.variable_url.id + ), + None, + ) + self.assertIsNotNone( + variable_data, + "The optional variable should be included " + "in the server values even if empty.", + ) + self.assertEqual( + variable_data[2]["value_char"], + "", + "Optional variable should have an empty value.", + ) + + def test_server_creation_with_all_required_variables_removed(self): + """ + Test that server creation fails if all required variables + are removed in the wizard. + + Steps: + 1. Create a server template with required variables. + 2. Open the server creation wizard. + 3. Remove all required variables from the wizard. + 4. Attempt to create the server. + + Expected Result: + - ValidationError is raised with a clear message listing missing variables. + """ + # Create a server template with mandatory variables + template = self.ServerTemplate.create( + { + "name": "Template with required variables", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_path.id, + "value_char": "/var/log", + "required": True, + }, + ), + ( + 0, + 0, + { + "variable_id": self.variable_dir.id, + "value_char": "logs", + "required": True, + }, + ), + ], + } + ) + + # Simulating the launch of a wizard with the removal of all variables + configuration_variables = {} # All variables removed + + # Checking that the server cannot be created + with self.assertRaises(ValidationError) as cm: + template._create_new_server( + name="Server with missing variables", + configuration_variables=configuration_variables, + ) + + # Checking that the error message contains all removed variables + error_message = str(cm.exception) + self.assertIn("Please resolve the following issues", error_message) + self.assertIn("Missing variables: test_path_, test_dir", error_message) + + def test_partial_required_variables_provided(self): + """ + Test that server creation fails if only some required variables + are provided, and the error message includes both missing and empty variables. + """ + # Create a template with mandatory variables + template = self.ServerTemplate.create( + { + "name": "Template with partial variables", + "variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_path.id, + "value_char": "/var/log", + "required": False, + }, + ), + ( + 0, + 0, + { + "variable_id": self.variable_dir.id, + "required": True, + }, + ), + ], + } + ) + + # Launch the wizard and specify only some of the required variables + configuration_variables = {"test_path_": "/var/log"} # test_dir skipped + + # Checking that the server is not being created + with self.assertRaises(ValidationError) as cm: + template._create_new_server( + name="Server with partial variables", + configuration_variables=configuration_variables, + ) + + # Checking the error message + error_message = str(cm.exception) + self.assertIn("Missing variables: test_dir", error_message) + self.assertNotIn("test_path_", error_message) # test_path_ provided + + def test_empty_values_for_required_variables(self): + """ + Test that server creation fails if required variables + have empty values, and the error message includes these variables. + """ + # Create a template with mandatory variables + template = self.ServerTemplate.create( + { + "name": "Template with empty values", + "variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_path.id, + "value_char": "", + "required": True, + }, + ), + ( + 0, + 0, + { + "variable_id": self.variable_dir.id, + "value_char": "", + "required": True, + }, + ), + ], + } + ) + + # Run the wizard with empty values for all variables + configuration_variables = {"test_path_": "", "test_dir": ""} + + # Checking that the server is not being created + with self.assertRaises(ValidationError) as cm: + template._create_new_server( + name="Server with empty variables", + configuration_variables=configuration_variables, + ) + + # Checking the error message + error_message = str(cm.exception) + self.assertIn("Empty values for variables: test_path_, test_dir", error_message) + + def test_with_partial_removed_variables_from_wizard(self): + """ + Test that server creation only with specified + variables from wizard and option + """ + # create new variable option + test_variable = self.Variable.create( + { + "name": "Test Variable", + "variable_type": "s", + } + ) + option = self.VariableOption.create( + { + "name": "test", + "value_char": "test", + "variable_id": test_variable.id, + } + ) + + # template with variables + self.server_template_sample.write( + { + "variable_value_ids": [ + ( + 0, + 0, + { + "variable_id": self.variable_path.id, + "value_char": "/var/log", + "required": False, + }, + ), + ( + 0, + 0, + { + "variable_id": test_variable.id, + "option_id": option.id, + "required": False, + }, + ), + ], + } + ) + + action = self.server_template_sample.action_create_server() + + # Open the wizard and fill in the data + wizard = ( + self.env["cx.tower.server.template.create.wizard"] # pylint: disable=context-overridden we new need a new clean context + .with_context(action["context"]) + .create( + { + "name": "Server from Template", + "ip_v4_address": "localhost", + "server_template_id": self.server_template_sample.id, + } + ) + ) + + with Form(wizard) as wizard_form: + wizard_form.line_ids.remove(0) + wizard_form.save() + + wizard.action_confirm() + + server = self.server_template_sample.server_ids + self.assertEqual( + len(server.variable_value_ids), 1, "Server variable must be 1!" + ) + self.assertEqual( + server.variable_value_ids.value_char, + option.value_char, + "The variable value must be equal to the value from the option", + ) + + def test_manager_access_rights(self): + """ + Test manager access rights for Server Template records: + - Read: user is in user_ids or manager_ids + - Write: user is in manager_ids + """ + record = self.ServerTemplate.create( + { + "name": "Manager Access Test", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "user_ids": [(5, 0, 0)], + "manager_ids": [(5, 0, 0)], + } + ) + + # Case 1: No access rights + records = self.ServerTemplate.with_user(self.manager1).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 0, + "Manager should not see the record if not added to user_ids or manager_ids", + ) + + # Case 2: Read access through user_ids + record.write({"user_ids": [(4, self.manager1.id)]}) + records = self.ServerTemplate.with_user(self.manager1).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should see the record when added to user_ids", + ) + + # Write access should still be forbidden + with self.assertRaises(AccessError): + record.with_user(self.manager1).write({"name": "Updated Name"}) + + # Case 3: Full access through manager_ids + record.write( + { + "user_ids": [(5, 0, 0)], + "manager_ids": [(4, self.manager1.id)], + } + ) + + records = self.ServerTemplate.with_user(self.manager1).search( + [("id", "=", record.id)] + ) + self.assertEqual( + len(records), + 1, + "Manager should see the record when added to manager_ids", + ) + + # Write access should now work + try: + record.with_user(self.manager1).write({"name": "Updated Name"}) + except AccessError: + self.fail("Manager should be able to update the record when in manager_ids") + + def test_manager_create_access(self): + """ + Test that a manager can only create a Server Template record + if they add themselves to manager_ids. + """ + # Try to create without adding to manager_ids + with self.assertRaises(AccessError): + self.ServerTemplate.with_user(self.manager1).create( + { + "name": "Create Access Test - Should Fail", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(5, 0, 0)], + } + ) + + # Create with manager_ids - should succeed + record = self.ServerTemplate.with_user(self.manager1).create( + { + "name": "Create Access Test - Should Succeed", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(4, self.manager1.id)], + } + ) + self.assertEqual( + len(self.ServerTemplate.search([("id", "=", record.id)])), + 1, + "Manager should be able to create record when added to manager_ids", + ) + + def test_manager_delete_access(self): + """ + Test that a manager can only delete a Server Template record if: + - They are in manager_ids + - They created the record + """ + # Scenario 1: Manager1 creates and tries to delete their own record + record = self.ServerTemplate.with_user(self.manager1).create( + { + "name": "Delete Access Test - Own Record", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(4, self.manager1.id)], + } + ) + + try: + record.with_user(self.manager1).unlink() + except AccessError: + self.fail( + "Manager should be able to delete their own record if in manager_ids" + ) + + # Scenario 2: Manager2 creates record, Manager1 tries to delete + record2 = self.ServerTemplate.with_user(self.manager2).create( + { + "name": "Delete Access Test - Other's Record", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(6, 0, [self.manager1.id, self.manager2.id])], + } + ) + + # Manager1 should not be able to delete Manager2's record + with self.assertRaises(AccessError): + record2.with_user(self.manager1).unlink() + + # Remove Manager2 from manager_ids + record2.write({"manager_ids": [(5, 0, 0)]}) + + # Manager2 should not be able to delete their record now + with self.assertRaises(AccessError): + record2.with_user(self.manager2).unlink() + + # Scenario 3: Manager1 creates record but is later removed from manager_ids + record3 = self.ServerTemplate.with_user(self.manager1).create( + { + "name": "Delete Access Test - Removed Manager", + "ssh_port": 22, + "ssh_username": "admin", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "manager_ids": [(4, self.manager1.id)], + } + ) + + # Remove Manager1 from manager_ids + record3.write({"manager_ids": [(5, 0, 0)]}) + + # Manager1 should not be able to delete their record after being removed + with self.assertRaises(AccessError): + record3.with_user(self.manager1).unlink() + + def test_server_template_reference_update(self): + """Test server template reference update cascades to dependent models""" + # 1. Add a variable value to server_template_sample + variable_value = self.VariableValue.create( + { + "variable_id": self.variable_os.id, + "value_char": "Ubuntu 20.04", + "server_template_id": self.server_template_sample.id, + } + ) + + # Store original references for comparison + original_template_reference = self.server_template_sample.reference + original_variable_value_reference = variable_value.reference + + # 2. Change the reference for server_template_sample to "super_template" + self.server_template_sample.write({"reference": "super_template"}) + + # 3. Verify that references are updated for dependent models + # Invalidate models to refresh all references + self.env["cx.tower.server.template"].invalidate_model(["reference"]) + self.env["cx.tower.variable.value"].invalidate_model(["reference"]) + + # Check that server template reference was updated + self.assertEqual(self.server_template_sample.reference, "super_template") + self.assertNotEqual( + self.server_template_sample.reference, original_template_reference + ) + + # Check that variable value reference was updated + # to include the new template reference + self.assertIn("super_template", variable_value.reference) + self.assertNotEqual(variable_value.reference, original_variable_value_reference) + + # Verify the reference pattern for variable value follows the expected format: + # ___ # noqa: E501 + expected_variable_pattern = ( + f"{self.variable_os.reference}_variable_value_server_template_" + f"{self.server_template_sample.reference}" + ) + self.assertEqual(variable_value.reference, expected_variable_pattern) diff --git a/addons/cetmix_tower_server/tests/test_shortcut.py b/addons/cetmix_tower_server/tests/test_shortcut.py new file mode 100644 index 0000000..804edf3 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_shortcut.py @@ -0,0 +1,244 @@ +from .common import TestTowerCommon + + +class TestTowerShortcut(TestTowerCommon): + """Test Tower Shortcut""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Server + cls.server_test_1_pro = cls.Server.create( + { + "name": "Test 1 Pro", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "skip_host_key": True, + } + ) + + # Variable + cls.variable_path_pro = cls.Variable.create({"name": "test_path_pro"}) + + # Command + cls.command_list_dir_pro = cls.Command.create( + { + "name": "Test create directory", + "code": "ls -l {{ test_path_ }}", + } + ) + + # Flight plan + cls.plan_1_pro = cls.Plan.create( + { + "name": "Test plan 1 Pro", + "note": "List directory contents", + } + ) + cls.plan_line_1_pro = cls.plan_line.create( + { + "sequence": 5, + "plan_id": cls.plan_1_pro.id, + "command_id": cls.command_list_dir_pro.id, + } + ) + + # Shortcuts + cls.shortcut_for_command = cls.Shortcut.create( + { + "name": "Shortcut for Command", + "action": "command", + "command_id": cls.command_list_dir_pro.id, + "server_ids": [(4, cls.server_test_1_pro.id)], + } + ) + + cls.shortcut_for_flight_plan = cls.Shortcut.create( + { + "name": "Shortcut for Flight Plan", + "action": "plan", + "plan_id": cls.plan_1_pro.id, + "server_ids": [(4, cls.server_test_1_pro.id)], + } + ) + + def test_shortcut_user_access_rules(self): + """Test shortcut user access rules""" + # Create shortcuts with different access levels and server/template assignments + shortcut_level_1_server = self.Shortcut.create( + { + "name": "Level 1 Server Shortcut", + "action": "command", + "command_id": self.command_list_dir_pro.id, + "server_ids": [(4, self.server_test_1_pro.id)], + "access_level": "1", + } + ) + + shortcut_level_2_template = self.Shortcut.create( + { + "name": "Level 2 Template Shortcut", + "action": "command", + "command_id": self.command_list_dir_pro.id, + "server_template_ids": [(4, self.server_template_sample.id)], + "access_level": "2", + } + ) + + # Remove bob from all cxtower_server groups + self.remove_from_group( + self.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + + shortcut_server_as_bob = shortcut_level_1_server.with_user(self.user_bob) + shortcut_template_as_bob = shortcut_level_2_template.with_user(self.user_bob) + + # Test: User access + self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") + self.server_test_1_pro.write({"user_ids": [(4, self.user_bob.id)]}) + + # User should see level 1 shortcuts for their servers + res = shortcut_server_as_bob.read(["name"]) + self.assertEqual(res[0]["name"], shortcut_level_1_server.name) + + # User should NOT see level 2 shortcuts + search_result = shortcut_template_as_bob.search( + [("id", "=", shortcut_level_2_template.id)] + ) + self.assertEqual(len(search_result), 0) + + # Test: Manager access through server assignment + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + self.server_test_1_pro.write({"manager_ids": [(4, self.user_bob.id)]}) + + # Manager should see shortcuts for servers they manage + res = shortcut_server_as_bob.read(["name"]) + self.assertEqual(res[0]["name"], shortcut_level_1_server.name) + + # Manager should NOT see template shortcuts without template access + search_result = shortcut_template_as_bob.search( + [("id", "=", shortcut_level_2_template.id)] + ) + self.assertEqual(len(search_result), 0) + + # Test: Manager access through template assignment + self.server_template_sample.write({"manager_ids": [(4, self.user_bob.id)]}) + + # Manager should now see template shortcuts + res = shortcut_template_as_bob.read(["name"]) + self.assertEqual(res[0]["name"], shortcut_level_2_template.name) + + # Test: Manager access as template user + self.server_template_sample.write( + { + "manager_ids": [(3, self.user_bob.id)], # Remove from managers + "user_ids": [(4, self.user_bob.id)], # Add as user + } + ) + + # Manager should still see template shortcuts when they're a template user + res = shortcut_template_as_bob.read(["name"]) + self.assertEqual(res[0]["name"], shortcut_level_2_template.name) + + # Test: Root access to all shortcuts + shortcut_level_3 = self.Shortcut.create( + { + "name": "Level 3 Mixed Shortcut", + "action": "command", + "command_id": self.command_list_dir_pro.id, + "server_ids": [(4, self.server_test_1_pro.id)], + "server_template_ids": [(4, self.server_template_sample.id)], + "access_level": "3", + } + ) + shortcut_level_3_as_bob = shortcut_level_3.with_user(self.user_bob) + + # Manager should NOT see level 3 shortcuts + search_result = shortcut_level_3_as_bob.search( + [("id", "=", shortcut_level_3.id)] + ) + self.assertEqual(len(search_result), 0) + + # Root should see all shortcuts + self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") + search_result = shortcut_level_3_as_bob.search( + [ + ( + "id", + "in", + [ + shortcut_level_1_server.id, + shortcut_level_2_template.id, + shortcut_level_3.id, + ], + ) + ] + ) + self.assertEqual(len(search_result), 3) + + def test_shortcut_run_type_command(self): + """Test run shortcut of type 'command'""" + self.shortcut_for_command.run(self.server_test_1_pro) + + # Check command log + shortcut_result = self.CommandLog.search( + [("command_id", "=", self.shortcut_for_command.command_id.id)] + ) + self.assertEqual(len(shortcut_result), 1, "Must be single log record") + self.assertEqual( + shortcut_result.server_id, + self.server_test_1_pro, + "Server should match", + ) + + def test_shortcut_run_type_plan(self): + """Test run shortcut of type 'plan'""" + self.shortcut_for_flight_plan.run(self.server_test_1_pro) + + # Check shortcut log + shortcut_result = self.PlanLog.search( + [("plan_id", "=", self.shortcut_for_flight_plan.plan_id.id)] + ) + self.assertEqual(len(shortcut_result), 1, "Must be single log record") + self.assertEqual( + shortcut_result.server_id, + self.server_test_1_pro, + "Server should match", + ) + + def test_shortcut_run_from_context(self): + """Test running shortcut with server from context""" + # Create a test shortcut + shortcut = self.Shortcut.create( + { + "name": "Context Test Shortcut", + "action": "command", + "command_id": self.command_list_dir_pro.id, + "server_ids": [(4, self.server_test_1_pro.id)], + } + ) + + # Run with server_id in context + shortcut.with_context(server_id=self.server_test_1_pro.id).run() + + # Check command log was created + log_entries = self.CommandLog.search( + [ + ("command_id", "=", shortcut.command_id.id), + ("server_id", "=", self.server_test_1_pro.id), + ] + ) + self.assertEqual(len(log_entries), 1, "Should create a log entry") + self.assertEqual( + log_entries.server_id, + self.server_test_1_pro, + "Server should match", + ) diff --git a/addons/cetmix_tower_server/tests/test_tag.py b/addons/cetmix_tower_server/tests/test_tag.py new file mode 100644 index 0000000..41b61a4 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_tag.py @@ -0,0 +1,91 @@ +from odoo.exceptions import AccessError, ValidationError + +from .common import TestTowerCommon + + +class TestTowerTag(TestTowerCommon): + """Test for the 'cx.tower.tag' model""" + + def test_01_unlink_as_user_with_used_tag(self): + """Test that user cannot delete tag that is in use""" + # Create test tag + test_tag = self.Tag.create( + { + "name": "Test Tag User", + } + ) + # Link tag to server + self.server_test_1.write({"tag_ids": [(4, test_tag.id)]}) + + with self.assertRaises(ValidationError): + test_tag.with_user(self.user).unlink() + + def test_02_unlink_as_user_with_unused_tag(self): + """Test that user cannot delete tag even if it's not in use""" + # Create new unused tag + unused_tag = self.Tag.create( + { + "name": "Unused Tag", + } + ) + # Try to delete unused tag + with self.assertRaises(AccessError): + unused_tag.with_user(self.user).unlink() + + def test_03_unlink_as_manager_with_used_tag(self): + """Test that manager cannot delete tag that is in use""" + # Create test tag as manager + test_tag = self.Tag.with_user(self.manager).create( + { + "name": "Test Tag Manager", + } + ) + # Link tag to server + test_tag.write({"server_ids": [(4, self.server_test_1.id)]}) + + # Access error because user doesn't have access to server + with self.assertRaises(AccessError): + test_tag.with_user(self.user).unlink() + + # Add 'manager' to server + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + + # Validation error + with self.assertRaises(ValidationError): + test_tag.with_user(self.manager).unlink() + + def test_04_unlink_as_manager_with_own_tag(self): + """Test that manager can delete their own unused tag""" + # Create new unused tag as manager + unused_tag = self.Tag.with_user(self.manager).create( + { + "name": "Manager's Tag", + } + ) + # Manager should be able to delete their own unused tag + unused_tag.with_user(self.manager).unlink() + + def test_05_unlink_as_manager_with_other_tag(self): + """Test that manager cannot delete tag created by other user""" + # Create tag as root + other_tag = self.Tag.create( + { + "name": "Other's Tag", + } + ) + # Manager should not be able to delete tag created by other user + with self.assertRaises(AccessError): + other_tag.with_user(self.manager).unlink() + + def test_06_unlink_as_sudo(self): + """Test that sudo can delete tag that is in use""" + # Create test tag + test_tag = self.Tag.create( + { + "name": "Test Tag Sudo", + } + ) + # Link tag to server + self.server_test_1.write({"tag_ids": [(4, test_tag.id)]}) + + test_tag.with_user(self.user).sudo().unlink() diff --git a/addons/cetmix_tower_server/tests/test_tag_mixin.py b/addons/cetmix_tower_server/tests/test_tag_mixin.py new file mode 100644 index 0000000..8faa270 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_tag_mixin.py @@ -0,0 +1,167 @@ +from .common import TestTowerCommon + + +class TestTowerTagMixin(TestTowerCommon): + """Test class for tower tag mixin.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create 3 tags to test tag mixin + cls.tag_test_1 = cls.Tag.create( + { + "name": "Test Tag 1", + } + ) + cls.tag_test_2 = cls.Tag.create( + { + "name": "Test Tag 2", + } + ) + cls.tag_test_3 = cls.Tag.create( + { + "name": "Test Tag 3", + } + ) + + # Create 3 commands to test tag mixin + cls.command_test_1 = cls.Command.create( + { + "name": "Test Command 1", + } + ) + cls.command_test_2 = cls.Command.create( + { + "name": "Test Command 2", + } + ) + cls.command_test_3 = cls.Command.create( + { + "name": "Test Command 3", + } + ) + + cls.all_commands = cls.command_test_1 | cls.command_test_2 | cls.command_test_3 + + # Add tags to commands + # - Command 1: Test Tag 1, Test Tag 2 + cls.command_test_1.add_tags(["Test Tag 1", "Test Tag 2", "Test Tag 3"]) + # - Command 2: Test Tag 2, Test Tag 3 + cls.command_test_2.add_tags(["Test Tag 2", "Test Tag 3"]) + # - Command 3: Test Tag 3 + cls.command_test_3.add_tags(["Test Tag 3"]) + + def test_01_add_tags(self): + """Test that tags are added to the record""" + self.assertEqual(len(self.command_test_1.tag_ids), 3) + self.assertEqual(len(self.command_test_2.tag_ids), 2) + self.assertEqual(len(self.command_test_3.tag_ids), 1) + self.assertIn(self.tag_test_1, self.command_test_1.tag_ids) + self.assertIn(self.tag_test_2, self.command_test_1.tag_ids) + self.assertIn(self.tag_test_3, self.command_test_1.tag_ids) + self.assertIn(self.tag_test_2, self.command_test_2.tag_ids) + self.assertIn(self.tag_test_3, self.command_test_2.tag_ids) + self.assertIn(self.tag_test_3, self.command_test_3.tag_ids) + + # Test adding duplicate tags (should be idempotent) + self.command_test_1.add_tags(["Test Tag 1"]) + self.assertEqual(len(self.command_test_1.tag_ids), 3) + + # Test adding single tag name + self.command_test_1.add_tags("Test Tag 1") + self.assertEqual(len(self.command_test_1.tag_ids), 3) + self.assertIn(self.tag_test_1, self.command_test_1.tag_ids) + self.assertIn(self.tag_test_2, self.command_test_1.tag_ids) + self.assertIn(self.tag_test_3, self.command_test_1.tag_ids) + + # Test adding invalid type (should return True) + self.assertTrue(self.command_test_1.add_tags(123)) + self.assertTrue(self.command_test_1.add_tags([])) + # Test adding invalid type (should return True) + # Empty list is a no-op + before = len(self.command_test_1.tag_ids) + self.assertTrue(self.command_test_1.add_tags([])) + self.assertEqual(len(self.command_test_1.tag_ids), before) + + # Test adding non-existent tags (should be ignored) + initial_count = len(self.command_test_1.tag_ids) + self.command_test_1.add_tags(["Non Existent Tag"]) + self.assertEqual(len(self.command_test_1.tag_ids), initial_count) + + def test_02_remove_tags(self): + """Test that tags are removed from the record""" + self.command_test_1.remove_tags(["Test Tag 1", "Test Tag 2"]) + self.assertEqual(len(self.command_test_1.tag_ids), 1) + + # Test removing single tag name + self.command_test_2.remove_tags("Test Tag 2") + self.assertEqual(len(self.command_test_2.tag_ids), 1) + self.assertIn(self.tag_test_3, self.command_test_2.tag_ids) + + # Test removing invalid type (should return True) + self.assertTrue(self.command_test_1.remove_tags(123)) + # Test removing no tags (should return True) + self.assertTrue(self.command_test_1.remove_tags([])) + + def test_03_has_tags(self): + """Test that the record has any of the given tags""" + + # Search selected records + commands_with_any_tags = self.all_commands.has_tags( + ["Test Tag 1", "Test Tag 2"] + ) + self.assertEqual(len(commands_with_any_tags), 2) + self.assertIn(self.command_test_1, commands_with_any_tags) + self.assertIn(self.command_test_2, commands_with_any_tags) + + # Search all records in the model + commands_with_any_tags = self.Command.has_tags( + ["Test Tag 1", "Test Tag 2"], search_all=True + ) + self.assertEqual(len(commands_with_any_tags), 2) + self.assertIn(self.command_test_1, commands_with_any_tags) + self.assertIn(self.command_test_2, commands_with_any_tags) + + # Search with single tag name + commands_with_any_tags = self.all_commands.has_tags("Test Tag 2") + self.assertEqual(len(commands_with_any_tags), 2) + self.assertIn(self.command_test_1, commands_with_any_tags) + self.assertIn(self.command_test_2, commands_with_any_tags) + + commands_with_any_tags = self.Command.has_tags("Test Tag 2", search_all=True) + self.assertEqual(len(commands_with_any_tags), 2) + self.assertIn(self.command_test_1, commands_with_any_tags) + self.assertIn(self.command_test_2, commands_with_any_tags) + + # Search with invalid type (should return empty recordset) + commands_with_any_tags = self.Command.has_tags(123) + self.assertEqual(len(commands_with_any_tags), 0) + + # Search with no tags (should return empty recordset) + commands_with_any_tags = self.Command.has_tags([]) + self.assertEqual(len(commands_with_any_tags), 0) + + def test_04_has_all_tags(self): + """Test that the record has all of the given tags""" + + # Search selected records + commands_with_all_tags = self.all_commands.has_all_tags( + ["Test Tag 1", "Test Tag 2"] + ) + self.assertEqual(len(commands_with_all_tags), 1) + self.assertIn(self.command_test_1, commands_with_all_tags) + + # Search all records in the model + commands_with_all_tags = self.Command.has_all_tags( + ["Test Tag 1", "Test Tag 2"], search_all=True + ) + self.assertEqual(len(commands_with_all_tags), 1) + self.assertIn(self.command_test_1, commands_with_all_tags) + + # Search with invalid type (should return empty recordset) + commands_with_all_tags = self.Command.has_all_tags(123) + self.assertEqual(len(commands_with_all_tags), 0) + + # Search with no tags (should return empty recordset) + commands_with_all_tags = self.Command.has_all_tags([]) + self.assertEqual(len(commands_with_all_tags), 0) diff --git a/addons/cetmix_tower_server/tests/test_tools.py b/addons/cetmix_tower_server/tests/test_tools.py new file mode 100644 index 0000000..98cd545 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_tools.py @@ -0,0 +1,38 @@ +from odoo.tests import common + +from ..models.tools import CHARS, generate_random_id + + +class TestTools(common.TransactionCase): + """Test class for tools module.""" + + def test_generate_random_id(self): + """Test random id generation""" + # Test single section + result = generate_random_id() + self.assertEqual(len(result), 4) # Default length is 4 + self.assertTrue(all(c in CHARS for c in result)) # All chars from CHARS + + # Test multiple sections + result = generate_random_id(sections=2) + sections = result.split("-") + self.assertEqual(len(sections), 2) + self.assertTrue(all(len(s) == 4 for s in sections)) + self.assertTrue(all(c in CHARS for s in sections for c in s)) + + # Test custom population + result = generate_random_id(population=6) + self.assertEqual(len(result), 6) + + # Test custom separator + result = generate_random_id(sections=2, separator="_") + self.assertIn("_", result) + self.assertEqual(len(result.split("_")), 2) + + # Test invalid inputs + self.assertIsNone(generate_random_id(sections=0)) + self.assertIsNone(generate_random_id(population=-1)) + + # Test empty separator + result = generate_random_id(sections=3, separator="") + self.assertEqual(len(result), 12) # 3 sections of 4 chars with no separator diff --git a/addons/cetmix_tower_server/tests/test_update_related_variable_names.py b/addons/cetmix_tower_server/tests/test_update_related_variable_names.py new file mode 100644 index 0000000..aba26cd --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_update_related_variable_names.py @@ -0,0 +1,204 @@ +from .common import TestTowerCommon + + +class TestUpdateRelatedVariableNames(TestTowerCommon): + """Test Update Related Variable Names""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test variables + cls.var1 = cls.Variable.create({"name": "var1", "reference": "var1"}) + cls.var2 = cls.Variable.create({"name": "var2", "reference": "var2"}) + cls.var3 = cls.Variable.create({"name": "var3", "reference": "var3"}) + + cls.test_command = cls.Command.create( + { + "name": "Test Command", + "code": "{{ var1 }} and {{ var2 }}", + "path": "{{ var3 }}", + } + ) + + cls.server = cls.Server.create( + { + "name": "Test Server", + "color": 2, + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "k", + "ssh_key_id": cls.key_1.id, + } + ) + cls.test_file = cls.File.create( + { + "server_id": cls.server.id, + "code": "{{ var1 }} is used", + "server_dir": "path/to/{{ var2 }}", + "name": "{{ var3 }}.txt", + } + ) + + cls.test_plan_line = cls.plan_line.create( + { + "command_id": cls.test_command.id, + "condition": "Condition with {{ var1 }} and {{ var2 }}", + } + ) + + cls.test_variable_value = cls.VariableValue.create( + { + "variable_id": cls.variable_os.id, + "value_char": "{{ var1 }} is here and {{ var2 }} too", + } + ) + + cls.test_file_template = cls.FileTemplate.create( + { + "name": "Test File Template", + "code": "{{ var1 }} in code", + "server_dir": "This path has {{ var2 }}", + "file_name": "file_name_with_{{ var1 }}", + } + ) + + def test_variables_command_computation(self): + """ + Test that the variable_ids field is correctly computed based on the 'code' + and 'path' fields of the command. + """ + # Verify that the correct variables are assigned to variable_ids + self.assertEqual( + set(self.test_command.variable_ids.ids), + {self.var1.id, self.var2.id, self.var3.id}, + "The variable_ids should contain var1, var2, and var3.", + ) + + def test_variables_command_clearing(self): + """ + Test that the variable_ids field is cleared when + no variables are found in the code or path. + """ + # Update code and path to remove references + self.test_command.write( + {"code": "No variables here", "path": "No variables here either"} + ) + # Verify that variable_ids is empty + self.assertFalse( + self.test_command.variable_ids, + "The variable_ids should be empty when no variables are found.", + ) + + def test_variables_file_computation(self): + """ + Test that the variable_ids field is correctly computed based on the 'code', + 'server_dir', and 'name' fields of the file. + """ + # Verify that the correct variables are assigned to variable_ids + self.assertEqual( + set(self.test_file.variable_ids.ids), + {self.var1.id, self.var2.id, self.var3.id}, + "The variable_ids should contain var1, var2, and var3.", + ) + + def test_variables_file_clearing(self): + """ + Test that the variable_ids field is cleared when + no variables are found in the code, server_dir, or name fields. + """ + # Update the file to remove references + self.test_file.write( + { + "code": "No variables here", + "server_dir": "No variables here either", + "name": "no_var.txt", + } + ) + # Verify that variable_ids is empty + self.assertFalse( + self.test_file.variable_ids, + "The variable_ids should be empty when no variables are found.", + ) + + def test_variables_plan_line_computation(self): + """ + Test that the variable_ids field is correctly + computed based on the 'condition' field of the plan line. + """ + # Verify that the correct variables are assigned to variable_ids + self.assertEqual( + set(self.test_plan_line.variable_ids.ids), + {self.var1.id, self.var2.id}, + "The variable_ids should contain var1 and var2.", + ) + + def test_variables_plan_line_clearing(self): + """ + Test that the variable_ids field is cleared when + no variables are found in the condition field. + """ + # Update the plan line to remove references + self.test_plan_line.write({"condition": "No variables in this condition"}) + # Verify that variable_ids is empty + self.assertFalse( + self.test_plan_line.variable_ids, + "The variable_ids should be empty when no variables are found.", + ) + + def test_variables_variable_value_computation(self): + """ + Test that the variable_ids field is correctly + computed based on the 'value_char' field. + """ + # Verify that the correct variables are assigned to variable_ids + self.assertEqual( + set(self.test_variable_value.variable_ids.ids), + {self.var1.id, self.var2.id}, + "The variable_ids should contain var1 and var2.", + ) + + def test_variables_variable_value_clearing(self): + """ + Test that the variable_ids field is cleared when + no variables are found in the value_char field. + """ + # Update the variable value to remove references + self.test_variable_value.write({"value_char": "No variables in this text"}) + # Verify that variable_ids is empty + self.assertFalse( + self.test_variable_value.variable_ids, + "The variable_ids should be empty when no variables are found.", + ) + + def test_variables_file_template_computation(self): + """ + Test that the variable_ids field is correctly computed + based on 'code', 'server_dir', and 'file_name' fields. + """ + # Verify that the correct variables are assigned to variable_ids + self.assertEqual( + set(self.test_file_template.variable_ids.ids), + {self.var1.id, self.var2.id}, + "The variable_ids should contain var1 and var2.", + ) + + def test_variable_file_template_clearing(self): + """ + Test that the variable_ids field is cleared when + no variables are found in code, server_dir, or file_name. + """ + # Update the file template to remove references + self.test_file_template.write( + { + "code": "No variables here", + "server_dir": "No variables here either", + "file_name": "no_var_in_file", + } + ) + # Verify that variable_ids is empty + self.assertFalse( + self.test_file_template.variable_ids, + "The variable_ids should be empty when no variables are found.", + ) diff --git a/addons/cetmix_tower_server/tests/test_variable.py b/addons/cetmix_tower_server/tests/test_variable.py new file mode 100644 index 0000000..9eb380e --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_variable.py @@ -0,0 +1,1189 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest.mock import patch +from urllib.parse import urlparse + +from odoo import _, fields +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import Form + +from .common import TestTowerCommon +from .common_jets import TestTowerJetsCommon + + +class TestTowerVariable(TestTowerCommon): + """Testing variables and variable values.""" + + def check_variable_values(self, vals, server_ids=None): + """Check if variable values are correctly stored in db + + Args: + vals (List of tuples): format ("variable_id", "value") + server_id (cx.tower.server()): Servers those variables belong to. + """ + if server_ids: + variable_records = server_ids.variable_value_ids + else: + variable_records = self.VariableValue.search([("is_global", "=", True)]) + len_vals = len(vals) + + # Ensure correct number of records + self.assertEqual( + len(variable_records), len_vals, msg=f"Must be {str(len_vals)} records" + ) + + # Check variable values + for val in vals: + variable_line = variable_records.filtered( + lambda v, val=val: v.variable_id.id == val[0] + ) + self.assertEqual( + len(variable_line), 1, msg="Must be a single variable line" + ) + expected_value = val[1] or False + self.assertEqual( + variable_line.value_char, + expected_value, + msg="Variable value does not match provided one", + ) + + def test_variable_values(self): + """Test common variable operations""" + + # -- 1 -- + # Server specific variables + + # Add two variables + with Form(self.server_test_1) as f: + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_dir + line.value_char = "/opt/odoo" + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_url + line.value_char = "example.com" + f.save() + + vals = [ + (self.variable_url.id, "example.com"), + (self.variable_dir.id, "/opt/odoo"), + ] + self.check_variable_values(vals=vals, server_ids=self.server_test_1) + + # Add another variable and edit the existing one + with Form(self.server_test_1) as f: + with f.variable_value_ids.edit(1) as line: + line.value_char = "meme.example.com" + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_version + line.value_char = "10.0" + f.save() + + vals = [ + (self.variable_url.id, "meme.example.com"), + (self.variable_dir.id, "/opt/odoo"), + (self.variable_version.id, "10.0"), + ] + self.check_variable_values(vals=vals, server_ids=self.server_test_1) + + # Delete two variables, add a new one + with Form(self.server_test_1) as f: + f.variable_value_ids.remove(index=0) + f.variable_value_ids.remove(index=0) + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_os + line.value_char = "Debian" + + # Add an empty variable value + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_url + f.save() + + vals = [ + (self.variable_os.id, "Debian"), + (self.variable_version.id, "10.0"), + (self.variable_url.id, False), + ] + self.check_variable_values(vals=vals, server_ids=self.server_test_1) + + # Test 'get_variable_values' function + res_vars = self.Variable._get_variable_values_by_references( + ["test_dir", "test_os", "test_url", "test_version"], + server=self.server_test_1, + ) + self.assertEqual(len(res_vars), 5, "Must be a 5 values in the result") + + var_dir = res_vars["test_dir"] + var_os = res_vars["test_os"] + var_url = res_vars["test_url"] + var_version = res_vars["test_version"] + + self.assertIsNone(var_dir, msg="Variable 'dir' must be None") + self.assertFalse(var_url, msg="Variable 'url' must be False") + self.assertEqual(var_os, "Debian", msg="Variable 'os' must be 'Debian'") + self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") + + # -- 2 -- + # Test global variable values + + # Create a global value for the 'dir' variable + self.VariableValue.create( + {"variable_id": self.variable_dir.id, "value_char": "/global/dir"} + ) + res_vars = self.Variable._get_variable_values_by_references( + ["test_dir", "test_os", "test_url", "test_version"], + server=self.server_test_1, + ) + self.assertEqual(len(res_vars), 5, "Must be a 5 values in the result") + + var_dir = res_vars["test_dir"] + var_os = res_vars["test_os"] + var_url = res_vars["test_url"] + var_version = res_vars["test_version"] + + self.assertEqual( + var_dir, "/global/dir", msg="Variable 'dir' must be equal to '/global/dir'" + ) + self.assertFalse(var_url, msg="Variable 'url' must be False") + self.assertEqual(var_os, "Debian", msg="Variable 'os' must be 'Debian'") + self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") + + # Now save a local value for the variable + with Form(self.server_test_1) as f: + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_dir + line.value_char = "/opt/odoo" + f.save() + + # Check + res_vars = self.Variable._get_variable_values_by_references( + ["test_dir", "test_os", "test_url", "test_version"], + server=self.server_test_1, + ) + self.assertEqual(len(res_vars), 5, "Must be a 5 values in the result") + + var_dir = res_vars["test_dir"] + var_os = res_vars["test_os"] + var_url = res_vars["test_url"] + var_version = res_vars["test_version"] + + self.assertEqual( + var_dir, "/opt/odoo", msg="Variable 'dir' must be equal to '/opt/odoo'" + ) + self.assertFalse(var_url, msg="Variable 'url' must be False") + self.assertEqual(var_os, "Debian", msg="Variable 'os' must be 'Debian'") + self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") + + def test_variables_in_variable_values(self): + """Test variables in variable values + eg + home: /home + user: bob + home_dir: {{ home }}/{{ user }} --> /home/bob + """ + + # Add local variables + with Form(self.server_test_1) as f: + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_dir + line.value_char = "/web" + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_path + line.value_char = "{{ test_dir }}/{{ test_version }}" + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_url + line.value_char = "{{ test_path_ }}/example.com" + f.save() + + # Create a global value for the 'Version' variable + self.VariableValue.create( + {"variable_id": self.variable_version.id, "value_char": "10.0"} + ) + + # Check values + res_vars = self.Variable._get_variable_values_by_references( + ["test_dir", "test_url", "test_version"], + server=self.server_test_1, + ) + # Including system variable + self.assertEqual(len(res_vars), 4, "Must be a 4 values in the result") + + var_dir = res_vars["test_dir"] + var_url = res_vars["test_url"] + var_version = res_vars["test_version"] + + self.assertEqual(var_dir, "/web", msg="Variable 'dir' must be '/web'") + self.assertEqual( + var_url, + "/web/10.0/example.com", + msg="Variable 'url' must be '/web/10.0/example.com'", + ) + self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") + + def test_variable_values_unlink(self): + """Ensure variable values are deleted properly + - Create a new server + - Add 2 variable values + - Delete server + - Ensure variable values are deleted + """ + + def get_value_count(variable): + """helper function to count variable value records + Arg: (cx.tower.variable) variable rec + Returns: (int) record count + """ + return self.VariableValue.search_count([("variable_id", "=", variable.id)]) + + # Get variable values count before adding variables to server + count_dir_before = get_value_count(self.variable_dir) + count_url_before = get_value_count(self.variable_url) + + # Create new server + server_test_var = self.Server.create( + { + "name": "Test Var", + "os_id": self.os_debian_10.id, + "ip_v4_address": "localhost", + "ssh_username": "bob", + "ssh_password": "pass", + } + ) + + # Add two variables to server + with Form(server_test_var) as f: + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_dir + line.value_char = "/opt/odoo" + with f.variable_value_ids.new() as line: + line.variable_id = self.variable_url + line.value_char = "example.com" + f.save() + + # Number of values should be incremented + self.assertEqual( + get_value_count(self.variable_dir), + count_dir_before + 1, + msg="Value count must be incremented!", + ) + self.assertEqual( + get_value_count(self.variable_url), + count_url_before + 1, + msg="Value count must be incremented!", + ) + + # Delete the server + server_test_var.unlink() + self.assertEqual( + get_value_count(self.variable_dir), + count_dir_before, + msg="Value count must be same as before server creation!", + ) + self.assertEqual( + get_value_count(self.variable_url), + count_url_before, + msg="Value count must be same as before server creation!", + ) + + def test_variable_value_toggle_global(self): + """Test what happens when variable value 'global' setting is togged""" + + variable_meme = self.Variable.create({"name": "meme"}) + variable_value_pepe = self.VariableValue.create( + {"variable_id": variable_meme.id, "value_char": "Pepe"} + ) + + self.assertEqual( + variable_value_pepe.is_global, True, msg="Value 'Pepe' must be global" + ) + + # Test `_check_is_global` function + self.assertEqual( + variable_value_pepe._check_is_global(), + True, + msg="Value 'Pepe' must be global", + ) + + # Try to create another global value for the same variable + with self.assertRaises(ValidationError) as err: + self.VariableValue.create( + {"variable_id": variable_meme.id, "value_char": "Doge"} + ) + + # We check the message in order to ensure that + # exception was raised by the correct event. + self.assertEqual( + err.exception.args[0], + _("Only one global value can be defined for variable 'meme'"), + msg="Error message doesn't match. Check if you have modified it in code:" + "models/cx_tower_server.py", + ) + + # Try to disable 'global' for a global variable explicitly + with self.assertRaises(ValidationError) as err: + variable_value_pepe.is_global = False + + # We check the message in order to ensure that + # exception was raised by the correct event. + self.assertEqual( + err.exception.args[0], + _( + "Cannot change 'global' status for " + "'meme' with value 'Pepe'." + "\nTry to assigns it to a record instead." + ), + msg="Error message doesn't match. Check if you have modified it in code:" + "models/cx_tower_server.py", + ) + + def test_system_variable_server_type_values(self): + """Test system variables of `server` type""" + + # Modify server record for testing + self.server_test_1.ip_v6_address = "suchmuchipv6" + self.server_test_1.url = "meme.example.com" + self.server_test_1.partner_id = ( + self.env["res.partner"].create({"name": "Pepe Frog"}).id + ) + + # Create new command with system variables + command = self.Command.create( + { + "name": "Super System Command", + "code": "echo {{ tower.server.name }} " + "{{ tower.server.username}} " + "{{ tower.server.partner_name }} " + "{{ tower.server.ipv4 }} " + "{{ tower.server.ipv6 }} " + "{{ tower.server.url }} ", + } + ) + + # Get variables + variables = command.get_variables().get(str(command.id)) + # Get variable values + variable_values = self.Variable._get_variable_values_by_references( + variables, + server=self.server_test_1, + ) + + # Check values + self.assertEqual( + variable_values["tower"]["server"]["name"], + self.server_test_1.name, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["reference"], + self.server_test_1.reference, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["username"], + self.server_test_1.ssh_username, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["username"], + self.server_test_1.ssh_username, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["partner_name"], + self.server_test_1.partner_id.name, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["ipv4"], + self.server_test_1.ip_v4_address, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["ipv6"], + self.server_test_1.ip_v6_address, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["url"], + self.server_test_1.url, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["hostname"], + urlparse(self.server_test_1.url).hostname, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["netloc"], + urlparse(self.server_test_1.url).netloc, + "System variable doesn't match server property", + ) + self.assertEqual( + variable_values["tower"]["server"]["port"], + urlparse(self.server_test_1.url).port, + "System variable doesn't match server property", + ) + + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_variable.fields.Datetime.now", + return_value=fields.Datetime.now(), + ) + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_variable.fields.Date.today", + return_value=fields.Date.today(), + ) + @patch( + "odoo.addons.cetmix_tower_server.models.cx_tower_variable.uuid.uuid4", + return_value="suchmuchuuid4", + ) + def test_system_variable_tools_type_values(self, mock_uuid4, mock_today, mock_now): + """Test system variables of `tools` type""" + + # Create new command with system variables + command = self.Command.create( + {"name": "Super System Command", "code": "echo {{ tower.tools.uuid}}"} + ) + + # Get variables + variables = command.get_variables().get(str(command.id)) + # Get variable values + variable_values = self.Variable._get_variable_values_by_references( + variables, + server=self.server_test_1, + ) + + # Check values + self.assertEqual( + variable_values["tower"]["tools"]["uuid"], + mock_uuid4.return_value, + "System variable doesn't match result provided by tools", + ) + self.assertEqual( + variable_values["tower"]["tools"]["today"], + str(mock_today.return_value), + "System variable doesn't match result provided by tools", + ) + self.assertEqual( + variable_values["tower"]["tools"]["now"], + str(mock_now.return_value), + "System variable doesn't match result provided by tools", + ) + self.assertEqual( + variable_values["tower"]["tools"]["today_underscore"], + str(mock_today.return_value) + .replace("-", "_") + .replace(" ", "_") + .replace(":", "_") + .replace(".", "_") + .replace("/", "_"), + "System variable doesn't match result provided by tools", + ) + self.assertEqual( + variable_values["tower"]["tools"]["now_underscore"], + str(mock_now.return_value) + .replace("-", "_") + .replace(":", "_") + .replace(" ", "_") + .replace(".", "_") + .replace("/", "_"), + "System variable doesn't match result provided by tools", + ) + + def test_make_value_pythonic(self): + """Test making variable values 'pythonic`""" + + # Number + value = 12.34 + expected_value = '"12.34"' + result_value = self.Command._make_value_pythonic(value) + + self.assertEqual( + expected_value, result_value, "Result value doesn't match expected" + ) + + # Text + value = "Doge much like" + expected_value = '"Doge much like"' + result_value = self.Command._make_value_pythonic(value) + + self.assertEqual( + expected_value, result_value, "Result value doesn't match expected" + ) + + # Boolean + value = True + expected_value = True + result_value = self.Command._make_value_pythonic(value) + + self.assertEqual( + expected_value, result_value, "Result value doesn't match expected" + ) + + # None + value = None + expected_value = None + result_value = self.Command._make_value_pythonic(value) + + self.assertEqual( + expected_value, result_value, "Result value doesn't match expected" + ) + + # Dict + value = {"doge": {"likes": "memes", "much": 200}} + expected_value = {"doge": {"likes": '"memes"', "much": '"200"'}} + result_value = self.Command._make_value_pythonic(value) + + self.assertEqual( + expected_value, result_value, "Result value doesn't match expected" + ) + + def test_single_assignment(self): + """Test that a variable can only be assigned to one model at a time.""" + # Create a variable value assigned to the server + variable_value = self.env["cx.tower.variable.value"].create( + { + "variable_id": self.variable_os.id, + "value_char": "Branch = Main", + "server_id": self.server_test_1.id, + } + ) + + # Try to assign the same variable value to + # server template and expect a ValidationError + with self.assertRaises(ValidationError): + variable_value.write({"server_template_id": self.server_template_sample.id}) + + # Try to assign the same variable value to + # plan line action and expect a ValidationError + with self.assertRaises(ValidationError): + variable_value.write({"plan_line_action_id": self.plan_line_1_action_1.id}) + + def test_unique_assignment(self): + """Test that the same variable value cannot be + assigned multiple times to the same record. + """ + + # Create a variable + variable = self.env["cx.tower.variable"].create( + {"name": "Environment Type", "note": "The environment type for the server."} + ) + + # Create a server + server = self.env["cx.tower.server"].create( + { + "name": "Test Server", + "ip_v4_address": "127.0.0.1", + "ssh_username": "testuser", + "ssh_password": "testpassword", + "ssh_auth_mode": "p", + } + ) + + # Create a variable value for the server + self.env["cx.tower.variable.value"].create( + { + "variable_id": variable.id, + "value_char": "Production", + "server_id": server.id, + } + ) + + # Try to create a second variable value with the same variable and server + with self.assertRaises( + ValidationError, + msg="A variable value cannot be assigned multiple times to the same server", + ): + self.env["cx.tower.variable.value"].create( + { + "variable_id": variable.id, + "value_char": "Production", + "server_id": server.id, + } + ) + + def test_value_access_level_consistency(self): + """Test that variable value access level cannot be lower + than variable access level.""" + + # Create test servers + server_2 = self.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + } + ) + + server_3 = self.Server.create( + { + "name": "Test Server 3", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + } + ) + + # Create a variable with access level "2" + variable_restricted = self.Variable.create( + { + "name": "restricted_variable", + "access_level": "2", + } + ) + + # Should succeed: value with same access level as variable + try: + self.VariableValue.create( + { + "variable_id": variable_restricted.id, + "value_char": "test_value1", + "access_level": "2", + "is_global": True, + } + ) + except ValidationError: + self.fail("Should allow creating value with same access level as variable") + + # Should succeed: value with higher access level than variable + try: + self.VariableValue.create( + { + "variable_id": variable_restricted.id, + "value_char": "test_value2", + "access_level": "3", + "server_id": server_2.id, + } + ) + except ValidationError: + self.fail( + "Should allow creating value with higher access level than variable" + ) + + # Should fail: value with lower access level than variable + with self.assertRaises( + ValidationError, + msg="Should not allow creating value with lower access level than variable", + ): + self.VariableValue.create( + { + "variable_id": variable_restricted.id, + "value_char": "test_value3", + "access_level": "1", + "server_id": server_3.id, + } + ) + + # Test updating existing value's access level + value = self.VariableValue.create( + { + "variable_id": self.variable_dir.id, # Using a different variable + "value_char": "test_value4", + "access_level": "2", + "server_id": server_3.id, + } + ) + + # Should fail: updating to lower access level than variable + with self.assertRaises( + ValidationError, + msg="Should not allow updating value to lower access level than variable", + ): + value.write({"access_level": "1"}) + + # Should succeed: updating to higher access level than variable + try: + value.write({"access_level": "3"}) + except ValidationError: + self.fail( + "Should allow updating value to higher access level than variable" + ) + + def test_variable_access_rights(self): + """Test access rights for variables based on access levels and user roles.""" + + # Create variables with different access levels + variable_level_1 = self.Variable.create( + { + "name": "Level 1 Variable", + "access_level": "1", + } + ) + + variable_level_2 = self.Variable.create( + { + "name": "Level 2 Variable", + "access_level": "2", + } + ) + + variable_level_3 = self.Variable.create( + { + "name": "Level 3 Variable", + "access_level": "3", + } + ) + manager2 = self.Users.create( + { + "name": "Manager 2", + "login": "manager2@example.com", + "groups_id": [(4, self.group_manager.id)], + } + ) + + # Test User Access + # --------------- + # Should see level 1 variables + records = self.Variable.with_user(self.user).search( + [ + ( + "id", + "in", + [variable_level_1.id, variable_level_2.id, variable_level_3.id], + ) + ] + ) + self.assertEqual(len(records), 1, "User should only see level 1 variables") + self.assertEqual( + records.id, variable_level_1.id, "User should only see level 1 variables" + ) + + # Test Manager Access + # ----------------- + # Should see level 1 and 2 variables + records = self.Variable.with_user(self.manager).search( + [ + ( + "id", + "in", + [variable_level_1.id, variable_level_2.id, variable_level_3.id], + ) + ] + ) + self.assertEqual(len(records), 2, "Manager should see level 1 and 2 variables") + self.assertIn( + variable_level_1.id, records.ids, "Manager should see level 1 variables" + ) + self.assertIn( + variable_level_2.id, records.ids, "Manager should see level 2 variables" + ) + + # Test Manager Write Access + # ----------------------- + # Create a variable as manager + manager_variable = self.Variable.with_user(self.manager).create( + { + "name": "Manager Created Variable", + "access_level": "2", + } + ) + + # Manager should be able to modify their own variable + try: + manager_variable.with_user(self.manager).write({"name": "Updated Name"}) + except AccessError: + self.fail("Manager should be able to modify their own variables") + + # Manager should not be able to modify another manager's variable + manager2_variable = self.Variable.with_user(manager2).create( + { + "name": "Other Manager Variable", + "access_level": "2", + } + ) + + with self.assertRaises(AccessError): + manager2_variable.with_user(self.manager).write({"name": "Try Update"}) + + # Manager should not be able to create level 3 variable + with self.assertRaises(AccessError): + self.Variable.with_user(self.manager).create( + { + "name": "Try Level 3", + "access_level": "3", + } + ) + + # Test Root Access + # -------------- + # Root should see all variables + records = self.Variable.with_user(self.root).search( + [ + ( + "id", + "in", + [variable_level_1.id, variable_level_2.id, variable_level_3.id], + ) + ] + ) + self.assertEqual(len(records), 3, "Root should see all variables") + + # Root should be able to create any level variable + try: + self.Variable.with_user(self.root).create( + { + "name": "Root Level 3", + "access_level": "3", + } + ) + except AccessError: + self.fail("Root should be able to create any level variable") + + # Root should be able to modify any variable + try: + variable_level_3.with_user(self.root).write({"name": "Updated by Root"}) + except AccessError: + self.fail("Root should be able to modify any variable") + + def test_validate_value(self): + """Test variable value validation""" + # Create variable with validation pattern + variable_with_pattern = self.Variable.create( + { + "name": "Test Pattern", + "validation_pattern": "^[a-z0-9]+$", + "validation_message": "Only lowercase letters and numbers allowed", + } + ) + + # Test valid values + valid_value = "abc123" + is_valid, message = variable_with_pattern._validate_value(valid_value) + self.assertTrue(is_valid, "Value should be valid") + self.assertIsNone(message, "No message should be returned for valid value") + + # Test invalid values + invalid_value = "ABC123!" + is_valid, message = variable_with_pattern._validate_value(invalid_value) + self.assertFalse(is_valid, "Value should be invalid") + self.assertEqual( + message, + f"Variable: {variable_with_pattern.name}, Value: {invalid_value}\n" + "Only lowercase letters and numbers allowed", + "Invalid value message doesn't match", + ) + + # Test empty value + is_valid, message = variable_with_pattern._validate_value(None) + self.assertTrue(is_valid, "Empty value should be valid") + self.assertIsNone(message, "No message should be returned for empty value") + + # Test variable without pattern + variable_no_pattern = self.Variable.create( + { + "name": "No Pattern", + } + ) + test_value = "Any Value!" + is_valid, message = variable_no_pattern._validate_value(test_value) + self.assertTrue(is_valid, "Value should be valid when no pattern is set") + self.assertIsNone( + message, "No message should be returned when no pattern is set" + ) + + # Test default validation message + variable_default_message = self.Variable.create( + { + "name": "Default Message", + "validation_pattern": "^[a-z]+$", + } + ) + invalid_value = "123" + is_valid, message = variable_default_message._validate_value(invalid_value) + self.assertFalse(is_valid, "Value should be invalid") + self.assertEqual( + message, + f"Variable: {variable_default_message.name}, Value: {invalid_value}\n" + f"{variable_default_message.DEFAULT_VALIDATION_MESSAGE}", + "Default validation message doesn't match", + ) + + +class TestVariableReferenceRename(TestTowerCommon): + """Ensure variable rename updates all Jinja references using shared fixtures.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.ref_old = cls.variable_version.reference + cls.ref_new = "software_version" + + cls.command = cls.Command.create( + { + "name": "Show version (test)", + "code": f"echo {{ {{ {cls.ref_old} }} }}", + "variable_ids": [(6, 0, [cls.variable_version.id])], + } + ) + + cls.file = cls.File.create( + { + "name": "test_version.txt", + "server_dir": "/tmp", + "code": f"{{ {{ {cls.ref_old} }} }}", + "variable_ids": [(6, 0, [cls.variable_version.id])], + } + ) + + def _rename(self): + """Rename variable and invalidate caches for records under test.""" + self.variable_version.write({"reference": self.ref_new}) + self.command.invalidate_recordset() + self.file.invalidate_recordset() + + def test_false_references_are_ignored(self): + """Ignore malformed or non-Jinja references.""" + cmd_plain = self.Command.create( + { + "name": "Plain", + "code": "print(test_version)", + "variable_ids": [(6, 0, [self.variable_version.id])], + } + ) + cmd_bad = self.Command.create( + { + "name": "BadBrackets", + "code": "{test_version}", + "variable_ids": [(6, 0, [self.variable_version.id])], + } + ) + + self._rename() + cmd_plain.invalidate_recordset() + cmd_bad.invalidate_recordset() + + self.assertEqual(cmd_plain.code, "print(test_version)") + self.assertEqual(cmd_bad.code, "{test_version}") + + def test_multiple_occurrences_replace_all(self): + """Replace all valid Jinja references in one field.""" + code = "A: {{ test_version }}, B: {{ test_version }}, C-end" + cmd_multi = self.Command.create( + { + "name": "Multi", + "code": code, + "variable_ids": [(6, 0, [self.variable_version.id])], + } + ) + + self._rename() + cmd_multi.invalidate_recordset() + actual_ref = self.variable_version.reference + expected = f"A: {{{{ {actual_ref} }}}}, " f"B: {{{{ {actual_ref} }}}}, C-end" + self.assertEqual(cmd_multi.code, expected) + + def test_template_files_updated(self): + """Propagate rename in template and generated file.""" + tpl = self.env["cx.tower.file.template"].create( + { + "name": "TmpTpl", + "file_name": "tpl.txt", + "server_dir": "/tmp", + "code": "{{ test_version }}", + "variable_ids": [(6, 0, [self.variable_version.id])], + } + ) + tpl_file = self.File.create( + { + "name": "from_tpl.txt", + "server_dir": "/tmp", + "template_id": tpl.id, + "code": "{{ test_version }}", + } + ) + + self._rename() + tpl.invalidate_recordset() + tpl_file.invalidate_recordset() + + actual_ref = self.variable_version.reference + expected = f"{{{{ {actual_ref} }}}}" + self.assertEqual(tpl.code, expected) + self.assertEqual(tpl_file.code, expected) + + def test_value_and_plan_line_update(self): + """Update value_char and plan line condition.""" + + def patched_mapping(_): + return { + "cx.tower.command": ["code", "path"], + "cx.tower.file": ["code", "server_dir", "name"], + "cx.tower.file.template": ["code", "server_dir", "file_name"], + "cx.tower.variable.value": ["value_char"], + "cx.tower.plan.line": ["condition"], + } + + with patch.object( + type(self.variable_version), + "_get_propagation_field_mapping", + patched_mapping, + ): + val = self.env["cx.tower.variable.value"].create( + { + "variable_id": self.variable_version.id, + "value_char": "hello {{ test_version }} world", + } + ) + + pl = self.plan_line_1 + pl.write( + { + "variable_ids": [(6, 0, [self.variable_version.id])], + "condition": "if {{ test_version }} then", + } + ) + + self.assertIn(self.variable_version.id, pl.variable_ids.ids) + + self._rename() + val.invalidate_recordset() + pl.invalidate_recordset() + + actual_ref = self.variable_version.reference + expected_val = f"hello {{{{ {actual_ref} }}}} world" + self.assertEqual(val.value_char, expected_val) + expected_cond = f"if {{{{ {actual_ref} }}}} then" + self.assertEqual(pl.condition, expected_cond) + + def test_variable_reference_update(self): + """Test variable reference update cascades to dependent models""" + # 1. Add a variable value to variable_os + variable_value = self.VariableValue.create( + { + "variable_id": self.variable_os.id, + "value_char": "Ubuntu 20.04", + "server_id": self.server_test_1.id, + } + ) + + # Store original references for comparison + original_variable_reference = self.variable_os.reference + original_variable_value_reference = variable_value.reference + + # 2. Change the reference for variable_os to "awesome_variable" + self.variable_os.write({"reference": "awesome_variable"}) + + # 3. Verify that references are updated for dependent models + # Invalidate models to refresh all references + self.env["cx.tower.variable"].invalidate_model(["reference"]) + self.env["cx.tower.variable.value"].invalidate_model(["reference"]) + + # Check that variable reference was updated + self.assertEqual(self.variable_os.reference, "awesome_variable") + self.assertNotEqual(self.variable_os.reference, original_variable_reference) + + # Check that variable value reference was updated + # to include the new variable reference + self.assertIn("awesome_variable", variable_value.reference) + self.assertNotEqual(variable_value.reference, original_variable_value_reference) + + # Verify the reference pattern for variable value follows the expected format: + # ___ # noqa: E501 + expected_variable_pattern = ( + f"{self.variable_os.reference}_variable_value_server_" + f"{self.server_test_1.reference}" + ) + self.assertEqual(variable_value.reference, expected_variable_pattern) + + +class TestTowerVariableJet(TestTowerJetsCommon): + """Testing jet system variables with waypoint data.""" + + def test_system_variable_jet_type_values_with_waypoint(self): + """Test system variables of `jet` type with waypoint data""" + # Set waypoint as current waypoint for the jet + self.jet_test.waypoint_id = self.waypoint.id + + # Set waypoint metadata + self.waypoint.metadata = {"key1": "value1", "key2": "value2"} + + # Get system variable values + variable_values = self.Variable._get_system_variable_values(jet=self.jet_test) + + # Check waypoint data is included + self.assertIn( + "waypoint", variable_values["jet"], "Waypoint data should be included" + ) + waypoint_data = variable_values["jet"]["waypoint"] + + # Check waypoint reference and type + self.assertEqual( + waypoint_data["reference"], + self.waypoint.reference, + "Waypoint reference should match", + ) + self.assertEqual( + waypoint_data["type"], + self.waypoint_template.reference, + "Waypoint type should match template reference", + ) + + # Check metadata is included + self.assertEqual( + waypoint_data["key1"], + "value1", + "Waypoint metadata key1 should match", + ) + self.assertEqual( + waypoint_data["key2"], + "value2", + "Waypoint metadata key2 should match", + ) + + def test_system_variable_jet_type_values_without_waypoint(self): + """Test system variables of `jet` type without waypoint""" + # Ensure jet has no waypoint + self.jet_test.waypoint_id = False + + # Get system variable values + variable_values = self.Variable._get_system_variable_values(jet=self.jet_test) + + # Check waypoint data is included but with False values + self.assertIn( + "waypoint", + variable_values["jet"], + "Waypoint data should be included even when jet has no waypoint", + ) + waypoint_data = variable_values["jet"]["waypoint"] + + # Check waypoint reference and type are False + self.assertFalse( + waypoint_data["reference"], + "Waypoint reference should be False when jet has no waypoint", + ) + self.assertFalse( + waypoint_data["type"], + "Waypoint type should be False when jet has no waypoint", + ) + + def test_system_variable_jet_type_values_with_waypoint_empty_metadata(self): + """Test system variables of `jet` type with waypoint but empty metadata""" + # Set waypoint as current waypoint for the jet + self.jet_test.waypoint_id = self.waypoint.id + + # Set waypoint metadata to empty dict + self.waypoint.metadata = {} + + # Get system variable values + variable_values = self.Variable._get_system_variable_values(jet=self.jet_test) + + # Check waypoint data is included + self.assertIn( + "waypoint", variable_values["jet"], "Waypoint data should be included" + ) + waypoint_data = variable_values["jet"]["waypoint"] + + # Check that only reference and type are present (no metadata keys) + self.assertEqual( + len(waypoint_data), + 2, + "Waypoint data should only contain reference" + " and type when metadata is empty", + ) + self.assertIn( + "reference", waypoint_data, "Waypoint reference should be present" + ) + self.assertIn("type", waypoint_data, "Waypoint type should be present") diff --git a/addons/cetmix_tower_server/tests/test_variable_option.py b/addons/cetmix_tower_server/tests/test_variable_option.py new file mode 100644 index 0000000..a833af6 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_variable_option.py @@ -0,0 +1,285 @@ +from odoo.exceptions import AccessError, ValidationError + +from .common import TestTowerCommon + + +class TestTowerVariableOption(TestTowerCommon): + """Test case class to validate the behavior of + 'cx.tower.variable.option' model. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.variable_odoo_versions = cls.Variable.create( + { + "name": "odoo_versions", + "variable_type": "o", + } + ) + + cls.variable_option_17_0 = cls.VariableOption.create( + { + "name": "17.0", + "value_char": "17.0", + "variable_id": cls.variable_odoo_versions.id, + } + ) + + cls.variable_option_18_0 = cls.VariableOption.create( + { + "name": "18.0", + "value_char": "18.0", + "variable_id": cls.variable_odoo_versions.id, + } + ) + + # Create additional test users + cls.manager2 = cls.Users.create( + { + "name": "Manager 2", + "login": "manager2@example.com", + "groups_id": [(4, cls.group_manager.id)], + } + ) + + # Create variables with different access levels + cls.variable_level_1 = cls.Variable.create( + { + "name": "Level 1 Variable", + "access_level": "1", + } + ) + + cls.variable_level_2 = cls.Variable.create( + { + "name": "Level 2 Variable", + "access_level": "2", + } + ) + + # Create options with different access levels (inherited from variables) + cls.option_level_1 = cls.VariableOption.create( + { + "name": "Option Level 1", + "value_char": "value1", + "variable_id": cls.variable_level_1.id, + } + ) + + cls.option_level_2 = cls.VariableOption.create( + { + "name": "Option Level 2", + "value_char": "value2", + "variable_id": cls.variable_level_2.id, + } + ) + + def test_variable_value_set_from_option(self): + """Test that a variable value can be set from an option.""" + + variable_value = self.VariableValue.create( + { + "server_id": self.server_test_1.id, + "variable_id": self.variable_odoo_versions.id, + } + ) + + # -- 1 -- + # Set value_char to an existing option + variable_value.value_char = "17.0" + self.assertEqual( + variable_value.option_id, + self.variable_option_17_0, + ) + + # -- 2 -- + # Set value_char to a non-existing option + variable_meme_level = self.Variable.create( + { + "name": "meme_level", + "variable_type": "o", + } + ) + option_meme_level_high = self.VariableOption.create( + { + "name": "high", + "value_char": "high", + "variable_id": variable_meme_level.id, + } + ) + with self.assertRaises(ValidationError): + variable_value.option_id = option_meme_level_high + + # -- 3 -- + # Set value_char to a non-existing option + variable_value.value_char = "29.0" + self.assertFalse(variable_value.option_id) + + def test_access_level_consistency(self): + """Test that variable option access level cannot be lower + than variable access level.""" + + # Create a variable with access level "2" + variable_restricted = self.Variable.create( + { + "name": "restricted_variable", + "variable_type": "o", + "access_level": "2", + } + ) + + # Should succeed: option with same access level as variable + try: + self.VariableOption.create( + { + "name": "Option 1", + "value_char": "value1", + "variable_id": variable_restricted.id, + "access_level": "2", + } + ) + except ValidationError: + self.fail("Should allow creating option with same access level as variable") + + # Should succeed: option with higher access level than variable + try: + self.VariableOption.create( + { + "name": "Option 2", + "value_char": "value2", + "variable_id": variable_restricted.id, + "access_level": "3", + } + ) + except ValidationError: + self.fail( + "Should allow creating option with higher access level than variable" + ) + + # Should fail: option with lower access level than variable + with self.assertRaises( + ValidationError, + msg="Should not allow creating option " + "with lower access level than variable", + ): + self.VariableOption.create( + { + "name": "Option 3", + "value_char": "value3", + "variable_id": variable_restricted.id, + "access_level": "1", + } + ) + + # Test updating existing option's access level + option = self.VariableOption.create( + { + "name": "Option 4", + "value_char": "value4", + "variable_id": variable_restricted.id, + "access_level": "2", + } + ) + + # Should fail: updating to lower access level than variable + with self.assertRaises( + ValidationError, + msg="Should not allow updating option to lower access level than variable", + ): + option.write({"access_level": "1"}) + + # Should succeed: updating to higher access level than variable + try: + option.write({"access_level": "3"}) + except ValidationError: + self.fail( + "Should allow updating option to higher access level than variable" + ) + + def test_variable_option_access_rights(self): + """ + Test access rights for variable options + based on access levels and user roles. + """ + + # Test User Access + # --------------- + # Should see level 1 options only + records = self.VariableOption.with_user(self.user).search( + [("id", "in", [self.option_level_1.id, self.option_level_2.id])] + ) + self.assertEqual(len(records), 1, "User should only see level 1 options") + self.assertEqual( + records.id, self.option_level_1.id, "User should only see level 1 options" + ) + + # Test Manager Access + # ----------------- + # Should see level 1 and 2 options + records = self.VariableOption.with_user(self.manager).search( + [("id", "in", [self.option_level_1.id, self.option_level_2.id])] + ) + self.assertEqual(len(records), 2, "Manager should see level 1 and 2 options") + self.assertIn( + self.option_level_1.id, records.ids, "Manager should see level 1 options" + ) + self.assertIn( + self.option_level_2.id, records.ids, "Manager should see level 2 options" + ) + + # Test Manager Write Access + # ----------------------- + # Create an option as manager + manager_option = self.VariableOption.with_user(self.manager).create( + { + "name": "Manager Created Option", + "value_char": "manager_value", + "variable_id": self.variable_level_2.id, + } + ) + + # Manager should be able to modify their own option + try: + manager_option.with_user(self.manager).write({"name": "Updated Name"}) + except AccessError: + self.fail("Manager should be able to modify their own options") + + # Manager should not be able to modify another manager's option + manager2_option = self.VariableOption.with_user(self.manager2).create( + { + "name": "Other Manager Option", + "value_char": "other_value", + "variable_id": self.variable_level_2.id, + } + ) + + with self.assertRaises(AccessError): + manager2_option.with_user(self.manager).write({"name": "Try Update"}) + + # Test Root Access + # -------------- + # Root should see all options + records = self.VariableOption.with_user(self.root).search( + [("id", "in", [self.option_level_1.id, self.option_level_2.id])] + ) + self.assertEqual(len(records), 2, "Root should see all options") + + # Root should be able to create any option + try: + self.VariableOption.with_user(self.root).create( + { + "name": "Root Created Option", + "value_char": "root_value", + "variable_id": self.variable_level_2.id, + } + ) + except AccessError: + self.fail("Root should be able to create any option") + + # Root should be able to modify any option + try: + self.option_level_2.with_user(self.root).write({"name": "Updated by Root"}) + except AccessError: + self.fail("Root should be able to modify any option") diff --git a/addons/cetmix_tower_server/tests/test_variable_value.py b/addons/cetmix_tower_server/tests/test_variable_value.py new file mode 100644 index 0000000..b1d85e9 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_variable_value.py @@ -0,0 +1,952 @@ +from odoo.exceptions import AccessError + +from . import common + + +class TestTowerVariableValue(common.TestTowerCommon): + """Testing variable values.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional test users + cls.user2 = cls.Users.create( + { + "name": "Test User 2", + "login": "test_user2", + "email": "test_user2@example.com", + "groups_id": [(6, 0, [cls.group_user.id])], + } + ) + + cls.manager2 = cls.Users.create( + { + "name": "Test Manager 2", + "login": "test_manager2", + "email": "test_manager2@example.com", + "groups_id": [(6, 0, [cls.group_manager.id])], + } + ) + + # Create variables with different access levels + cls.variable_level_1 = cls.Variable.create( + { + "name": "Level 1 Variable", + "access_level": "1", + } + ) + + cls.variable_level_2 = cls.Variable.create( + { + "name": "Level 2 Variable", + "access_level": "2", + } + ) + + # Create servers + cls.server_1 = cls.Server.create( + { + "name": "Test Server 1", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": cls.os_debian_10.id, + "user_ids": [(4, cls.user.id)], + "manager_ids": [(4, cls.manager.id)], + } + ) + + cls.server_2 = cls.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": cls.os_debian_10.id, + "user_ids": [(4, cls.user2.id)], + "manager_ids": [(4, cls.manager2.id)], + } + ) + + # Create test command + cls.test_command = cls.Command.create( + { + "name": "Test Command", + "code": "echo 'test'", + } + ) + + # Create flight plan and its components + cls.test_plan = cls.Plan.create( + { + "name": "Test Plan", + "user_ids": [(4, cls.user.id)], + "manager_ids": [(4, cls.manager.id)], + } + ) + + cls.test_plan_line = cls.plan_line.create( + { + "name": "Test Line", + "plan_id": cls.test_plan.id, + "command_id": cls.test_command.id, + } + ) + + cls.test_plan_line_action = cls.plan_line_action.create( + { + "name": "Test Action", + "line_id": cls.test_plan_line.id, + "condition": "==", + "value_char": "0", + "action": "n", + } + ) + + # Create variable values + cls.global_value_1 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_1.id, + "value_char": "global_value_1", + } + ) + + cls.global_value_2 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_2.id, + "value_char": "global_value_2", + } + ) + + cls.server_value_1 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_1.id, + "value_char": "server_value_1", + "server_id": cls.server_1.id, + } + ) + + cls.server_value_2 = cls.VariableValue.with_user(cls.manager).create( + { + "variable_id": cls.variable_level_2.id, + "value_char": "server_value_2", + "server_id": cls.server_1.id, + } + ) + + cls.plan_value_1 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_1.id, + "value_char": "plan_value_1", + "plan_line_action_id": cls.test_plan_line_action.id, + } + ) + + cls.plan_value_2 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_2.id, + "value_char": "plan_value_2", + "plan_line_action_id": cls.test_plan_line_action.id, + } + ) + + # Add server template setup + cls.server_template = cls.ServerTemplate.create( + { + "name": "Test Template", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": cls.os_debian_10.id, + "manager_ids": [ + (4, cls.manager.id) + ], # Only managers should have access + } + ) + + # Add template variable values + cls.template_value_1 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_1.id, + "value_char": "template_value_1", + "server_template_id": cls.server_template.id, + } + ) + + cls.template_value_2 = cls.VariableValue.with_user(cls.manager).create( + { + "variable_id": cls.variable_level_2.id, + "value_char": "template_value_2", + "server_template_id": cls.server_template.id, + } + ) + + # Add server to plan + cls.test_plan.write({"server_ids": [(4, cls.server_1.id)]}) + + # Create Jet Template + cls.jet_template = cls.JetTemplate.create( + { + "name": "Test Jet Template", + "server_ids": [(4, cls.server_1.id)], + "user_ids": [(4, cls.user.id)], + "manager_ids": [(4, cls.manager.id)], + } + ) + + # Create Jet Template variable values + cls.jet_template_value_1 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_1.id, + "value_char": "jet_template_value_1", + "jet_template_id": cls.jet_template.id, + } + ) + + cls.jet_template_value_2 = cls.VariableValue.with_user(cls.manager).create( + { + "variable_id": cls.variable_level_2.id, + "value_char": "jet_template_value_2", + "jet_template_id": cls.jet_template.id, + } + ) + + # Create Jet + cls.jet = cls.Jet.create( + { + "name": "Test Jet", + "jet_template_id": cls.jet_template.id, + "server_id": cls.server_1.id, + "user_ids": [(4, cls.user.id)], + "manager_ids": [(4, cls.manager.id)], + } + ) + + # Create Jet variable values + cls.jet_value_1 = cls.VariableValue.create( + { + "variable_id": cls.variable_level_1.id, + "value_char": "jet_value_1", + "jet_id": cls.jet.id, + } + ) + + cls.jet_value_2 = cls.VariableValue.with_user(cls.manager).create( + { + "variable_id": cls.variable_level_2.id, + "value_char": "jet_value_2", + "jet_id": cls.jet.id, + } + ) + + def test_variable_value_access_rights(self): + """ + Test access rights for variable values + based on access levels and user roles. + """ + + # Test User Access + # --------------- + user_values = self.VariableValue.with_user(self.user).search( + [ + ( + "id", + "in", + [ + self.global_value_1.id, + self.global_value_2.id, + self.server_value_1.id, + self.server_value_2.id, + self.plan_value_1.id, + self.plan_value_2.id, + ], + ) + ] + ) + + # User should see level 1 global values and level 1 values + # from their server/plan + self.assertEqual(len(user_values), 3) + self.assertIn(self.global_value_1.id, user_values.ids) + self.assertIn(self.server_value_1.id, user_values.ids) + self.assertIn(self.plan_value_1.id, user_values.ids) + + # User should not be able to create/write/unlink values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.user).create( + { + "variable_id": self.variable_level_1.id, + "value_char": "test", + "server_id": self.server_1.id, + } + ) + + with self.assertRaises(AccessError): + self.server_value_1.with_user(self.user).write({"value_char": "new_value"}) + + with self.assertRaises(AccessError): + self.server_value_1.with_user(self.user).unlink() + + # Test Manager Access + # ------------------ + manager_values = self.VariableValue.with_user(self.manager).search( + [ + ( + "id", + "in", + [ + self.global_value_1.id, + self.global_value_2.id, + self.server_value_1.id, + self.server_value_2.id, + self.plan_value_1.id, + self.plan_value_2.id, + ], + ) + ] + ) + + # Manager should see all level 1 and 2 values from their server/plan + self.assertEqual(len(manager_values), 6) + + # Manager should be able to create values for their server/plan + test_variable = self.Variable.create( + { + "name": "Test Variable", + "access_level": "2", + } + ) + try: + new_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, + "value_char": "manager_value", + "server_id": self.server_1.id, + } + ) + except AccessError: + self.fail("Manager should be able to create values for their server") + + # Manager should be able to modify values for their server/plan + try: + self.server_value_2.with_user(self.manager).write( + {"value_char": "updated_value"} + ) + except AccessError: + self.fail("Manager should be able to modify values for their server") + + # Manager should be able to delete their own values + try: + new_value.with_user(self.manager).unlink() + except AccessError: + self.fail("Manager should be able to delete their own values") + + # Manager should not be able to modify other manager's values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.manager).create( + { + "variable_id": self.variable_level_1.id, + "value_char": "test", + "server_id": self.server_2.id, + } + ) + + # Test Root Access + # --------------- + root_values = self.VariableValue.with_user(self.root).search( + [ + ( + "id", + "in", + [ + self.global_value_1.id, + self.global_value_2.id, + self.server_value_1.id, + self.server_value_2.id, + self.plan_value_1.id, + self.plan_value_2.id, + ], + ) + ] + ) + + # Root should see all values + self.assertEqual(len(root_values), 6) + + # Root should be able to create any value + try: + root_value = self.VariableValue.with_user(self.root).create( + { + "variable_id": self.variable_level_2.id, + "value_char": "root_value", + "server_id": self.server_2.id, + "access_level": "2", + } + ) + except AccessError: + self.fail("Root should be able to create any value") + + # Root should be able to modify any value + try: + self.server_value_2.with_user(self.root).write( + {"value_char": "root_updated"} + ) + except AccessError: + self.fail("Root should be able to modify any value") + + # Root should be able to delete any value + try: + root_value.with_user(self.root).unlink() + except AccessError: + self.fail("Root should be able to delete any value") + + def test_server_template_access(self): + """Test access rights for server template variable values""" + + # Test user access to template values + # (should see none since they don't have template access) + user_template_values = self.VariableValue.with_user(self.user).search( + [("server_template_id", "=", self.server_template.id)] + ) + self.assertEqual( + len(user_template_values), 0 + ) # Users can't see template values + + # Test manager access to template values + manager_template_values = self.VariableValue.with_user(self.manager).search( + [("server_template_id", "=", self.server_template.id)] + ) + self.assertEqual(len(manager_template_values), 2) + + # Create a new variable for testing manager create rights + test_variable = self.Variable.create( + { + "name": "Test Template Manager Variable", + "access_level": "2", + } + ) + + # Test manager create rights + new_template_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, # Use the new variable + "value_char": "new_template_value", + "server_template_id": self.server_template.id, + } + ) + self.assertTrue(new_template_value.exists()) + + # Test manager write rights + self.template_value_2.with_user(self.manager).write( + {"value_char": "updated_template_value"} + ) + self.assertEqual(self.template_value_2.value_char, "updated_template_value") + + # Test manager unlink rights (only own records) + new_template_value.with_user(self.manager).unlink() + self.assertFalse(new_template_value.exists()) + + def test_server_template_manager_in_users_access(self): + """Test access rights for server template when manager is in user_ids only""" + + # Create new template with manager in user_ids only (not in manager_ids) + template_with_manager_user = self.ServerTemplate.create( + { + "name": "Template With Manager User", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + "user_ids": [(4, self.manager.id)], # Add manager to user_ids only + } + ) + + # Create test values as root to set up the test + template_value_1 = self.VariableValue.create( + { + "variable_id": self.variable_level_1.id, + "value_char": "manager_user_value_1", + "server_template_id": template_with_manager_user.id, + } + ) + + template_value_2 = self.VariableValue.create( + { + "variable_id": self.variable_level_2.id, + "value_char": "manager_user_value_2", + "server_template_id": template_with_manager_user.id, + } + ) + + # Test manager can read both level 1 and level 2 values + # (Manager Read rule allows access_level <= '2' when manager is in user_ids) + manager_values = self.VariableValue.with_user(self.manager).search( + [("server_template_id", "=", template_with_manager_user.id)] + ) + self.assertEqual(len(manager_values), 2) + self.assertIn(template_value_1.id, manager_values.ids) + self.assertIn(template_value_2.id, manager_values.ids) + + # Create a new variable for testing create access + test_variable = self.Variable.create( + { + "name": "Test Template User Variable", + "access_level": "1", + } + ) + + # Test manager cannot create values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, # Use the new variable + "value_char": "new_manager_user_value", + "server_template_id": template_with_manager_user.id, + } + ) + + # Test manager cannot write values + with self.assertRaises(AccessError): + template_value_1.with_user(self.manager).write( + {"value_char": "updated_manager_user_value"} + ) + + # Test manager cannot delete values + with self.assertRaises(AccessError): + template_value_1.with_user(self.manager).unlink() + + def test_plan_server_access(self): + """Test access rights for plan server variable values""" + + # Create a new variable for testing + test_variable = self.Variable.create( + { + "name": "Test Plan Server Variable", + "access_level": "2", + } + ) + + # Create variable value for plan server (only assign to server) + plan_server_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, + "value_char": "plan_server_value", + "server_id": self.server_1.id, + } + ) + + # Test user read access + user_plan_server_values = self.VariableValue.with_user(self.user).search( + [("server_id", "=", self.server_1.id), ("access_level", "=", "1")] + ) + self.assertTrue(user_plan_server_values) + + # Test manager read/write access + manager_plan_server_values = self.VariableValue.with_user(self.manager).search( + [("server_id", "=", self.server_1.id)] + ) + self.assertTrue(manager_plan_server_values) + + # Test manager write rights + plan_server_value.with_user(self.manager).write( + {"value_char": "updated_plan_server_value"} + ) + self.assertEqual(plan_server_value.value_char, "updated_plan_server_value") + + # Create another new variable for testing create rights + test_variable_2 = self.Variable.create( + { + "name": "Test Plan Server Variable 2", + "access_level": "2", + } + ) + + # Test manager create rights (only assign to server) + new_plan_server_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable_2.id, + "value_char": "new_plan_server_value", + "server_id": self.server_1.id, + } + ) + self.assertTrue(new_plan_server_value.exists()) + + # Test manager unlink rights (only own records) + new_plan_server_value.with_user(self.manager).unlink() + self.assertFalse(new_plan_server_value.exists()) + + # Test plan-specific variable values + test_variable_3 = self.Variable.create( + { + "name": "Test Plan Action Variable", + "access_level": "2", + } + ) + + # Create variable value for plan action + plan_action_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable_3.id, + "value_char": "plan_action_value", + "plan_line_action_id": self.test_plan_line_action.id, + } + ) + self.assertTrue(plan_action_value.exists()) + + # Test manager access to plan action values + manager_plan_values = self.VariableValue.with_user(self.manager).search( + [("plan_line_action_id", "=", self.test_plan_line_action.id)] + ) + self.assertIn(plan_action_value.id, manager_plan_values.ids) + + def test_jet_access(self): + """Test access rights for Jet variable values""" + + # Test user access to jet values + # User should see level 1 values from jets they're added to + user_jet_values = self.VariableValue.with_user(self.user).search( + [("jet_id", "=", self.jet.id)] + ) + self.assertEqual(len(user_jet_values), 1) + self.assertIn(self.jet_value_1.id, user_jet_values.ids) + + # User should not be able to create/write/unlink values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.user).create( + { + "variable_id": self.variable_level_1.id, + "value_char": "test", + "jet_id": self.jet.id, + } + ) + + with self.assertRaises(AccessError): + self.jet_value_1.with_user(self.user).write({"value_char": "new_value"}) + + with self.assertRaises(AccessError): + self.jet_value_1.with_user(self.user).unlink() + + # Test manager access to jet values + # Manager should see all level 1 and 2 values from jets they're added to + manager_jet_values = self.VariableValue.with_user(self.manager).search( + [("jet_id", "=", self.jet.id)] + ) + self.assertEqual(len(manager_jet_values), 2) + self.assertIn(self.jet_value_1.id, manager_jet_values.ids) + self.assertIn(self.jet_value_2.id, manager_jet_values.ids) + + # Create a new variable for testing manager create rights + test_variable = self.Variable.create( + { + "name": "Test Jet Manager Variable", + "access_level": "2", + } + ) + + # Test manager create rights (only when manager in jet manager_ids) + new_jet_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, + "value_char": "new_jet_value", + "jet_id": self.jet.id, + } + ) + self.assertTrue(new_jet_value.exists()) + + # Test manager write rights + self.jet_value_2.with_user(self.manager).write( + {"value_char": "updated_jet_value"} + ) + self.assertEqual(self.jet_value_2.value_char, "updated_jet_value") + + # Test manager unlink rights (only own records) + new_jet_value.with_user(self.manager).unlink() + self.assertFalse(new_jet_value.exists()) + + # Test manager cannot create values for jets they're not managers of + jet_without_manager = self.Jet.create( + { + "name": "Jet Without Manager", + "jet_template_id": self.jet_template.id, + "server_id": self.server_1.id, + "user_ids": [(4, self.user2.id)], + "manager_ids": [(4, self.manager2.id)], + } + ) + + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.manager).create( + { + "variable_id": self.variable_level_1.id, + "value_char": "test", + "jet_id": jet_without_manager.id, + } + ) + + def test_jet_manager_in_users_access(self): + """Test access rights for Jet when manager is in user_ids only""" + + # Create new jet with manager in user_ids only (not in manager_ids) + jet_with_manager_user = self.Jet.create( + { + "name": "Jet With Manager User", + "jet_template_id": self.jet_template.id, + "server_id": self.server_1.id, + "user_ids": [(4, self.manager.id)], # Add manager to user_ids only + "manager_ids": [(5, 0, 0)], + } + ) + + # Create test values as root to set up the test + jet_value_1 = self.VariableValue.create( + { + "variable_id": self.variable_level_1.id, + "value_char": "manager_user_value_1", + "jet_id": jet_with_manager_user.id, + } + ) + + jet_value_2 = self.VariableValue.create( + { + "variable_id": self.variable_level_2.id, + "value_char": "manager_user_value_2", + "jet_id": jet_with_manager_user.id, + } + ) + + # Test manager can read both level 1 and level 2 values + # (Manager Read rule allows access_level <= '2' when manager is in user_ids) + manager_values = self.VariableValue.with_user(self.manager).search( + [("jet_id", "=", jet_with_manager_user.id)] + ) + self.assertEqual(len(manager_values), 2) + self.assertIn(jet_value_1.id, manager_values.ids) + self.assertIn(jet_value_2.id, manager_values.ids) + + # Create a new variable for testing create access + test_variable = self.Variable.create( + { + "name": "Test Jet User Variable", + "access_level": "1", + } + ) + + # Test manager cannot create values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, + "value_char": "new_manager_user_value", + "jet_id": jet_with_manager_user.id, + } + ) + + # Test manager cannot write values + with self.assertRaises(AccessError): + jet_value_1.with_user(self.manager).write( + {"value_char": "updated_manager_user_value"} + ) + + # Test manager cannot delete values + with self.assertRaises(AccessError): + jet_value_1.with_user(self.manager).unlink() + + def test_jet_template_access(self): + """Test access rights for Jet Template variable values""" + + # Test user access to template values + # User should see level 1 values from jet templates they're added to + user_jet_template_values = self.VariableValue.with_user(self.user).search( + [("jet_template_id", "=", self.jet_template.id)] + ) + self.assertEqual(len(user_jet_template_values), 1) + self.assertIn(self.jet_template_value_1.id, user_jet_template_values.ids) + + # User should not be able to create/write/unlink values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.user).create( + { + "variable_id": self.variable_level_1.id, + "value_char": "test", + "jet_template_id": self.jet_template.id, + } + ) + + with self.assertRaises(AccessError): + self.jet_template_value_1.with_user(self.user).write( + {"value_char": "new_value"} + ) + + with self.assertRaises(AccessError): + self.jet_template_value_1.with_user(self.user).unlink() + + # Test manager access to template values + # Manager should see all level 1 and 2 values from jet templates + # they're added to + manager_jet_template_values = self.VariableValue.with_user(self.manager).search( + [("jet_template_id", "=", self.jet_template.id)] + ) + self.assertEqual(len(manager_jet_template_values), 2) + self.assertIn(self.jet_template_value_1.id, manager_jet_template_values.ids) + self.assertIn(self.jet_template_value_2.id, manager_jet_template_values.ids) + + # Create a new variable for testing manager create rights + test_variable = self.Variable.create( + { + "name": "Test Jet Template Manager Variable", + "access_level": "2", + } + ) + + # Test manager create rights (only when manager in template manager_ids) + new_jet_template_value = self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, + "value_char": "new_jet_template_value", + "jet_template_id": self.jet_template.id, + } + ) + self.assertTrue(new_jet_template_value.exists()) + + # Test manager write rights + self.jet_template_value_2.with_user(self.manager).write( + {"value_char": "updated_jet_template_value"} + ) + self.assertEqual( + self.jet_template_value_2.value_char, "updated_jet_template_value" + ) + + # Test manager unlink rights (only own records) + new_jet_template_value.with_user(self.manager).unlink() + self.assertFalse(new_jet_template_value.exists()) + + # Test manager cannot create values for templates they're not managers of + jet_template_without_manager = self.JetTemplate.create( + { + "name": "Template Without Manager", + "server_ids": [(4, self.server_1.id)], + "user_ids": [(4, self.user2.id)], + "manager_ids": [(4, self.manager2.id)], + } + ) + + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.manager).create( + { + "variable_id": self.variable_level_1.id, + "value_char": "test", + "jet_template_id": jet_template_without_manager.id, + } + ) + + def test_jet_template_manager_in_users_access(self): + """Test access rights for Jet Template when manager is in user_ids only""" + + # Create new template with manager in user_ids only (not in manager_ids) + template_with_manager_user = self.JetTemplate.create( + { + "name": "Template With Manager User", + "server_ids": [(4, self.server_1.id)], + "user_ids": [(4, self.manager.id)], # Add manager to user_ids only + "manager_ids": [(5, 0, 0)], + } + ) + + # Create test values as root to set up the test + template_value_1 = self.VariableValue.create( + { + "variable_id": self.variable_level_1.id, + "value_char": "manager_user_value_1", + "jet_template_id": template_with_manager_user.id, + } + ) + + template_value_2 = self.VariableValue.create( + { + "variable_id": self.variable_level_2.id, + "value_char": "manager_user_value_2", + "jet_template_id": template_with_manager_user.id, + } + ) + + # Test manager can read both level 1 and level 2 values + # (Manager Read rule allows access_level <= '2' when manager is in user_ids) + manager_values = self.VariableValue.with_user(self.manager).search( + [("jet_template_id", "=", template_with_manager_user.id)] + ) + self.assertEqual(len(manager_values), 2) + self.assertIn(template_value_1.id, manager_values.ids) + self.assertIn(template_value_2.id, manager_values.ids) + + # Create a new variable for testing create access + test_variable = self.Variable.create( + { + "name": "Test Template User Variable", + "access_level": "1", + } + ) + + # Test manager cannot create values + with self.assertRaises(AccessError): + self.VariableValue.with_user(self.manager).create( + { + "variable_id": test_variable.id, + "value_char": "new_manager_user_value", + "jet_template_id": template_with_manager_user.id, + } + ) + + # Test manager cannot write values + with self.assertRaises(AccessError): + template_value_1.with_user(self.manager).write( + {"value_char": "updated_manager_user_value"} + ) + + # Test manager cannot delete values + with self.assertRaises(AccessError): + template_value_1.with_user(self.manager).unlink() + + def test_reference_pattern_global_server_template_action(self): + """Ensure model-scoped references follow the required pattern.""" + # Global + model_ref = self.VariableValue._get_model_generic_reference() + self.assertTrue(self.global_value_1.reference.endswith(f"_{model_ref}_global")) + + # Server + srv_model_ref = self.Server._get_model_generic_reference() + self.assertTrue( + self.server_value_1.reference.startswith( + f"{self.variable_level_1.reference}_{model_ref}_{srv_model_ref}_" + ) + ) + + # Server Template + tmpl_model_ref = self.ServerTemplate._get_model_generic_reference() + self.assertTrue( + self.template_value_1.reference.startswith( + f"{self.variable_level_1.reference}_{model_ref}_{tmpl_model_ref}_" + ) + ) + + # Plan Line Action + action_model_ref = self.plan_line_action._get_model_generic_reference() + self.assertTrue( + self.plan_value_1.reference.startswith( + f"{self.variable_level_1.reference}_{model_ref}_{action_model_ref}_" + ) + ) + + # Jet Template + jet_tmpl_model_ref = self.JetTemplate._get_model_generic_reference() + self.assertTrue( + self.jet_template_value_1.reference.startswith( + f"{self.variable_level_1.reference}_{model_ref}_{jet_tmpl_model_ref}_" + ) + ) + + # Jet + jet_model_ref = self.Jet._get_model_generic_reference() + self.assertTrue( + self.jet_value_1.reference.startswith( + f"{self.variable_level_1.reference}_{model_ref}_{jet_model_ref}_" + ) + ) diff --git a/addons/cetmix_tower_server/tests/test_vault_mixin.py b/addons/cetmix_tower_server/tests/test_vault_mixin.py new file mode 100644 index 0000000..d387342 --- /dev/null +++ b/addons/cetmix_tower_server/tests/test_vault_mixin.py @@ -0,0 +1,534 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from .common import TestTowerCommon + + +class TestVaultMixin(TestTowerCommon): + """Test vault mixin functionality.""" + + def test_vault_mixin_secret_fields(self): + """Test vault mixin functionality for secret fields + (host_key and ssh_password)""" + # Create a server with initial secret values + initial_password = "initial_password" + initial_host_key = "initial_host_key" + + server = self.Server.create( + { + "name": "Vault Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": initial_password, + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "host_key": initial_host_key, + "skip_host_key": False, + } + ) + + # Test 1: Verify initial values are stored in vault and accessible + # Read values using common way - should return placeholder + self.assertEqual( + server.ssh_password, + self.Server.SECRET_VALUE_PLACEHOLDER, + "ssh_password should return placeholder value when read normally", + ) + self.assertEqual( + server.host_key, + self.Server.SECRET_VALUE_PLACEHOLDER, + "host_key should return placeholder value when read normally", + ) + + # Read using _get_secret_values() - should return actual initial values + secret_values = server._get_secret_values() + self.assertIsNotNone(secret_values, "secret_values should not be None") + self.assertIn(server.id, secret_values, "Server ID should be in secret values") + + server_secrets = secret_values[server.id] + self.assertIn( + "ssh_password", server_secrets, "ssh_password should be in secret values" + ) + self.assertIn("host_key", server_secrets, "host_key should be in secret values") + + self.assertEqual( + server_secrets["ssh_password"], + initial_password, + "ssh_password should return initial value from vault", + ) + self.assertEqual( + server_secrets["host_key"], + initial_host_key, + "host_key should return initial value from vault", + ) + + # Read individual fields using _get_secret_value() + # should return initial values + retrieved_password = server._get_secret_value("ssh_password") + retrieved_host_key = server._get_secret_value("host_key") + + self.assertEqual( + retrieved_password, + initial_password, + "_get_secret_value should return correct initial ssh_password", + ) + self.assertEqual( + retrieved_host_key, + initial_host_key, + "_get_secret_value should return correct initial host_key", + ) + + # Test 2: Save new values to secret fields + new_password = "new_secure_password_123" + new_host_key = "new_host_key_456" + + server.write( + { + "ssh_password": new_password, + "host_key": new_host_key, + } + ) + + # Test 3: Read values using common way after update - should return placeholder + # Note: In Odoo, we need to re-read the record to see updated values + server = self.Server.browse(server.id) + self.assertEqual( + server.ssh_password, + self.Server.SECRET_VALUE_PLACEHOLDER, + "ssh_password should return placeholder value when read normally " + "after update", + ) + self.assertEqual( + server.host_key, + self.Server.SECRET_VALUE_PLACEHOLDER, + "host_key should return placeholder value when read normally " + "after update", + ) + + # Test 4: Read using _get_secret_values() after update + # should return new values + secret_values = server._get_secret_values() + self.assertIsNotNone( + secret_values, "secret_values should not be None after update" + ) + self.assertIn( + server.id, + secret_values, + "Server ID should be in secret values after update", + ) + + server_secrets = secret_values[server.id] + self.assertIn( + "ssh_password", + server_secrets, + "ssh_password should be in secret values after update", + ) + self.assertIn( + "host_key", + server_secrets, + "host_key should be in secret values after update", + ) + + self.assertEqual( + server_secrets["ssh_password"], + new_password, + "ssh_password should return new value from vault after update", + ) + self.assertEqual( + server_secrets["host_key"], + new_host_key, + "host_key should return new value from vault after update", + ) + + # Test 5: Read individual fields using _get_secret_value() after update + # Get both values in one call using _get_secret_values() + secret_values = server._get_secret_values() + self.assertIsNotNone( + secret_values, "secret_values should not be None for individual field test" + ) + self.assertIn( + server.id, + secret_values, + "Server ID should be in secret values for individual field test", + ) + + server_secrets = secret_values[server.id] + retrieved_password = server_secrets["ssh_password"] + retrieved_host_key = server_secrets["host_key"] + + self.assertEqual( + retrieved_password, + new_password, + "_get_secret_values should return correct new ssh_password after update", + ) + self.assertEqual( + retrieved_host_key, + new_host_key, + "_get_secret_values should return correct new host_key after update", + ) + + # Test 6: Verify that non-secret fields are not affected + self.assertEqual( + server.name, + "Vault Test Server", + "Non-secret field should not be affected by vault mixin", + ) + self.assertEqual( + server.ssh_username, + "admin", + "Non-secret field should not be affected by vault mixin", + ) + + def test_vault_mixin_create_with_secret_fields(self): + """Test vault mixin functionality when creating records with secret fields""" + # Create a server with secret fields + server = self.Server.create( + { + "name": "Create Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "create_password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "host_key": "create_host_key", + "skip_host_key": False, + } + ) + + # Verify secret fields are stored in vault and not in main table + self.assertEqual( + server.ssh_password, + self.Server.SECRET_VALUE_PLACEHOLDER, + "ssh_password should return placeholder after creation", + ) + self.assertEqual( + server.host_key, + self.Server.SECRET_VALUE_PLACEHOLDER, + "host_key should return placeholder after creation", + ) + + # Verify actual values are accessible via vault methods + secret_values = server._get_secret_values() + self.assertIn( + server.id, + secret_values, + "Server ID should be in secret values after creation", + ) + + server_secrets = secret_values[server.id] + self.assertEqual( + server_secrets["ssh_password"], + "create_password", + "ssh_password should be stored in vault after creation", + ) + self.assertEqual( + server_secrets["host_key"], + "create_host_key", + "host_key should be stored in vault after creation", + ) + + def test_vault_mixin_delete_secret_fields(self): + """Test vault mixin functionality when deleting secret field values""" + # Create a server with secret fields + server = self.Server.create( + { + "name": "Delete Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "delete_password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "host_key": "delete_host_key", + "skip_host_key": False, + } + ) + + # Verify initial values exist + secret_values = server._get_secret_values() + self.assertIn( + "ssh_password", + secret_values[server.id], + "ssh_password should exist initially", + ) + self.assertIn( + "host_key", secret_values[server.id], "host_key should exist initially" + ) + + # Delete secret field values + server.write( + { + "ssh_password": False, + "host_key": False, + } + ) + + # Verify values are removed from vault + secret_values = server._get_secret_values() + server_secrets = secret_values.get(server.id, {}) + + self.assertNotIn( + "ssh_password", server_secrets, "ssh_password should be removed from vault" + ) + self.assertNotIn( + "host_key", server_secrets, "host_key should be removed from vault" + ) + + # Verify normal field access still returns placeholders + server = self.Server.browse(server.id) + self.assertEqual( + server.ssh_password, + self.Server.SECRET_VALUE_PLACEHOLDER, + "ssh_password should return placeholder after deletion", + ) + self.assertEqual( + server.host_key, + self.Server.SECRET_VALUE_PLACEHOLDER, + "host_key should return placeholder after deletion", + ) + + def test_vault_mixin_bulk_create_with_secret_fields(self): + """Test vault mixin functionality when creating multiple servers with different + secret field configurations""" + placeholder = self.Server.SECRET_VALUE_PLACEHOLDER + # Create 3 servers with different secret field configurations + servers_data = [ + { + "name": "Server 1 - Both Fields", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password1", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "host_key": "host_key1", + "skip_host_key": False, + }, + { + "name": "Server 2 - Host Key Only", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_auth_mode": "k", + "os_id": self.os_debian_10.id, + "host_key": "host_key2", + "skip_host_key": False, + "ssh_key_id": self.key_1.id, + }, + { + "name": "Server 3 - SSH Password Only", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password3", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "skip_host_key": True, + }, + ] + + # Create all servers in one call + servers = self.Server.create(servers_data) + + # Verify we have 3 servers + self.assertEqual(len(servers), 3, "Should have created 3 servers") + + # Test 1: Get values for all 3 servers regular way - should return placeholders + for server in servers: + self.assertEqual( + server.ssh_password, + placeholder, + f"Server {server.name} ssh_password should return placeholder " + f"when read normally", + ) + + self.assertEqual( + server.host_key, + placeholder, + f"Server {server.name} host_key should return placeholder " + f"when read normally", + ) + + # Test 2: Get values for all 3 servers at once using _get_secret_values() + all_secret_values = servers._get_secret_values() + self.assertIsNotNone(all_secret_values, "all_secret_values should not be None") + + # Verify Server 1 (both fields) + server1 = servers[0] + self.assertIn( + server1.id, all_secret_values, "Server 1 should be in secret values" + ) + server1_secrets = all_secret_values[server1.id] + + self.assertEqual( + server1_secrets.get("ssh_password"), + "password1", + "Server 1 ssh_password should be preserved correctly in vault", + ) + self.assertEqual( + server1_secrets.get("host_key"), + "host_key1", + "Server 1 host_key should be preserved correctly in vault", + ) + + # Verify Server 2 (host key only) + server2 = servers[1] + self.assertIn( + server2.id, all_secret_values, "Server 2 should be in secret values" + ) + server2_secrets = all_secret_values[server2.id] + + self.assertIsNone( + server2_secrets.get("ssh_password"), + "Server 2 should not have ssh_password in vault", + ) + self.assertEqual( + server2_secrets.get("host_key"), + "host_key2", + "Server 2 host_key should be preserved correctly in vault", + ) + + # Verify Server 3 (ssh password only) + server3 = servers[2] + self.assertIn( + server3.id, all_secret_values, "Server 3 should be in secret values" + ) + server3_secrets = all_secret_values[server3.id] + + self.assertEqual( + server3_secrets.get("ssh_password"), + "password3", + "Server 3 ssh_password should be preserved correctly in vault", + ) + self.assertIsNone( + server3_secrets.get("host_key"), + "Server 3 should not have host_key in vault", + ) + + # Test 3: Verify that non-secret fields are not affected + for server in servers: + self.assertIsNotNone( + server.name, + f"Server {server.id} name should not be affected by vault mixin", + ) + self.assertIsNotNone( + server.ssh_username, + f"Server {server.id} ssh_username should not be affected " + f"by vault mixin", + ) + self.assertIsNotNone( + server.ip_v4_address, + f"Server {server.id} ip_v4_address should not be affected " + f"by vault mixin", + ) + + # Test 4: Modify secret fields and verify changes are handled correctly + # Change the ssh password and remove the host key from Server 1 + server1 = servers.filtered(lambda s: s.name == "Server 1 - Both Fields") + server1.write( + { + "ssh_password": "updated_password1", + "host_key": False, + } + ) + + # Remove host key and add an ssh password in Server 2 + server2 = servers.filtered(lambda s: s.name == "Server 2 - Host Key Only") + server2.write( + { + "host_key": False, + "ssh_password": "new_password2", + } + ) + + # Remove ssh password from Server 3 + server3 = servers.filtered(lambda s: s.name == "Server 3 - SSH Password Only") + server3.write( + { + "ssh_password": False, + } + ) + + # Test 5: Get values for all 3 servers regular way after modifications + # Ensure that all values are replaced with placeholders + for server in servers: + self.assertEqual( + server.ssh_password, + placeholder, + f"Server {server.id} ssh_password should return placeholder " + f"after modifications", + ) + self.assertEqual( + server.host_key, + placeholder, + f"Server {server.id} host_key should return placeholder " + f"after modifications", + ) + + # Test 6: Get values for all 3 servers at once using _get_secret_values() + # Ensure that all values are preserved correctly after modifications + all_secret_values = servers._get_secret_values() + self.assertIsNotNone( + all_secret_values, + "all_secret_values should not be None after modifications", + ) + + # Verify Server 1 (updated password, no host key) + server1 = servers[0] + server1_secrets = all_secret_values[server1.id] + + self.assertEqual( + server1_secrets.get("ssh_password"), + "updated_password1", + "Server 1 ssh_password should be updated correctly in vault", + ) + self.assertIsNone( + server1_secrets.get("host_key"), + "Server 1 host_key should be removed from vault", + ) + + # Verify Server 2 (new password, no host key) + server2_secrets = all_secret_values[server2.id] + + self.assertEqual( + server2_secrets.get("ssh_password"), + "new_password2", + "Server 2 ssh_password should be added correctly in vault", + ) + self.assertIsNone( + server2_secrets.get("host_key"), + "Server 2 host_key should be removed from vault", + ) + + # Verify Server 3 (no ssh password, no host key) + # Server 3 should not be in the result since it has no secret values + self.assertNotIn( + server3.id, + all_secret_values, + "Server 3 should not be in secret values since it has no secret fields", + ) + + def test_is_secret_value_set(self): + """Test _is_secret_value_set returns True/False for host_key correctly.""" + server = self.Server.create( + { + "name": "Is Secret Set Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "ssh_auth_mode": "p", + "os_id": self.os_debian_10.id, + "host_key": "test_host_key_value", + "skip_host_key": False, + } + ) + + self.assertTrue( + server._is_secret_value_set("host_key"), + "host_key should be considered set when value exists in vault", + ) + + server.write({"host_key": False}) + server = self.Server.browse(server.id) + + self.assertFalse( + server._is_secret_value_set("host_key"), + "host_key should be considered not set when cleared", + ) diff --git a/addons/cetmix_tower_server/views/cx_tower_command_log_view.xml b/addons/cetmix_tower_server/views/cx_tower_command_log_view.xml new file mode 100644 index 0000000..1651b37 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_command_log_view.xml @@ -0,0 +1,211 @@ + + + + cx.tower.command.log.view.form + cx.tower.command.log + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + + cx.tower.command.log.view.list + cx.tower.command.log + + + + + + + + + + + + + + + + + + cx.tower.command.log.view.search + cx.tower.command.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Command Log + ir.actions.act_window + cetmix_tower_command_logs + cx.tower.command.log + list,form + {} + +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_command_view.xml b/addons/cetmix_tower_server/views/cx_tower_command_view.xml new file mode 100644 index 0000000..74bc546 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_command_view.xml @@ -0,0 +1,342 @@ + + + + cx.tower.command.view.form + cx.tower.command + +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + cx.tower.command.view.list + cx.tower.command + + + + + + + + + + + + + + + + + + cx.tower.command.view.search + cx.tower.command + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Command + ir.actions.act_window + cetmix_tower_commands + cx.tower.command + list,form + +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_file_template_view.xml b/addons/cetmix_tower_server/views/cx_tower_file_template_view.xml new file mode 100644 index 0000000..4df0d43 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_file_template_view.xml @@ -0,0 +1,163 @@ + + + + cx.tower.file.template.view.form + cx.tower.file.template + +
    + +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + cx.tower.file.template.view.list + cx.tower.file.template + + + + + + + + + + + + + + + cx.tower.file.template.view.search + cx.tower.file.template + + + + + + + + + + + + + + + + + + + Templates + cx.tower.file.template + cetmix_tower_file_templates + list,form + + +

    + Add a new file template +

    +
    +
    +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_file_view.xml b/addons/cetmix_tower_server/views/cx_tower_file_view.xml new file mode 100644 index 0000000..bf30bb6 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_file_view.xml @@ -0,0 +1,307 @@ + + + + cx.tower.file.view.form + cx.tower.file + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + cx.tower.file.view.list + cx.tower.file + + + + + + + + + + + + + + + + + + + + cx.tower.file.view.search + cx.tower.file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Files + cx.tower.file + cetmix_tower_files + list,form + + [] + {} + +

    + Add a new file +

    +
    +
    + + + Upload + + + code + action = records.action_push_to_server() + + + + Download + + + code + action = records.action_pull_from_server() + + + + Delete from server + + + code + action = records.action_delete_from_server() + + +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_jet_action_view.xml b/addons/cetmix_tower_server/views/cx_tower_jet_action_view.xml new file mode 100644 index 0000000..43bab92 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_jet_action_view.xml @@ -0,0 +1,41 @@ + + + + + cx.tower.jet.action.form + cx.tower.jet.action + +
    + +
    +

    + +

    +
    + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_jet_request_view.xml b/addons/cetmix_tower_server/views/cx_tower_jet_request_view.xml new file mode 100644 index 0000000..67c9cec --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_jet_request_view.xml @@ -0,0 +1,91 @@ + + + + + cx.tower.jet.request.list + cx.tower.jet.request + + + + + + + + + + + + + + + + cx.tower.jet.request.search + cx.tower.jet.request + + + + + + + + + + + + + + + + + + + + + + Jet Requests + cx.tower.jet.request + list + +

    + No jet requests found +

    +

    + Jet requests will appear here when jets request resources from templates. +

    +
    +
    +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_jet_state_view.xml b/addons/cetmix_tower_server/views/cx_tower_jet_state_view.xml new file mode 100644 index 0000000..ff30ae9 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_jet_state_view.xml @@ -0,0 +1,87 @@ + + + + + cx.tower.jet.state.form + cx.tower.jet.state + +
    + + +
    +

    + +

    +
    + + + + + + + + + + + + +
    +
    +
    +
    + + + + cx.tower.jet.state.list + cx.tower.jet.state + + + + + + + + + + + + + + cx.tower.jet.state.search + cx.tower.jet.state + + + + + + + + + + + + Jet States + cx.tower.jet.state + list,form + +

    + Create your first Jet State! +

    +

    + Jet States represent the different states a jet can be in during its lifecycle. +

    +
    +
    +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_jet_template_install_view.xml b/addons/cetmix_tower_server/views/cx_tower_jet_template_install_view.xml new file mode 100644 index 0000000..318a797 --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_jet_template_install_view.xml @@ -0,0 +1,176 @@ + + + + + cx.tower.jet.template.install.list + cx.tower.jet.template.install + + + + + + + + + + + + + + + cx.tower.jet.template.install.form + cx.tower.jet.template.install + +
    +
    + +
    + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + + cx.tower.jet.template.install.search + cx.tower.jet.template.install + + + + + + + + + + + + + + + + + + + + + + + + + + Template Installations + cx.tower.jet.template.install + list,form + {'search_default_processing': 1} + +

    + No template installations found! +

    +

    + Template installations are created automatically when you install jet templates on servers. +

    +
    +
    +
    diff --git a/addons/cetmix_tower_server/views/cx_tower_jet_template_view.xml b/addons/cetmix_tower_server/views/cx_tower_jet_template_view.xml new file mode 100644 index 0000000..bec7c0c --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_jet_template_view.xml @@ -0,0 +1,583 @@ + + + + + cx.tower.jet.template.form + cx.tower.jet.template + +
    +
    +
    + +
    + +
    + + +
    +

    + +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      +
    • Action with an initial state can be triggered only from that state.
    • +
    • Action without an initial state can be triggered from any state. Such actions can be used to create a new Jet.
    • +
    • Action without a final state do not change the state. Such actions can be used to destroy a Jet.
    • +
    • You need to save your changes to be able to select newly added actions in the fields below.
    • +
    +

    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +

    + +

    +
    + + + + + + + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +

    + +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +

    + +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +

    + +

    +
    + + + + + + + + +
    +
    +
    +
    + + + + + + + +
    + + + + +
    +
    +
    + + + + + + + +
    + + + + +
    +
    +
    +
    + + + +
    + + + + cx.tower.server.template.view.search + cx.tower.server.template + + + + + + + + + + + + + + + + + + + + + Server Templates + ir.actions.act_window + cx.tower.server.template + cetmix_tower_server_templates + kanban,list,form + + +

    Add a new server template

    +
    +
    + diff --git a/addons/cetmix_tower_server/views/cx_tower_server_view.xml b/addons/cetmix_tower_server/views/cx_tower_server_view.xml new file mode 100644 index 0000000..3baa27e --- /dev/null +++ b/addons/cetmix_tower_server/views/cx_tower_server_view.xml @@ -0,0 +1,604 @@ + + + + cx.tower.server.view.kanban + cx.tower.server + + + + + + + + + + + + +
    +
    +
    +
    + Command +
    + +
    +
    +
    + +
    +
    +
    + +
    + + + + +
    +
    + +
    +
      +
    • + Partner: + +
    • +
    • + Operating System: + +
    • +
    • + IPv4: + +
    • +
    • + IPv6: + +
    • +
    +
    + +
    +
    +
    +
    +
    +
    + + + cx.tower.server.view.list + cx.tower.server + + +
    +
    + + + + + + +
    +
    +
    + + + cx.tower.server.view.form + cx.tower.server + +
    +
    +
    + +
    + + +
    + +
    + +

    + + +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + +
    +
    +
    + + + + +
    +
    + +
    +
    + + + cx.tower.variable.view.list + cx.tower.variable + + + + + + + + + + + + + cx.tower.variable.view.search + cx.tower.variable + + + + + + + + + + + + + + Variables + ir.actions.act_window + cetmix_tower_variables + cx.tower.variable + list,form + +
    diff --git a/addons/cetmix_tower_server/views/menuitems.xml b/addons/cetmix_tower_server/views/menuitems.xml new file mode 100644 index 0000000..b9c1aaf --- /dev/null +++ b/addons/cetmix_tower_server/views/menuitems.xml @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/cetmix_tower_server/views/res_config_settings.xml b/addons/cetmix_tower_server/views/res_config_settings.xml new file mode 100644 index 0000000..425d23b --- /dev/null +++ b/addons/cetmix_tower_server/views/res_config_settings.xml @@ -0,0 +1,113 @@ + + + + + res.config.settings.view.form.inherit.cetmix.tower.settings + + res.config.settings + + +
    + + + + +
    +
    +
    + +
    + Pull files from server +
    +
    + Files will be pulled from server to Tower automatically using cron job. +
    +
    +
    +
    + +
    + Run scheduled tasks +
    +
    + Scheduled tasks will be run automatically using cron job. +
    +
    +
    +
    +
    + + + + + + + + +
    +
    +
    +
    + + + + General Settings + ir.actions.act_window + res.config.settings + + cetmix_tower_settings + form + inline + {'module' : 'cetmix_tower_server', 'bin_size': False} + +
    diff --git a/addons/cetmix_tower_server/views/res_partner_view.xml b/addons/cetmix_tower_server/views/res_partner_view.xml new file mode 100644 index 0000000..6689053 --- /dev/null +++ b/addons/cetmix_tower_server/views/res_partner_view.xml @@ -0,0 +1,82 @@ + + + + res.partner.form.inherit.cetmix.tower + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + res.partner.select.inherit.cetmix.tower + res.partner + + + + + + + + + + + + + Partners + ir.actions.act_window + res.partner + {'search_default_filter_with_servers': 1} + kanban,list,form,activity + +

    Add a new partner

    +
    +
    +
    diff --git a/addons/cetmix_tower_server/wizards/__init__.py b/addons/cetmix_tower_server/wizards/__init__.py new file mode 100644 index 0000000..0d9777b --- /dev/null +++ b/addons/cetmix_tower_server/wizards/__init__.py @@ -0,0 +1,9 @@ +from . import cx_tower_command_run_wizard +from . import cx_tower_plan_run_wizard +from . import cx_tower_server_template_create_wizard +from . import cx_tower_server_host_key_wizard +from . import cx_tower_jet_template_install_wizard +from . import cx_tower_jet_state_wizard +from . import cx_tower_jet_action_wizard +from . import cx_tower_jet_create_wizard +from . import cx_tower_jet_clone_wizard diff --git a/addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py new file mode 100644 index 0000000..d74f881 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard.py @@ -0,0 +1,564 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from ansi2html import Ansi2HTMLConverter + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, ValidationError + +from ..models.tools import generate_random_id + +html_converter = Ansi2HTMLConverter(inline=True) + + +class CxTowerCommandRunWizard(models.TransientModel): + """ + Wizard to run a command on selected servers. + """ + + _name = "cx.tower.command.run.wizard" + _inherit = "cx.tower.template.mixin" + _description = "Run Command in Wizard" + + server_ids = fields.Many2many( + "cx.tower.server", + string="Servers", + compute="_compute_server_ids", + readonly=False, + required=True, + store=True, + ) + jet_ids = fields.Many2many( + "cx.tower.jet", + string="Jets", + help="Jets to run the command on", + ) + command_id = fields.Many2one( + "cx.tower.command", + ) + note = fields.Text(related="command_id.note", readonly=True) + action = fields.Selection( + selection=[ + ("ssh_command", "SSH command"), + ("python_code", "Python code"), + ], + default="ssh_command", + required=True, + ) + path = fields.Char( + compute="_compute_code", + readonly=False, + store=True, + help="Put custom path to run the command.\n" + "IMPORTANT: this field does NOT support variables!", + ) + command_domain = fields.Binary( + compute="_compute_command_domain", + ) + tag_ids = fields.Many2many( + comodel_name="cx.tower.tag", + string="Tags", + ) + use_sudo = fields.Boolean( + string="Use sudo", + help="Will use sudo based on server settings." + "If no sudo is configured will run without sudo", + ) + code = fields.Text(compute="_compute_code", readonly=False, store=True) + applicability = fields.Selection( + selection=[ + ("this", "For selected server(s)"), + ("shared", "Non server restricted"), + ], + default="shared", + required=True, + compute="_compute_show_servers", + readonly=False, + store=True, + help="Selected server(s): only Commands that are specific" + " to the selected server(s)\n" + "Non server restricted: all Commands that are " + "not specific to any server", + ) + rendered_code = fields.Text( + compute="_compute_rendered_code", + compute_sudo=True, + ) + result = fields.Html() + show_servers = fields.Boolean( + compute="_compute_show_servers", + store=True, + ) + show_jets = fields.Boolean( + compute="_compute_show_jets", + compute_sudo=True, + ) + os_compatibility_warning = fields.Text( + compute="_compute_os_compatibility_warning", + compute_sudo=True, + help="Warning about OS compatibility of the command", + ) + command_variable_ids = fields.Many2many( + "cx.tower.variable", + related="command_id.variable_ids", + readonly=True, + string="Command Variables", + ) + custom_variable_value_ids = fields.One2many( + "cx.tower.command.run.wizard.variable.value", + "wizard_id", + ) + have_access_to_server = fields.Boolean( + compute="_compute_have_access_to_server", + ) + has_missing_required_values = fields.Boolean( + compute="_compute_has_missing_required_values" + ) + missing_required_variables_message = fields.Text( + compute="_compute_has_missing_required_values" + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + if not self._is_privileged_user(): + res["applicability"] = "this" + return res + + @api.depends("jet_ids") + def _compute_server_ids(self): + for rec in self: + if rec.jet_ids: + rec.server_ids = rec.jet_ids.server_id + + @api.depends("server_ids", "jet_ids") + def _compute_show_servers(self): + for rec in self: + rec.show_servers = ( + bool(rec.server_ids and len(rec.server_ids) > 1) + and not rec.jet_ids + and not rec.result + ) + + @api.depends("jet_ids") + def _compute_show_jets(self): + for rec in self: + rec.show_jets = bool(rec.jet_ids and len(rec.jet_ids) > 1) + + @api.depends("command_id", "server_ids", "action") + def _compute_code(self): + """ + Set code after change command + """ + for record in self: + if record.command_id and record.server_ids: + # Render code preview for the first server only. + record.update( + { + "code": record.command_id.code, + "path": record.server_ids[0] + ._render_command(record.command_id) + .get("rendered_path"), + } + ) + else: + record.update({"code": False, "path": False}) + + @api.depends("code", "server_ids", "action", "custom_variable_value_ids.value_char") + def _compute_rendered_code(self): + for record in self: + if record.server_ids and len(record.server_ids) == 1: + # Render code preview for the first server only. + if record.jet_ids: + server_id = record.jet_ids[0].server_id + else: + server_id = record.server_ids[0] + + # Get variable list + variables = record.get_variables() + + # Get variable values + variable_values = self.env[ + "cx.tower.variable" + ]._get_variable_values_by_references( + variables.get(str(record.id)), + server=server_id, + jet_template=record.jet_ids[0].jet_template_id + if record.jet_ids + else None, + jet=record.jet_ids[0] if record.jet_ids else None, + ) + if variable_values and record.custom_variable_value_ids: + custom_vals = { + custom_value.variable_id.reference: custom_value.value_char + for custom_value in record.custom_variable_value_ids + if custom_value.variable_id + } + variable_values.update(custom_vals) + + # Render template + if variable_values: + record.rendered_code = record.render_code( + pythonic_mode=record.action == "python_code", + **variable_values, + )[record.id] # pylint: disable=no-member + else: + record.rendered_code = record.code + else: + record.rendered_code = record.code + + @api.depends("applicability", "server_ids", "tag_ids", "action") + def _compute_command_domain(self): + """ + Compose domain based on condition + """ + for record in self: + domain = [("action", "=", record.action)] + if record.applicability == "shared": + domain.append(("server_ids", "=", False)) + elif record.applicability == "this": + domain.append(("server_ids", "in", record.server_ids.ids)) + if record.tag_ids: + domain.append(("tag_ids", "in", record.tag_ids.ids)) + record.command_domain = domain + + @api.depends("command_id", "server_ids") + def _compute_os_compatibility_warning(self): + for wizard in self: + # Skip if command is not SSH command or no OS compatibility is defined + if ( + not wizard.command_id + or not wizard.server_ids + or wizard.command_id.action != "ssh_command" + or not wizard.command_id.os_ids + ): + wizard.os_compatibility_warning = False + continue + warning_list = [] + for server in wizard.server_ids: + if server.os_id not in wizard.command_id.os_ids: + warning_list.append( + _( + "OS %(os)s used by the server '%(srv)s' is not present" + " in the command's OS compatibility list", + os=server.os_id.name, + srv=server.name, + ) + ) + wizard.os_compatibility_warning = ( + "\n".join(warning_list) if warning_list else False + ) + + @api.depends("server_ids") + def _compute_have_access_to_server(self): + """ + Compute have_access_to_server field + """ + for record in self: + if not record.server_ids: + record.have_access_to_server = False + continue + record.have_access_to_server = all( + server._have_access_to_server("write") for server in record.server_ids + ) + + @api.depends( + "custom_variable_value_ids.value_char", + "custom_variable_value_ids.required", + ) + def _compute_has_missing_required_values(self): + """ + Mark the wizard when at least one *required* variable + has an empty value **and** build a human-readable message. + """ + for wiz in self: + missing = wiz.custom_variable_value_ids.filtered( + lambda var_line: var_line.required and not var_line.value_char + ) + wiz.has_missing_required_values = bool(missing) + wiz.missing_required_variables_message = ( + _( + "Please provide values for the following " + "configuration variables: %(vars)s", + vars=", ".join(missing.mapped("variable_id.name")), + ) + if missing + else False + ) + + @api.onchange("action", "applicability") + def _onchange_action(self): + """ + Reset command after change action + """ + self.command_id = False + + @api.onchange("command_variable_ids", "server_ids") + def _onchange_command_variable_ids(self): + """ + Reset custom variable values after code change + """ + + self.ensure_one() + # Remove existing custom variable values + self.custom_variable_value_ids = False + + if ( + self.jet_ids + or not self.command_variable_ids + or not self.server_ids + or len(self.server_ids) > 1 + ): + return + + # Add new custom variable values + # Render values for the first server only. + server = self.server_ids[0] + + # Get variable list + variables = self.get_variables() + + # Get variable values + variable_values = self.env[ + "cx.tower.variable" + ]._get_variable_values_by_references( + variables.get(str(self.id)), + server=server._origin if hasattr(server, "_origin") else server, + ) + + # Filter variables current user has access to + command_variables = self.command_variable_ids.search( + [("id", "in", self.command_variable_ids.ids)] + ) + + self.custom_variable_value_ids = [ + ( + 0, + 0, + { + "variable_id": variable.id, + "value_char": variable_values.get(variable.reference), + "option_id": variable.option_ids.filtered( + lambda o, v=variable: o.value_char + == variable_values.get(v.reference) + ).id + if variable.variable_type == "o" + else None, + "variable_value_id": server.variable_value_ids.filtered( + lambda v, var=variable: v.variable_id == var + )[:1].id, + }, + ) + for variable in command_variables + ] + + def action_run_command(self): + """ + Return wizard action to select command and execute it + """ + context = self.env.context.copy() + if self.jet_ids: + context["default_jet_ids"] = self.jet_ids.ids + else: + context["default_server_ids"] = self.server_ids.ids + return { + "type": "ir.actions.act_window", + "name": _("Run Command"), + "res_model": "cx.tower.command.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def run_command_on_server(self): + """Run command on selected servers or jets""" + self.ensure_one() + + # Check if all required values are set + if self.has_missing_required_values: + raise ValidationError(self.missing_required_variables_message) + # Check if command is selected + if not self.command_id: + raise ValidationError(_("Please select a command to execute")) + # Generate custom label. Will be used later to locate the command log + log_label = generate_random_id(4) + path_value = ( + self.env.user.has_group("cetmix_tower_server.group_manager") and self.path + ) + # Add custom values for log + kwargs = { + "log": {"label": log_label}, + "variable_values": { + value.variable_id.reference: value.value_char + for value in self.custom_variable_value_ids + }, + } + if self.jet_ids: + for jet in self.jet_ids: + jet.run_command( + command=self.command_id, + sudo=self.use_sudo, + path=path_value, + **kwargs, + ) + else: + for server in self.server_ids: + server.run_command( + command=self.command_id, + sudo=self.use_sudo, + path=path_value, + **kwargs, + ) + return { + "type": "ir.actions.act_window", + "name": _("Command Log"), + "res_model": "cx.tower.command.log", + "view_mode": "list,form", + "target": "current", + "context": {"search_default_label": log_label}, + } + + def run_command_in_wizard(self): + """ + Runs a given code as is in wizard + """ + self.ensure_one() + + # Check if multiple servers are selected + if len(self.server_ids) > 1: + raise ValidationError( + _("You cannot run custom code on multiple servers at once.") + ) + + # Check if multiple jets are selected + if len(self.jet_ids) > 1: + raise ValidationError( + _("You cannot run custom code on multiple jets at once.") + ) + + # From now we have one server or one jet selected + # Raise access error if non manager is trying to call this method + if not self._is_privileged_user(): + raise AccessError(_("You are not allowed to execute commands in wizard")) + + # Check if jet is currently executing an action + if self.jet_ids and self.jet_ids.current_action_id: + raise ValidationError( + _( + "Jet '%(jet)s' is currently executing an action", + jet=self.jet_ids.display_name, + ) + ) + + if not self.command_id.allow_parallel_run: + running_count = ( + self.env["cx.tower.command.log"] + .sudo() + .search_count( + [ + ("server_id", "in", self.server_ids.ids), + ("command_id", "=", self.command_id.id), + ("is_running", "=", True), + ] + ) + ) + # Create log record and continue to the next one + # if the same command is currently running on the same server + # Log result + if running_count > 0: + raise ValidationError( + _("Another instance of the command is already running") + ) + + if not self.rendered_code: + raise ValidationError(_("You cannot execute an empty command")) + + # check that we can execute the command for selected servers + command_servers = self.command_id.server_ids + if command_servers and not all( + [server in command_servers for server in self.server_ids] + ): + raise ValidationError(_("Some servers don't support this command")) + + result = "" + + # Set the "no_split_for_sudo" property + no_split_for_sudo = bool(self.command_id and self.command_id.no_split_for_sudo) + + for server in self.server_ids: + server_name = server.name + # Prepare key renderer values + key_vals = { + "server_id": server.id, + "partner_id": server.partner_id.id if server.partner_id else None, + } + + kwargs = { + "key": key_vals, + "no_split_for_sudo": no_split_for_sudo, + "log": { + "jet_id": self.jet_ids and self.jet_ids[0].id + if self.jet_ids + else None, + "jet_template_id": self.jet_ids + and self.jet_ids[0].jet_template_id.id + if self.jet_ids + else None, + }, + } + + if self.action == "python_code": + command_result = server._run_python_code( + code=self.rendered_code, **kwargs + ) + else: + command_result = server._run_command_using_ssh( + server._get_ssh_client(raise_on_error=True), + self.rendered_code, + self.path or None, + sudo=self.use_sudo and server.use_sudo, + **kwargs, + ) + command_error = command_result["error"] + command_response = command_result["response"] + if command_error: + result = f"{result}\n[{server_name}]: ERROR: {command_error}" + if command_response: + result = f"{result}\n[{server_name}]: {command_response}" + if not result.endswith("\n"): + result = f"{result}\n" + + if result: + self.result = html_converter.convert(result) + return { + "type": "ir.actions.act_window", + "name": _("Run Result"), + "res_model": "cx.tower.command.run.wizard", + "res_id": self.id, # pylint: disable=no-member + "view_mode": "form", + "target": "new", + } + + def _is_privileged_user(self): + """Return True if current user is in Manager or Root group.""" + return self.env.user.has_group( + "cetmix_tower_server.group_manager" + ) or self.env.user.has_group("cetmix_tower_server.group_root") + + +class CxTowerCommandRunWizardVariableValue(models.TransientModel): + """ + Custom variable values for command run wizard + """ + + _inherit = "cx.tower.custom.variable.value.mixin" + _name = "cx.tower.command.run.wizard.variable.value" + _description = "Custom variable values for command run wizard" + + variable_id = fields.Many2one( + readonly=True, + ) + wizard_id = fields.Many2one( + "cx.tower.command.run.wizard", + string="Wizard", + ) diff --git a/addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard_view.xml new file mode 100644 index 0000000..83fbc5d --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_command_run_wizard_view.xml @@ -0,0 +1,213 @@ + + + + cx.tower.command.run.wizard.view.form + cx.tower.command.run.wizard + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + Cetmix Tower Run Command + cx.tower.command.run.wizard + ir.actions.act_window + form + + {'default_server_ids': [id]} + new + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard.py new file mode 100644 index 0000000..979c185 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard.py @@ -0,0 +1,59 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class CxTowerJetActionWizard(models.TransientModel): + """ + Wizard to trigger jet actions. + """ + + _name = "cx.tower.jet.action.wizard" + _description = "Trigger Jet Action Wizard" + + action_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + required=True, + domain="[('id', 'in', action_available_ids)]", + ) + + jet_ids = fields.Many2many( + comodel_name="cx.tower.jet", + readonly=True, + ) + + action_available_ids = fields.Many2many( + comodel_name="cx.tower.jet.action", + compute="_compute_available_actions", + help="Actions that are available for all selected jets", + ) + + @api.depends("jet_ids") + def _compute_available_actions(self): + """Compute available actions based on selected jets""" + for wizard in self: + if not wizard.jet_ids: + wizard.action_available_ids = False + continue + + # Get actions that are available to ALL selected jets + # Start with the first jet's available actions + first_jet = wizard.jet_ids[0] + available_actions = first_jet.action_available_ids + + # Intersect with actions available to all other jets + for jet in wizard.jet_ids[1:]: + available_actions &= jet.action_available_ids + + wizard.action_available_ids = available_actions + + def action_confirm(self): + """Trigger the action for the selected jets""" + for wizard in self: + if wizard.jet_ids and wizard.action_id: + for jet in wizard.jet_ids: + jet._trigger_action(wizard.action_id) + return { + "type": "ir.actions.act_window_close", + } diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard_view.xml new file mode 100644 index 0000000..b567c28 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_action_wizard_view.xml @@ -0,0 +1,44 @@ + + + + cx.tower.jet.action.wizard.view.form + cx.tower.jet.action.wizard + +
    + + + + + + +
    +
    +
    +
    +
    + + + + Trigger Action + cx.tower.jet.action.wizard + form + new + + list + {'default_jet_ids': active_ids} + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard.py new file mode 100644 index 0000000..0fd5acd --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard.py @@ -0,0 +1,140 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class CxTowerJetCloneWizard(models.TransientModel): + """Clone jet""" + + _name = "cx.tower.jet.clone.wizard" + _description = "Clone jet" + + jet_id = fields.Many2one( + "cx.tower.jet", + required=True, + readonly=True, + ) + jet_template_id = fields.Many2one( + "cx.tower.jet.template", + related="jet_id.jet_template_id", + readonly=True, + ) + same_server = fields.Selection( + selection=[("y", "Yes"), ("n", "No")], + default="y", + required=True, + ) + server_id = fields.Many2one( + "cx.tower.server", + domain="[('jet_template_ids', 'in', jet_template_id)]", + ) + partner_id = fields.Many2one( + "res.partner", + compute="_compute_partner_id", + store=True, + readonly=False, + help="Partner associated with the cloned jet", + ) + name = fields.Char(help="The name of the new jet") + name_type = fields.Selection( + selection=[("a", "will be auto-generated"), ("m", "I will put myself")], + default="a", + required=True, + ) + url = fields.Char(help="The URL of the jet") + url_type = fields.Selection( + selection=[("a", "will be auto-generated"), ("m", "I will put myself")], + default="a", + required=True, + ) + state_id = fields.Many2one( + "cx.tower.jet.state", required=True, help="Requested state of the jet" + ) + state_domain = fields.Binary(compute="_compute_state_domain") + use_custom_variables = fields.Selection( + selection=[("n", "default settings"), ("y", "custom settings")], + default="n", + required=True, + ) + line_ids = fields.One2many( + "cx.tower.jet.clone.wizard.variable.line", + "wizard_id", + string="Variable Lines", + ) + + @api.depends("jet_id") + def _compute_partner_id(self): + """ + Compute the partner associated with the cloned jet + """ + for wizard in self: + if wizard.partner_id: + continue + if wizard.jet_id and wizard.jet_id.partner_id: + wizard.partner_id = wizard.jet_id.partner_id.id + + @api.depends("jet_template_id") + def _compute_state_domain(self): + """ + Compute the domain for the states + """ + for wizard in self: + if not wizard.jet_id: + wizard.state_domain = [] + continue + wizard.state_domain = [ + ("id", "in", wizard.jet_template_id.action_ids.state_to_id.ids) + ] + + def action_confirm(self): + """ + Clone the jet + """ + self.ensure_one() + kwargs = {} + + # Add custom variables + custom_variables = {} + if self.line_ids: + custom_variables = { + line.variable_id.reference: line.value_char for line in self.line_ids + } + if custom_variables: + kwargs["variable_values"] = custom_variables + + # Add partner + if self.partner_id: + kwargs["partner_id"] = self.partner_id.id + + # Add url + if self.url_type == "m" and self.url: + kwargs["url"] = self.url + + jet = self.jet_id.clone( + server=self.server_id, + name=self.name, + state=self.state_id, + **kwargs, + ) + return { + "type": "ir.actions.act_window", + "res_model": "cx.tower.jet", + "res_id": jet.id, + "view_mode": "form", + "target": "current", + } + + +class CxTowerJetCloneWizardVariableLine(models.TransientModel): + """Custom variable values for jet create wizard""" + + _name = "cx.tower.jet.clone.wizard.variable.line" + _inherit = "cx.tower.custom.variable.value.mixin" + _description = "Variable lines" + + wizard_id = fields.Many2one("cx.tower.jet.clone.wizard") + # Override from mixin to make variable_id editable + variable_id = fields.Many2one( + readonly=False, + ) diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard_view.xml new file mode 100644 index 0000000..b7309bd --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_clone_wizard_view.xml @@ -0,0 +1,111 @@ + + + + cx.tower.jet.clone.wizard.view.form + cx.tower.jet.clone.wizard + +
    + + + + + + + + + + +
    +
    +
    +
    +
    + + + + Clone + cx.tower.jet.clone.wizard + form + new + + form + {'default_jet_id': active_id} + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py new file mode 100644 index 0000000..e49a632 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard.py @@ -0,0 +1,206 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerJetCreateWizard(models.TransientModel): + """Create new jet from template""" + + _name = "cx.tower.jet.create.wizard" + _description = "Create new jet" + + name = fields.Char(help="The name of the jet") + name_type = fields.Selection( + selection=[("a", "will be auto-generated"), ("m", "I will put myself")], + default="a", + required=True, + ) + note = fields.Text(related="jet_template_id.note", readonly=True) + url = fields.Char(help="The URL of the jet") + url_type = fields.Selection( + selection=[("a", "will be auto-generated"), ("m", "I will put myself")], + default="a", + required=True, + ) + partner_id = fields.Many2one( + "res.partner", + compute="_compute_partner_id", + store=True, + readonly=False, + help="Partner associated with the jet", + ) + jet_template_id = fields.Many2one( + "cx.tower.jet.template", + required=True, + ) + jet_template_domain = fields.Binary( + compute="_compute_jet_template_domain", + help="Domain for jet template", + ) + jet_template_message = fields.Text( + compute="_compute_jet_template_domain", + help="Message for the user", + ) + server_domain = fields.Binary( + compute="_compute_server_domain", + help="Domain for server", + ) + server_id = fields.Many2one( + "cx.tower.server", + ) + state_id = fields.Many2one("cx.tower.jet.state", help="Requested state of the jet") + state_domain = fields.Binary(compute="_compute_state_domain") + use_custom_variables = fields.Selection( + selection=[("n", "default settings"), ("y", "custom settings")], + default="n", + required=True, + ) + line_ids = fields.One2many( + "cx.tower.jet.create.wizard.variable.line", + "wizard_id", + string="Variable Lines", + ) + + @api.depends("server_id") + def _compute_partner_id(self): + """ + Compute the partner associated with the jet + """ + for wizard in self: + # Do not modify partner if it is already set + if wizard.partner_id: + continue + # Set partner from server + if wizard.server_id and wizard.server_id.partner_id: + wizard.partner_id = wizard.server_id.partner_id.id + + @api.depends("server_id") + def _compute_jet_template_domain(self): + """ + Compute the domain and message for the jet templates + """ + template_obj = self.env["cx.tower.jet.template"] + all_templates_domain = [("show_in_create_wizard", "=", True)] + all_templates = template_obj.search(all_templates_domain) + for wizard in self: + if not all_templates: + wizard.jet_template_message = _( + "No jet templates are currently configured as 'Show in Wizard'." + " Please check your jet template settings." + ) + wizard.jet_template_domain = all_templates_domain + continue + if not wizard.server_id: + # All templates that can be shown in the create wizard + jet_template_message = False + jet_template_domain = all_templates_domain + else: + # All templates that can be shown in the create wizard and + # are installed on the selected server + jet_template_domain = [ + ("show_in_create_wizard", "=", True), + ("server_ids", "in", wizard.server_id.ids), + ] + available_templates = all_templates.filtered_domain(jet_template_domain) + if not available_templates: + jet_template_message = _( + "No jet templates configured as 'Show in Wizard' are" + " installed on the selected server." + " Please check your jet template settings." + ) + else: + jet_template_message = False + + # Set the domain and message + wizard.jet_template_domain = jet_template_domain + wizard.jet_template_message = jet_template_message + + @api.depends("jet_template_id") + def _compute_server_domain(self): + """ + Compute the domain for the servers + """ + for wizard in self: + if not wizard.jet_template_id: + wizard.server_domain = [] + continue + wizard.server_domain = [("id", "in", wizard.jet_template_id.server_ids.ids)] + + @api.depends("jet_template_id") + def _compute_state_domain(self): + """ + Compute the domain for the states + """ + for wizard in self: + if not wizard.jet_template_id: + wizard.state_domain = [] + continue + wizard.state_domain = [ + ("id", "in", wizard.jet_template_id.action_ids.state_to_id.ids) + ] + + def action_confirm(self): + """ + Create a new jet + """ + self.ensure_one() + + # Check if server is selected + if not self.server_id: + raise ValidationError(_("Please select a server to create a jet.")) + + kwargs = {} + + # Add custom variables + variable_values = {} + if self.use_custom_variables == "y" and self.line_ids: + variable_values = { + line.variable_id.reference: line.value_char for line in self.line_ids + } + kwargs["variable_values"] = variable_values + + # Add partner + if self.partner_id: + kwargs["partner_id"] = self.partner_id.id + + # Add url + if self.url_type == "m" and self.url: + kwargs["url"] = self.url + + jet = self.jet_template_id.create_jet( + self.server_id, + name=self.name, + state=self.state_id, + **kwargs, + ) + if not jet: + raise ValidationError( + _( + "Failed to create jet. " + "Please check the server and template settings." + ) + ) + + return { + "type": "ir.actions.act_window", + "res_model": "cx.tower.jet", + "res_id": jet.id, + "view_mode": "form", + "target": "current", + } + + +class CxTowerJetCreateWizardVariableLine(models.TransientModel): + """Custom variable values for jet create wizard""" + + _name = "cx.tower.jet.create.wizard.variable.line" + _inherit = "cx.tower.custom.variable.value.mixin" + _description = "Variable lines" + + wizard_id = fields.Many2one("cx.tower.jet.create.wizard") + # Override from mixin to make variable_id editable + variable_id = fields.Many2one( + readonly=False, + ) diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard_view.xml new file mode 100644 index 0000000..2675f5d --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_create_wizard_view.xml @@ -0,0 +1,132 @@ + + + + cx.tower.jet.create.wizard.view.form + cx.tower.jet.create.wizard + +
    + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + Launch New Jet + cx.tower.jet.create.wizard + form + new + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard.py new file mode 100644 index 0000000..1b50e7b --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard.py @@ -0,0 +1,79 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class CxTowerJetStateWizard(models.TransientModel): + """ + Wizard to set state for selected jets. + """ + + _name = "cx.tower.jet.state.wizard" + _description = "Set Jet State Wizard" + + jet_ids = fields.Many2many( + comodel_name="cx.tower.jet", + string="Jets", + required=True, + readonly=True, + ) + + state_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + required=True, + domain="[('id', 'in', available_state_ids)]", + ) + + available_state_ids = fields.Many2many( + comodel_name="cx.tower.jet.state", + string="Available States", + compute="_compute_available_states", + help="States that appear in the 'state_to' field " + "of jet templates of all selected jets", + ) + + @api.depends("jet_ids", "jet_ids.jet_template_id.action_ids.state_to_id") + def _compute_available_states(self): + """Compute available states based on selected jets' templates""" + + # Used as a placeholder for no available states + state_obj = self.env["cx.tower.jet.state"] + + for wizard in self: + if not wizard.jet_ids: + wizard.available_state_ids = False + continue + + # Get states that are available to ALL selected jets + # Start with the first jet's available states + first_jet = wizard.jet_ids[0] + if not first_jet.jet_template_id.action_ids: + wizard.available_state_ids = False + continue + + available_states = first_jet.jet_template_id.action_ids.mapped( + "state_to_id" + ) + + # Intersect with states available to all other jets + for jet in wizard.jet_ids[1:]: + actions = jet.jet_template_id.action_ids + # If no actions, no available states + if not actions: + available_states = state_obj + break + jet_states = actions.mapped("state_to_id") + available_states &= jet_states + + # Remove current state from available states if only one jet is selected + if len(wizard.jet_ids) == 1: + available_states -= wizard.jet_ids.state_id + wizard.available_state_ids = available_states + + def action_confirm(self): + """Bring the jets to the target state""" + for wizard in self: + if wizard.jet_ids and wizard.state_id: + for jet in wizard.jet_ids: + jet.bring_to_state(wizard.state_id.reference) diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard_view.xml new file mode 100644 index 0000000..f8f1dd9 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_state_wizard_view.xml @@ -0,0 +1,51 @@ + + + + cx.tower.jet.state.wizard.view.form + cx.tower.jet.state.wizard + +
    + + + + + + + + +
    +
    + +
    +
    + + + + Bring to State + cx.tower.jet.state.wizard + form + new + + list + {'default_jet_ids': active_ids} + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard.py new file mode 100644 index 0000000..f53a636 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard.py @@ -0,0 +1,72 @@ +# Copyright 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from odoo import api, fields, models + + +class CxTowerJetTemplateInstallWizard(models.TransientModel): + """ + Wizard to install a Jet Template on selected servers. + """ + + _name = "cx.tower.jet.template.install.wiz" + _description = "Install Jet Template on Selected Servers" + + jet_template_id = fields.Many2one( + "cx.tower.jet.template", + required=True, + ) + server_ids = fields.Many2many( + "cx.tower.server", + string="Servers", + ) + jet_template_domain = fields.Binary( + compute="_compute_jet_template_domain", + ) + server_domain = fields.Binary( + compute="_compute_server_domain", + ) + + @api.depends("server_ids", "server_ids.jet_template_ids") + def _compute_jet_template_domain(self): + """ + Show only templates that are not installed on the selected server. + """ + for wizard in self: + if wizard.server_ids and len(wizard.server_ids) == 1: + server = wizard.server_ids[0] + templates_installed = server.jet_template_ids + wizard.jet_template_domain = [("id", "not in", templates_installed.ids)] + else: + wizard.jet_template_domain = [] + + @api.depends("jet_template_id", "jet_template_id.server_ids") + def _compute_server_domain(self): + """ + Show only servers where the template is not installed. + """ + for wizard in self: + if wizard.jet_template_id: + servers_installed = wizard.jet_template_id.server_ids + wizard.server_domain = ( + [("id", "not in", servers_installed.ids)] + if servers_installed + else [] + ) + else: + wizard.server_domain = [] + + def action_install_template(self): + """ + Install the Jet Template on the selected servers. + """ + if self.server_ids: + self.jet_template_id.install_on_servers(self.server_ids) + + # Close the wizard + return { + "type": "ir.actions.act_window_close", + "params": { + "next": {"type": "ir.actions.client", "tag": "soft_reload"}, + }, + } diff --git a/addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard_view.xml new file mode 100644 index 0000000..5f497e5 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_jet_template_install_wizard_view.xml @@ -0,0 +1,49 @@ + + + cx.tower.jet.template.install.wiz.form + cx.tower.jet.template.install.wiz + +
    + + + + + + +
    +
    + +
    +
    + + + Install on Servers + cx.tower.jet.template.install.wiz + ir.actions.act_window + form + + {'default_jet_template_id': active_id} + new + + form,list + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard.py new file mode 100644 index 0000000..4a17b08 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard.py @@ -0,0 +1,178 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models + +from ..models.tools import generate_random_id + + +class CxTowerPlanRunWizard(models.TransientModel): + """ + Wizard to run a flight plan on selected servers. + """ + + _name = "cx.tower.plan.run.wizard" + _description = "Run Flight Plan in Wizard" + + server_ids = fields.Many2many( + "cx.tower.server", + string="Servers", + required=True, + compute="_compute_server_ids", + readonly=False, + store=True, + ) + jet_ids = fields.Many2many( + "cx.tower.jet", + string="Jets", + ) + plan_id = fields.Many2one( + string="Flight Plan", + comodel_name="cx.tower.plan", + required=True, + ) + note = fields.Text(related="plan_id.note", readonly=True) + plan_domain = fields.Binary( + compute="_compute_plan_domain", + ) + tag_ids = fields.Many2many( + comodel_name="cx.tower.tag", + string="Tags", + ) + applicability = fields.Selection( + selection=[ + ("this", "For selected server(s)"), + ("shared", "Non server restricted"), + ], + default="shared", + required=True, + compute="_compute_show_servers", + readonly=False, + store=True, + help="Selected server(s): only Flight Plans that are specific" + " to the selected server(s)\n" + "Non server restricted: all Flight Plans that are " + "not specific to any server", + ) + # Lines + plan_line_ids = fields.One2many( + string="Commands", + comodel_name="cx.tower.plan.line", + compute="_compute_plan_line_ids", + compute_sudo=True, + groups="cetmix_tower_server.group_manager", + ) + show_servers = fields.Boolean( + compute="_compute_show_servers", + store=True, + ) + show_jets = fields.Boolean( + compute="_compute_show_jets", + compute_sudo=True, + ) + custom_variable_value_ids = fields.One2many( + "cx.tower.plan.run.wizard.variable.value", + "wizard_id", + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + if not self._is_privileged_user(): + res["applicability"] = "this" + return res + + @api.depends("jet_ids") + def _compute_server_ids(self): + for rec in self: + if rec.jet_ids: + rec.server_ids = rec.jet_ids.server_id + + @api.depends("server_ids") + def _compute_show_servers(self): + for rec in self: + rec.show_servers = ( + bool(rec.server_ids and len(rec.server_ids) > 1) and not rec.jet_ids + ) + + @api.depends("jet_ids") + def _compute_show_jets(self): + for rec in self: + rec.show_jets = bool(rec.jet_ids and len(rec.jet_ids) > 1) + + @api.depends("plan_id") + def _compute_plan_line_ids(self): + """Sel lines in wizard based on selected plan""" + for rec in self: + if rec.plan_id and rec.plan_id.line_ids: + rec.plan_line_ids = rec.plan_id.line_ids + else: + rec.plan_line_ids = None + + @api.depends("applicability", "server_ids", "tag_ids") + def _compute_plan_domain(self): + """Compose domain based on condition""" + for record in self: + domain = [] + if record.applicability == "shared": + domain = [("server_ids", "=", False)] + elif record.applicability == "this": + domain.append(("server_ids", "in", record.server_ids.ids)) + if record.tag_ids: + domain.append(("tag_ids", "in", record.tag_ids.ids)) + record.plan_domain = domain + + @api.onchange("applicability") + def _onchange_applicability(self): + """Reset plan after change record type""" + self.plan_id = False + + def run_flight_plan(self): + """Run flight plan for selected servers""" + + if self.plan_id and self.server_ids: + # Generate custom label. Will be used later to locate the command log + plan_label = generate_random_id(4) + # Add custom values for log + variable_values = { + value.variable_id.reference: value.value_char + for value in self.custom_variable_value_ids + } + custom_values = { + "plan_log": {"label": plan_label}, + "variable_values": variable_values, + } + if self.jet_ids: + for jet in self.jet_ids: + jet.run_flight_plan(self.plan_id, **custom_values) + else: + for server in self.server_ids: + server.run_flight_plan(self.plan_id, **custom_values) + return { + "type": "ir.actions.act_window", + "name": _("Plan Log"), + "res_model": "cx.tower.plan.log", + "view_mode": "list,form", + "target": "current", + "context": {"search_default_label": plan_label}, + } + + def _is_privileged_user(self): + """Return True if current user is in Manager or Root group.""" + return self.env.user.has_group( + "cetmix_tower_server.group_manager" + ) or self.env.user.has_group("cetmix_tower_server.group_root") + + +class CxTowerPlanRunWizardVariableValue(models.TransientModel): + """ + Custom variable values for flight plan run wizard + """ + + _inherit = "cx.tower.custom.variable.value.mixin" + _name = "cx.tower.plan.run.wizard.variable.value" + _description = "Custom variable values for plan run wizard" + + wizard_id = fields.Many2one( + "cx.tower.plan.run.wizard", + string="Wizard", + ) diff --git a/addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard_view.xml new file mode 100644 index 0000000..c165784 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_plan_run_wizard_view.xml @@ -0,0 +1,112 @@ + + + + cx.tower.plan.run.wizard.view.form + cx.tower.plan.run.wizard + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    + + Cetmix Tower Run Flight Plan + cx.tower.plan.run.wizard + ir.actions.act_window + form + + {'default_server_ids': [id]} + new + +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py new file mode 100644 index 0000000..043f94c --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard.py @@ -0,0 +1,30 @@ +# Copyright 2025 Cetmix Oy +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0). + +from odoo import _, fields, models + + +class CxTowerServerHostKeyWizard(models.TransientModel): + """Wizard to show host key""" + + _name = "cx.tower.server.host.key.wizard" + _description = "Show Host Key" + + is_error = fields.Boolean() + host_key = fields.Char() + server_id = fields.Many2one("cx.tower.server") + + def action_insert_host_key(self): + """Show the host key""" + self.ensure_one() + self.server_id.write({"host_key": self.host_key, "skip_host_key": False}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "type": "success", + "title": _("Host Key"), + "message": _("Key inserted successfully!"), + "next": {"type": "ir.actions.act_window_close"}, + }, + } diff --git a/addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard_view.xml new file mode 100644 index 0000000..9d6d9ec --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_server_host_key_wizard_view.xml @@ -0,0 +1,35 @@ + + + + + cx.tower.server.host.key.wizard.form + cx.tower.server.host.key.wizard + +
    + +
    + Check the key before inserting in the server settings. Do not insert the key if you have any doubts! +
    + + +
    +
    + +
    +
    +
    diff --git a/addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py b/addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py new file mode 100644 index 0000000..5693a75 --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard.py @@ -0,0 +1,247 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class CxTowerServerTemplateCreateWizard(models.TransientModel): + """Create new server from template""" + + _name = "cx.tower.server.template.create.wizard" + _description = "Create new server from template" + + server_template_id = fields.Many2one( + "cx.tower.server.template", + string="Server Template", + readonly=True, + ) + name = fields.Char( + string="Server Name", + required=True, + ) + partner_id = fields.Many2one( + "res.partner", + ) + color = fields.Integer(help="For better visualization in views") + os_id = fields.Many2one( + string="Operating System", + comodel_name="cx.tower.os", + ) + tag_ids = fields.Many2many( + comodel_name="cx.tower.tag", + string="Tags", + ) + ip_v4_address = fields.Char(string="IPv4 Address") + ip_v6_address = fields.Char(string="IPv6 Address") + ssh_port = fields.Integer(string="SSH port", default=22) + ssh_username = fields.Char( + string="SSH Username", + required=True, + help="This is required, however you can change this later " + "in the server settings", + ) + ssh_password = fields.Char(string="SSH Password") + ssh_key_id = fields.Many2one( + comodel_name="cx.tower.key", + string="SSH Private Key", + domain=[("key_type", "=", "k")], + ) + ssh_auth_mode = fields.Selection( + string="SSH Auth Mode", + selection=[ + ("p", "Password"), + ("k", "Key"), + ], + default="p", + required=True, + ) + use_sudo = fields.Selection( + string="Use sudo", + selection=[("n", "Without password"), ("p", "With password")], + help="Run commands using 'sudo'", + ) + host_key = fields.Char( + help="Host key to verify the server", + ) + skip_host_key = fields.Boolean( + string="Don't Check Key", + help="Enable to skip host key verification", + ) + line_ids = fields.One2many( + comodel_name="cx.tower.server.template.create.wizard.line", + inverse_name="wizard_id", + string="Configuration Variables", + ) + has_missing_required_values = fields.Boolean( + compute="_compute_has_missing_required_values", + ) + missing_required_variables = fields.Text( + compute="_compute_missing_required_variables_message", + ) + missing_required_variables_message = fields.Text( + compute="_compute_missing_required_variables_message", + ) + + @api.depends("line_ids.value_char", "line_ids.required") + def _compute_has_missing_required_values(self): + """ + Compute whether there are required variables with missing values. + """ + for wizard in self: + missing_vars = wizard.line_ids.filtered( + lambda line: line.required and not line.value_char + ) + wizard.has_missing_required_values = bool(missing_vars) + wizard.missing_required_variables = ", ".join( + missing_vars.mapped("variable_id.name") + ) + + @api.depends("has_missing_required_values") + def _compute_missing_required_variables_message(self): + """ + Computes the user-friendly message for missing required variables. + """ + for wizard in self: + if wizard.has_missing_required_values and wizard.missing_required_variables: + wizard.missing_required_variables_message = _( + "Please provide values for the following " + "configuration variables: %(variables)s", + variables=wizard.missing_required_variables, + ) + else: + wizard.missing_required_variables_message = False + + def action_confirm(self): + """ + Create and open new created server from template + """ + self.ensure_one() + + kwargs = self._prepare_server_parameters() + server = self.server_template_id._create_new_server( + self.name, pick_all_template_variables=False, **kwargs + ) + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_server" + ) + action.update( + {"view_mode": "form", "res_id": server.id, "views": [(False, "form")]} + ) + return action + + def _prepare_server_parameters(self): + """Prepare new server parameters + + Returns: + dict(): New server parameters + """ + res = { + "ip_v4_address": self.ip_v4_address, + "ip_v6_address": self.ip_v6_address, + "ssh_port": self.ssh_port, + "ssh_username": self.ssh_username, + "ssh_password": self.ssh_password, + "ssh_key_id": self.ssh_key_id.id, + "ssh_auth_mode": self.ssh_auth_mode, + "use_sudo": self.use_sudo, + "partner_id": self.partner_id.id, + "os_id": self.os_id.id, + "tag_ids": [(4, tag_id) for tag_id in self.tag_ids.ids], + "skip_host_key": self.skip_host_key, + "host_key": self.host_key if not self.skip_host_key else None, + } + if self.line_ids: + res.update( + { + "configuration_variables": { + line.variable_reference: line.value_char + for line in self.line_ids + }, + "configuration_variable_options": { + line.variable_reference: line.option_id.reference + for line in self.line_ids + if line.option_id + }, + } + ) + return res + + +class CxTowerServerTemplateCreateWizardVariableLine(models.TransientModel): + """Configuration variables""" + + _name = "cx.tower.server.template.create.wizard.line" + _description = "Create new server from template variables" + + wizard_id = fields.Many2one("cx.tower.server.template.create.wizard") + variable_value_id = fields.Many2one( + comodel_name="cx.tower.variable.value", + ) + variable_id = fields.Many2one( + comodel_name="cx.tower.variable", + compute="_compute_variable_id", + readonly=False, + store=True, + ) + variable_reference = fields.Char(related="variable_id.reference", readonly=True) + value_char = fields.Char( + string="Value", + compute="_compute_value_char", + readonly=False, + store=True, + ) + required = fields.Boolean( + related="variable_value_id.required", + help="Indicates if this variable is mandatory for server creation", + readonly=True, + store=True, + ) + variable_type = fields.Selection( + related="variable_id.variable_type", + readonly=True, + ) + option_id = fields.Many2one( + comodel_name="cx.tower.variable.option", + domain="[('variable_id', '=', variable_id)]", + readonly=False, + compute="_compute_variable_id", + store=True, + ) + + @api.depends("variable_value_id") + def _compute_variable_id(self): + for rec in self: + variable_value = rec.variable_value_id + if variable_value: + rec.update( + { + "variable_id": variable_value.variable_id.id, + "option_id": variable_value.option_id.id, + "value_char": variable_value.value_char, + } + ) + + @api.depends("option_id", "variable_id", "variable_type") + def _compute_value_char(self): + for rec in self: + if rec.variable_id and rec.variable_type == "o" and rec.option_id: + rec.value_char = rec.option_id.value_char + else: + rec.value_char = "" + + @api.onchange("variable_id") + def _onchange_variable_id(self): + """ + Reset option_id when variable changes. + """ + self.update({"option_id": None}) + + @api.onchange("value_char") + def _onchange_value_char(self): + """ + Check value before saving + """ + if self.variable_id: + valid, message = self.variable_id._validate_value(self.value_char) + if not valid: + return {"warning": {"title": _("Value is invalid"), "message": message}} diff --git a/addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard_view.xml b/addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard_view.xml new file mode 100644 index 0000000..cdee4ce --- /dev/null +++ b/addons/cetmix_tower_server/wizards/cx_tower_server_template_create_wizard_view.xml @@ -0,0 +1,94 @@ + + + + cx.tower.server.template.create.wizard.view.form + cx.tower.server.template.create.wizard + +
    +
    +

    + +

    +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +