Wipe addons/: full reset for clean re-upload
This commit is contained in:
@@ -1,358 +0,0 @@
|
||||
===================
|
||||
Cetmix Tower Server
|
||||
===================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:4e04c56ceb53a86825bfbc09ed6acd8bbcaa032e85cef16c43997437620f429d
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github
|
||||
:target: https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server
|
||||
:alt: cetmix/cetmix-tower
|
||||
|
||||
|badge1| |badge2| |badge3|
|
||||
|
||||
`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.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Please refer to the `official
|
||||
documentation <https://cetmix.com/tower>`__ for detailed configuration
|
||||
instructions.
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
Please refer to the `official
|
||||
documentation <https://cetmix.com/tower>`__ for detailed usage
|
||||
instructions.
|
||||
|
||||
Changelog
|
||||
=========
|
||||
|
||||
16.0.3.0.1 (2026-03-27)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Waypoint behavior improvements. (5313)
|
||||
|
||||
16.0.3.0.0 (2026-03-23)
|
||||
-----------------------
|
||||
|
||||
- Features: Jets! (4700)
|
||||
|
||||
16.0.2.2.14 (2026-02-17)
|
||||
------------------------
|
||||
|
||||
- Features: Blacklist filter for Python commands, value checker for
|
||||
Vault. (5253)
|
||||
|
||||
16.0.2.2.13 (2026-01-12)
|
||||
------------------------
|
||||
|
||||
- Bugfixes: Last flight plan line post-run action was not triggered
|
||||
correctly. (5120)
|
||||
|
||||
16.0.2.2.12 (2026-01-11)
|
||||
------------------------
|
||||
|
||||
- Features: Improve the 'File using template' command flow, fix the
|
||||
flight plan line view layout. (5197)
|
||||
|
||||
16.0.2.2.11 (2026-01-08)
|
||||
------------------------
|
||||
|
||||
- Bugfixes: Ensure custom values can be updated even if not provided
|
||||
initially. (5175)
|
||||
|
||||
16.0.2.2.10 (2026-01-08)
|
||||
------------------------
|
||||
|
||||
- Features: Scheduled tasks: allow to select specific days of week.
|
||||
(5190)
|
||||
|
||||
16.0.2.2.8 (2025-12-22)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Handle malformed expressions in flight plan line conditions.
|
||||
(5154)
|
||||
|
||||
16.0.2.2.7 (2025-12-16)
|
||||
-----------------------
|
||||
|
||||
- Features: Support for ANSI formatting in server logs. (5141)
|
||||
|
||||
- Bugfixes: UI/UX fixed and improvements. (5141)
|
||||
|
||||
16.0.2.2.6 (2025-12-11)
|
||||
-----------------------
|
||||
|
||||
- Features: Improve search views, implement the search panel for
|
||||
selected views. (5139)
|
||||
|
||||
16.0.2.2.5 (2025-12-10)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Custom values in flight plan are lost in a skipped command
|
||||
and are not available after it. (5129)
|
||||
|
||||
16.0.2.2.4 (2025-12-10)
|
||||
-----------------------
|
||||
|
||||
- Features: Parse empty or missing key values as 'None' instead of
|
||||
leaving key reference as is. (5134)
|
||||
|
||||
16.0.2.2.3 (2025-12-03)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Save correct error message in log when SSH connection fails.
|
||||
(5109)
|
||||
|
||||
16.0.2.2.2 (2025-12-03)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Make variables selectable in scheduled tasks (5105)
|
||||
|
||||
16.0.2.2.0 (2025-11-12)
|
||||
-----------------------
|
||||
|
||||
- Features: Integrate user notifications into the main module, drop the
|
||||
'cetmix_tower_notify_backend' module. (5074)
|
||||
|
||||
16.0.2.0.6 (2025-10-27)
|
||||
-----------------------
|
||||
|
||||
- Features: Tag mixin and helper commands. (5039)
|
||||
|
||||
16.0.2.0.5 (2025-10-16)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Flight plan command exception handling (4930)
|
||||
|
||||
16.0.2.0.4 (2025-10-13)
|
||||
-----------------------
|
||||
|
||||
- Features: Auto update references for related records (5005)
|
||||
|
||||
16.0.2.0.3 (2025-10-13)
|
||||
-----------------------
|
||||
|
||||
- Features: Terminate running flight plan manually (3410)
|
||||
|
||||
16.0.2.0.2 (2025-10-08)
|
||||
-----------------------
|
||||
|
||||
- Features: UI/UX improvements (4996)
|
||||
|
||||
- Bugfixes: Handle secret values when a record is duplicated using
|
||||
copy() (4996)
|
||||
|
||||
16.0.2.0.1 (2025-10-08)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Improve variable value references uniqueness (4961)
|
||||
|
||||
16.0.2.0.0 (2025-10-07)
|
||||
-----------------------
|
||||
|
||||
- Features: 'Cetmix Tower Vault' - new way of centralized password/key
|
||||
management (4824)
|
||||
|
||||
16.0.1.7.2 (2025-09-18)
|
||||
-----------------------
|
||||
|
||||
- Features: Set 'Auto Sync' in files from file templates (4949)
|
||||
|
||||
16.0.1.7.1 (2025-09-10)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Check custom values in flight plan line condition (4922)
|
||||
|
||||
16.0.1.6.4 (2025-08-18)
|
||||
-----------------------
|
||||
|
||||
- Features: Improve the extendability of the file upload command. (4759)
|
||||
|
||||
16.0.1.6.3 (2025-08-13)
|
||||
-----------------------
|
||||
|
||||
- Features: Improve access settings for logs (4866)
|
||||
|
||||
16.0.1.6.2 (2025-08-05)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Pin paramiko version to "<4" to maintain compatibility with
|
||||
legacy installations (4891)
|
||||
|
||||
16.0.1.6.0 (2025-07-30)
|
||||
-----------------------
|
||||
|
||||
- Features: Optional behaviour when file uploaded by command already
|
||||
exists on the server. (4740)
|
||||
|
||||
16.0.1.5.3 (2025-07-29)
|
||||
-----------------------
|
||||
|
||||
- Features: Make file references server dependent to be more unique
|
||||
(4870)
|
||||
|
||||
16.0.1.5.1 (2025-07-25)
|
||||
-----------------------
|
||||
|
||||
- Features: Select secrets from dropdown list in the code fields (4853)
|
||||
|
||||
16.0.1.5.0 (2025-07-22)
|
||||
-----------------------
|
||||
|
||||
- Features: Select variables from dropdown list in the code fields
|
||||
(4827)
|
||||
|
||||
16.0.1.3.0 (2025-07-17)
|
||||
-----------------------
|
||||
|
||||
- Features: Add the tldextract and dnspython libraries. (4737)
|
||||
|
||||
16.0.1.1.4 (2025-07-07)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Command log sorting (4816)
|
||||
|
||||
16.0.1.1.2 (2025-06-25)
|
||||
-----------------------
|
||||
|
||||
- Features: Required variables in servers (4779)
|
||||
|
||||
16.0.1.1.1 (2025-06-21)
|
||||
-----------------------
|
||||
|
||||
- Features: Command view improvements (4753)
|
||||
|
||||
16.0.1.1.0 (2025-06-20)
|
||||
-----------------------
|
||||
|
||||
- Features: Run commands and flight plans using scheduled tasks. (4650)
|
||||
|
||||
16.0.1.0.12 (2025-06-06)
|
||||
------------------------
|
||||
|
||||
- Features: Improve command and flight plan log management. (4749)
|
||||
|
||||
16.0.1.0.11 (2025-06-06)
|
||||
------------------------
|
||||
|
||||
- Bugfixes: Host key cannot be retrieved from the UI. (4747)
|
||||
|
||||
16.0.1.0.10 (2025-05-24)
|
||||
------------------------
|
||||
|
||||
- Features: Improve command log and flight plan form views (4697)
|
||||
|
||||
16.0.1.0.9 (2025-05-23)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Error when rendering a file not attached to a server. (4715)
|
||||
|
||||
16.0.1.0.8 (2025-05-21)
|
||||
-----------------------
|
||||
|
||||
- Features: References for secret values. (4696)
|
||||
- Features: Make the "Host key" field non-required in the form view to
|
||||
improve the UX. (4699)
|
||||
|
||||
16.0.1.0.7 (2025-05-16)
|
||||
-----------------------
|
||||
|
||||
- Features: Option to preserve command splitting when using sudo. (4641)
|
||||
|
||||
- Features: Record references for files. (4670)
|
||||
|
||||
- Features: Use ``sudo`` parameter to pass sudo mode to command runner
|
||||
instead of using context. (4678)
|
||||
|
||||
- Bugfixes: Incorrect sudo usage in commands run in wizard. Pass 'No
|
||||
split for sudo' property to commands run in wizard. (4679)
|
||||
|
||||
16.0.1.0.6 (2025-05-16)
|
||||
-----------------------
|
||||
|
||||
- Features: Improve the key storage functionality. (4686)
|
||||
|
||||
16.0.1.0.5 (2025-05-09)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Non-critical issues and performance improvements. (4663)
|
||||
|
||||
16.0.1.0.4 (2025-04-30)
|
||||
-----------------------
|
||||
|
||||
- Features: UI/UX improvements. (4642)
|
||||
|
||||
16.0.1.0.3 (2025-04-22)
|
||||
-----------------------
|
||||
|
||||
- Features: Allow to pass custom variable values to commands (4524)
|
||||
|
||||
- Features: Cetmix Tower Odoo Automation model: pass custom variable
|
||||
values to the ``server_run_command`` method. (4547)
|
||||
|
||||
- Bugfixes: Random id generation, sudo command parsing, record rule
|
||||
names, spelling errors in descriptions. (4612)
|
||||
|
||||
16.0.1.0.2 (2025-04-22)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Refactor secret value handling, fix the new server template
|
||||
creation wizard. (4601)
|
||||
|
||||
16.0.1.0.1
|
||||
----------
|
||||
|
||||
Release for Odoo 16.0
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues <https://github.com/cetmix/cetmix-tower/issues>`_.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
`feedback <https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Do not contact contributors directly about support or help with technical issues.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Authors
|
||||
-------
|
||||
|
||||
* Cetmix
|
||||
|
||||
Maintainers
|
||||
-----------
|
||||
|
||||
This module is part of the `cetmix/cetmix-tower <https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute.
|
||||
@@ -1,4 +0,0 @@
|
||||
# pylint: disable=E8103
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
@@ -1,111 +0,0 @@
|
||||
# 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": "16.0.3.0.1",
|
||||
"category": "Productivity",
|
||||
"website": "https://tower.cetmix.com",
|
||||
"live_test_url": "https://cetmix.com/tower",
|
||||
"images": ["static/description/banner.png"],
|
||||
"author": "Cetmix",
|
||||
"license": "AGPL-3",
|
||||
"application": False,
|
||||
"installable": True,
|
||||
"external_dependencies": {
|
||||
"python": ["paramiko<4", "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_actions_server.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/**/*.xml",
|
||||
"cetmix_tower_server/static/src/**/*.js",
|
||||
"cetmix_tower_server/static/src/**/*.scss",
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<odoo noupdate="1">
|
||||
<record id="cx_tower_jet_state_preparing" model="cx.tower.jet.state">
|
||||
<field name="name">Preparing</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="color">4</field>
|
||||
<field name="note">Jet is being prepared</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_draft" model="cx.tower.jet.state">
|
||||
<field name="name">Draft</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="color">4</field>
|
||||
<field name="note">Jet is in draft state</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_building" model="cx.tower.jet.state">
|
||||
<field name="name">Building</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="color">4</field>
|
||||
<field name="note">Jet is being built</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_starting" model="cx.tower.jet.state">
|
||||
<field name="name">Starting</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="color">3</field>
|
||||
<field name="note">Jet is being started</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_running" model="cx.tower.jet.state">
|
||||
<field name="name">Running</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="color">10</field>
|
||||
<field name="note">Jet is running</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_stopping" model="cx.tower.jet.state">
|
||||
<field name="name">Stopping</field>
|
||||
<field name="sequence">6</field>
|
||||
<field name="color">1</field>
|
||||
<field name="note">Jet is being stopped</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_stopped" model="cx.tower.jet.state">
|
||||
<field name="name">Stopped</field>
|
||||
<field name="sequence">7</field>
|
||||
<field name="color">9</field>
|
||||
<field name="note">Jet is stopped and ready to be started</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_restarting" model="cx.tower.jet.state">
|
||||
<field name="name">Restarting</field>
|
||||
<field name="sequence">8</field>
|
||||
<field name="color">6</field>
|
||||
<field name="note">Jet is being restarted</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_removing" model="cx.tower.jet.state">
|
||||
<field name="name">Removing</field>
|
||||
<field name="sequence">9</field>
|
||||
<field name="color">7</field>
|
||||
<field name="note">Jet is being removed</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_removed" model="cx.tower.jet.state">
|
||||
<field name="name">Removed</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="color">7</field>
|
||||
<field name="note">Jet has been removed</field>
|
||||
</record>
|
||||
<record id="cx_tower_jet_state_destroying" model="cx.tower.jet.state">
|
||||
<field name="name">Destroying</field>
|
||||
<field name="sequence">11</field>
|
||||
<field name="color">8</field>
|
||||
<field name="note">Jet is being destroyed</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_execute_cx_tower_command" model="ir.actions.server">
|
||||
<field name="name">Command</field>
|
||||
<field name="type">ir.actions.server</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="binding_model_id" ref="model_cx_tower_server" />
|
||||
<field name="sequence">10</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_run_command()</field>
|
||||
<field name="groups_id" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
</record>
|
||||
|
||||
<record id="action_execute_cx_tower_plan" model="ir.actions.server">
|
||||
<field name="name">Flight Plan</field>
|
||||
<field name="type">ir.actions.server</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="binding_model_id" ref="model_cx_tower_server" />
|
||||
<field name="sequence">12</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_run_flight_plan()</field>
|
||||
<field name="groups_id" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
</record>
|
||||
|
||||
<record id="action_stop_cx_tower_plan_log" model="ir.actions.server">
|
||||
<field name="name">Stop Flight Plan</field>
|
||||
<field name="type">ir.actions.server</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="binding_model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="sequence">14</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_stop()</field>
|
||||
<field
|
||||
name="groups_id"
|
||||
eval="[(4, ref('cetmix_tower_server.group_manager'))]"
|
||||
/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,20 +0,0 @@
|
||||
<odoo noupdate="1">
|
||||
<record model="ir.config_parameter" id="cetmix_tower_server_command_timeout">
|
||||
<field name="key">cetmix_tower_server.command_timeout</field>
|
||||
<field name="value">900</field>
|
||||
</record>
|
||||
<record
|
||||
model="ir.config_parameter"
|
||||
id="cetmix_tower_server_notification_type_success"
|
||||
>
|
||||
<field name="key">cetmix_tower_server.notification_type_success</field>
|
||||
<field name="value">sticky</field>
|
||||
</record>
|
||||
<record
|
||||
model="ir.config_parameter"
|
||||
id="cetmix_tower_server_notification_type_error"
|
||||
>
|
||||
<field name="key">cetmix_tower_server.notification_type_error</field>
|
||||
<field name="value">sticky</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Check zombie commands -->
|
||||
<record forcecreate="True" id="ir_cron_check_zombie_commands" model="ir.cron">
|
||||
<field name="name">Cetmix Tower: Check zombie commands</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._check_zombie_commands()</field>
|
||||
<field name="user_id" ref="base.user_root" />
|
||||
<field name="interval_number">15</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field eval="False" name="doall" />
|
||||
</record>
|
||||
|
||||
<!-- Auto pull files from server -->
|
||||
<record forcecreate="True" id="ir_cron_auto_pull_files_from_server" model="ir.cron">
|
||||
<field name="name">Cetmix Tower: Auto pull files from server</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._run_auto_pull_files()</field>
|
||||
<field name="user_id" ref="base.user_root" />
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field eval="False" name="doall" />
|
||||
</record>
|
||||
|
||||
<!-- Run scheduled tasks -->
|
||||
<record forcecreate="True" id="ir_cron_run_scheduled_tasks" model="ir.cron">
|
||||
<field name="name">Cetmix Tower: Run scheduled tasks</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._run_scheduled_tasks()</field>
|
||||
<field name="user_id" ref="base.user_root" />
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field eval="False" name="doall" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,3 +0,0 @@
|
||||
-- deactivate scheduled tasks
|
||||
UPDATE cx_tower_scheduled_task
|
||||
SET active = false;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import SUPERUSER_ID, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Generate references for files.
|
||||
"""
|
||||
|
||||
_logger.info("Starting reference generation for files.")
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
model_obj = env["cx.tower.file"]
|
||||
records_without_reference = model_obj.search([("reference", "=", False)])
|
||||
for record in records_without_reference:
|
||||
record_reference = record._generate_or_fix_reference(record.name)
|
||||
record.write({"reference": record_reference})
|
||||
_logger.info(f"Generated reference for file {record.name}: {record_reference}")
|
||||
_logger.info("Reference generation for files completed.")
|
||||
@@ -1,119 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import SUPERUSER_ID, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Move SSH credentials, host keys, SSH keys, and secret values
|
||||
to the vault-backed storage.
|
||||
|
||||
"""
|
||||
|
||||
# 1. SSH password and host key are now stored in secrets
|
||||
_logger.info("Moving SSH password and host key to vault.")
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
# Read SSH password and host key from servers using SQL query
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT id, ssh_password, host_key
|
||||
FROM cx_tower_server
|
||||
WHERE ssh_password IS NOT NULL OR host_key IS NOT NULL
|
||||
"""
|
||||
)
|
||||
server_records = cr.fetchall()
|
||||
server_model = env["cx.tower.server"]
|
||||
success = False
|
||||
try:
|
||||
for record in server_records:
|
||||
_logger.info(
|
||||
f"Moving SSH password and host key to vault for server {record[0]}"
|
||||
)
|
||||
server_model.browse(record[0]).write(
|
||||
{"ssh_password": record[1], "host_key": record[2]}
|
||||
)
|
||||
_logger.info("Moving SSH password and host key to vault completed.")
|
||||
success = True
|
||||
# Clear SSH password and host key from servers
|
||||
except Exception as e:
|
||||
_logger.error(f"Error moving SSH password and host key to vault: {e}")
|
||||
raise e
|
||||
finally:
|
||||
if success:
|
||||
cr.execute(
|
||||
"""
|
||||
UPDATE cx_tower_server
|
||||
SET ssh_password = NULL, host_key = NULL
|
||||
WHERE ssh_password IS NOT NULL OR host_key IS NOT NULL
|
||||
"""
|
||||
)
|
||||
_logger.info("Cleared SSH password and host key from servers.")
|
||||
|
||||
# 2. SSH keys are now stored in secrets
|
||||
_logger.info("Moving SSH keys to vault.")
|
||||
success = False
|
||||
# Read SSH keys from keys using SQL query
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT id, secret_value
|
||||
FROM cx_tower_key
|
||||
WHERE key_type = 'k'
|
||||
"""
|
||||
)
|
||||
ssh_key_records = cr.fetchall()
|
||||
ssh_key_model = env["cx.tower.key"]
|
||||
try:
|
||||
for record in ssh_key_records:
|
||||
_logger.info(f"Moving SSH key to vault record {record[0]}")
|
||||
ssh_key_model.browse(record[0]).write({"secret_value": record[1]})
|
||||
_logger.info("Moving SSH keys to vault completed.")
|
||||
success = True
|
||||
except Exception as e:
|
||||
_logger.error(f"Error moving SSH keys to vault: {e}")
|
||||
raise e
|
||||
finally:
|
||||
if success:
|
||||
# Clear SSH key from keys
|
||||
cr.execute(
|
||||
"""
|
||||
UPDATE cx_tower_key
|
||||
SET secret_value = NULL
|
||||
WHERE secret_value IS NOT NULL
|
||||
"""
|
||||
)
|
||||
_logger.info("Cleared SSH key from keys.")
|
||||
|
||||
# 3. Secret values are now stored in secrets
|
||||
_logger.info("Moving secret values to vault.")
|
||||
success = False
|
||||
# Read secret values from key values using SQL query
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT id, secret_value
|
||||
FROM cx_tower_key_value
|
||||
"""
|
||||
)
|
||||
secret_value_records = cr.fetchall()
|
||||
secret_value_model = env["cx.tower.key.value"]
|
||||
try:
|
||||
for record in secret_value_records:
|
||||
_logger.info(f"Moving secret value to vault record {record[0]}")
|
||||
secret_value_model.browse(record[0]).write({"secret_value": record[1]})
|
||||
_logger.info("Moving secret values to vault completed.")
|
||||
success = True
|
||||
except Exception as e:
|
||||
_logger.error(f"Error moving secret values to vault: {e}")
|
||||
raise e
|
||||
finally:
|
||||
if success:
|
||||
# Clear secret value from key values
|
||||
cr.execute(
|
||||
"""
|
||||
UPDATE cx_tower_key_value
|
||||
SET secret_value = NULL
|
||||
WHERE secret_value IS NOT NULL
|
||||
"""
|
||||
)
|
||||
_logger.info("Cleared secret value from key values.")
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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"
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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
|
||||
@@ -1,313 +0,0 @@
|
||||
# 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 command.
|
||||
Default is True.
|
||||
try_file (bool): Try file operations.
|
||||
Default is True.
|
||||
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.<REFERENCE>!#)
|
||||
or just a <REFERENCE>.
|
||||
|
||||
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)
|
||||
@@ -1,153 +0,0 @@
|
||||
from odoo import _
|
||||
|
||||
# ***
|
||||
# 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 = _("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 = _(
|
||||
"""# 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 = _(
|
||||
"""
|
||||
<h3>Help with Python expressions</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p>
|
||||
Each Python code command returns the <code>result</code> value which is a dictionary.
|
||||
<br>There are two keys in the dictionary:
|
||||
<ul>
|
||||
<li><code>exit_code</code>: Integer. Exit code of the command. "0" means success, any other value means failure. Default value is "0".</li>
|
||||
<li><code>message</code>: String. Message to be logged. Default value is "None".</li>
|
||||
</ul>
|
||||
You can also access the <code>custom_values</code> 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.
|
||||
<br/>
|
||||
Here is an example of a python code command:
|
||||
|
||||
<code style='white-space: pre-wrap'>
|
||||
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"
|
||||
</code>
|
||||
</p>
|
||||
<br>
|
||||
Please refer to the <a href="https://cetmix.com/tower/documentation/command/#python-code-commands" target="_blank">official documentation</a> for more information and examples.
|
||||
</div>
|
||||
<p
|
||||
>Various fields may use Python code or Python expressions. The
|
||||
following variables can be used:</p>
|
||||
""" # noqa: E501
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,99 +0,0 @@
|
||||
# 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 []
|
||||
@@ -1,657 +0,0 @@
|
||||
# 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
|
||||
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",
|
||||
store=True,
|
||||
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"<li><code>{key}</code>: {value['help']}</li>")
|
||||
|
||||
help_text_fragments.append(
|
||||
f"<li><code>custom_values</code>: {_('Flight plan custom values')}</li>"
|
||||
)
|
||||
|
||||
help_text = "<ul>" + "".join(help_text_fragments) + "</ul>"
|
||||
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 current command log records
|
||||
"""
|
||||
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()
|
||||
@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:
|
||||
{"<library_name>": {
|
||||
"import": <library_import>,
|
||||
"help": <library_help_html>
|
||||
}}
|
||||
"""
|
||||
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. "
|
||||
"<a href='https://docs.python.org/3/library/hashlib.html'"
|
||||
" target='_blank'>Documentation</a>. "
|
||||
"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. "
|
||||
"<a href='https://docs.python.org/3/library/hmac.html'"
|
||||
" target='_blank'>Documentation</a>. "
|
||||
"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 "
|
||||
"<code>tldextract.extract()</code> to parse domains. "
|
||||
"Check <a href='https://github.com/john-kurkowski/tldextract'"
|
||||
" target='_blank'>tldextract</a> for more information."
|
||||
),
|
||||
},
|
||||
"dns": {
|
||||
"import": dns,
|
||||
"help": _(
|
||||
"Python 'dnspython' library. "
|
||||
"<a href='https://dnspython.readthedocs.io'"
|
||||
" target='_blank'>Documentation</a>."
|
||||
"<ul><li><code>dns.resolver</code>: "
|
||||
"wrapped dnspython. Use "
|
||||
'<code>dns.resolver.resolve(hostname, "A")</code> for '
|
||||
"DNS lookups.</li>"
|
||||
"<li><code>dns.reversename</code>: wrapped dnspython. "
|
||||
'Use <code>dns.reversename.from_address("8.8.8.8")</code>'
|
||||
" to build and reverse PTR records.</li>"
|
||||
"<li><code>dns.exception</code>: wrapped dnspython. "
|
||||
"Catch "
|
||||
"<code>dns.exception.DNSException</code> to handle "
|
||||
"DNS-related errors.</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
}
|
||||
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:
|
||||
{"<object_name>": {
|
||||
"import": <object_import>,
|
||||
"help": <object_help_html>
|
||||
}}
|
||||
"""
|
||||
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 "
|
||||
"<a href='https://cetmix.com/tower/documentation/odoo_automation'"
|
||||
" target='_blank'>helper class</a> shortcut"
|
||||
),
|
||||
},
|
||||
"tower_servers": {
|
||||
"import": self.env["cx.tower.server"],
|
||||
"help": _("A helper shortcut to <code>env['cx.tower.server']</code>"),
|
||||
},
|
||||
"tower_jets": {
|
||||
"import": self.env["cx.tower.jet"],
|
||||
"help": _("A helper shortcut to <code>env['cx.tower.jet']</code>"),
|
||||
},
|
||||
"tower_commands": {
|
||||
"import": self.env["cx.tower.command"],
|
||||
"help": _("A helper shortcut to <code>env['cx.tower.command']</code>"),
|
||||
},
|
||||
"tower_plans": {
|
||||
"import": self.env["cx.tower.plan"],
|
||||
"help": _("A helper shortcut to <code>env['cx.tower.plan']</code>"),
|
||||
},
|
||||
"tower_waypoints": {
|
||||
"import": self.env["cx.tower.jet.waypoint"],
|
||||
"help": _(
|
||||
"A helper shortcut to <code>env['cx.tower.jet.waypoint']</code>"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
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:
|
||||
{
|
||||
<module_name>: {"<library_name>": {
|
||||
"import": <library_import>,
|
||||
"help": <library_help_html>
|
||||
}
|
||||
}
|
||||
|
||||
Where:
|
||||
|
||||
<module_name> Odoo module technical name.
|
||||
<library_name> is the name of the library how it will be used in the code.
|
||||
|
||||
<library_import>: The library object to expose.
|
||||
<library_help_html>: 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("]
|
||||
@@ -1,401 +0,0 @@
|
||||
# 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",
|
||||
unaccent=False,
|
||||
)
|
||||
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("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 = _(
|
||||
"<p><strong>Error converting command"
|
||||
" response to HTML: %(error)s</strong></p>",
|
||||
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<br/>" "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<br/>" "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
|
||||
@@ -1,52 +0,0 @@
|
||||
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})
|
||||
@@ -1,783 +0,0 @@
|
||||
# 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_rights("unlink")
|
||||
file.check_access_rule("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 pull %(f)s from server: %(err)s",
|
||||
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
|
||||
@@ -1,243 +0,0 @@
|
||||
# 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(
|
||||
f"Invalid if_file_exists value: {if_file_exists}. "
|
||||
f"Expected one of {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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
||||
# 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
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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!")
|
||||
)
|
||||
@@ -1,260 +0,0 @@
|
||||
# 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})
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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_rights("read")
|
||||
jet.check_access_rule("write")
|
||||
|
||||
# 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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
||||
# 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
|
||||
@@ -1,474 +0,0 @@
|
||||
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<br/>" "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<br/>"
|
||||
"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<br/>"
|
||||
"%(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<br/>"
|
||||
"%(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": "tree,form",
|
||||
"domain": [("jet_template_install_id", "=", self.id)], # pylint: disable=no-member
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,789 +0,0 @@
|
||||
# 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_<metadata_key>: 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
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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:
|
||||
``<name> (<jet_template_name>)``.
|
||||
"""
|
||||
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]
|
||||
@@ -1,412 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,70 +0,0 @@
|
||||
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 = [(5, 0, 0)]
|
||||
|
||||
@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:
|
||||
list: List of secret IDs corresponding to the references in `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)]
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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")
|
||||
@@ -1,424 +0,0 @@
|
||||
# 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 = [(6, 0, plan.line_ids.mapped("command_id").ids)]
|
||||
|
||||
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_rights("read")
|
||||
self.check_access_rule("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
|
||||
@@ -1,315 +0,0 @@
|
||||
# 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
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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
|
||||
@@ -1,532 +0,0 @@
|
||||
# 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",
|
||||
unaccent=False,
|
||||
)
|
||||
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", "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
|
||||
"""
|
||||
values = {
|
||||
"is_running": False,
|
||||
"plan_status": plan_status,
|
||||
"finish_date": fields.Datetime.now(),
|
||||
}
|
||||
|
||||
self.ensure_one()
|
||||
|
||||
# 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<br/>" "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<br/>"
|
||||
"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
|
||||
@@ -1,481 +0,0 @@
|
||||
# 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,
|
||||
unaccent=False,
|
||||
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.clear_caches()
|
||||
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.clear_caches()
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
"""
|
||||
Overrides unlink to clear cache for this method
|
||||
"""
|
||||
res = super().unlink()
|
||||
self.clear_caches()
|
||||
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
|
||||
@@ -1,442 +0,0 @@
|
||||
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(default=False)
|
||||
tuesday = fields.Boolean(default=False)
|
||||
wednesday = fields.Boolean(default=False)
|
||||
thursday = fields.Boolean(default=False)
|
||||
friday = fields.Boolean(default=False)
|
||||
saturday = fields.Boolean(default=False)
|
||||
sunday = fields.Boolean(default=False)
|
||||
|
||||
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(f"Scheduled task {task.id} failed: {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
|
||||
@@ -1,18 +0,0 @@
|
||||
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",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,237 +0,0 @@
|
||||
# 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 = _("<log is empty>")
|
||||
|
||||
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
|
||||
return force_name or self.name
|
||||
@@ -1,653 +0,0 @@
|
||||
# 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 and concurrency fields)
|
||||
MAGIC_FIELDS = models.MAGIC_COLUMNS + [self.CONCURRENCY_CHECK_FIELD]
|
||||
|
||||
# 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"]
|
||||
@@ -1,100 +0,0 @@
|
||||
# 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 plan is triggered.
|
||||
Override to implement custom notifications.
|
||||
|
||||
Args:
|
||||
server (cx.tower.server()): Server action was triggered for
|
||||
|
||||
Returns:
|
||||
`ir.action.client`: Web client notification.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
notification = {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {
|
||||
"title": _("%(shr)s triggered", shr=self.name),
|
||||
"message": _(
|
||||
"Check %(t)s log for result",
|
||||
t="flight plan" if self.action == "plan" else "command",
|
||||
),
|
||||
"sticky": False,
|
||||
},
|
||||
}
|
||||
return notification
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class 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"))
|
||||
)
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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',..])
|
||||
"""
|
||||
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
|
||||
@@ -1,900 +0,0 @@
|
||||
# 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.safe_eval import safe_eval, wrap_module
|
||||
|
||||
_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 = _("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:<variable_name>, Value: <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": _("Variable Values"),
|
||||
"res_model": "cx.tower.variable.value",
|
||||
"views": [[False, "tree"]],
|
||||
"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": _("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": _("Variable Values"),
|
||||
"res_model": "cx.tower.variable.value",
|
||||
"views": [[False, "tree"]],
|
||||
"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,
|
||||
_(
|
||||
"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.<var_provider>.<var_name>` 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
|
||||
@@ -1,82 +0,0 @@
|
||||
# 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})
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
# 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 a SQL constraint to ensure the combination of
|
||||
# 'name' and 'variable_id' is unique
|
||||
_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
|
||||
@@ -1,592 +0,0 @@
|
||||
# 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,
|
||||
fields=["variable_id", "server_id"],
|
||||
groupby=["variable_id", "server_id"],
|
||||
lazy=False,
|
||||
)
|
||||
|
||||
# Check for groups with more than 1 record
|
||||
for group in grouped_data:
|
||||
if group["__count"] > 1:
|
||||
variable_name = (
|
||||
group.get("variable_id", ["", "Unknown"])[1]
|
||||
if group.get("variable_id")
|
||||
else "Unknown"
|
||||
)
|
||||
server_name = (
|
||||
group.get("server_id", ["", "Unknown"])[1]
|
||||
if group.get("server_id")
|
||||
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:
|
||||
<variable_reference>_<model_generic_reference>_<linked_model_generic_reference>_<linked_record_reference>
|
||||
Global:
|
||||
<variable_reference>_<model_generic_reference>_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
|
||||
@@ -1,52 +0,0 @@
|
||||
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.",
|
||||
),
|
||||
]
|
||||
@@ -1,422 +0,0 @@
|
||||
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 _read(self, fields): # pylint: disable=missing-return # doesn't return anything
|
||||
"""Substitute fields based on api.
|
||||
|
||||
This method replaces values of secret fields with a placeholder value
|
||||
when they are read from the database.
|
||||
|
||||
Args:
|
||||
fields (list): List of fields to read
|
||||
"""
|
||||
super()._read(fields)
|
||||
|
||||
show_all = not fields
|
||||
secret_fields = (
|
||||
self.SECRET_FIELDS
|
||||
if show_all
|
||||
else [f for f in self.SECRET_FIELDS if f in fields]
|
||||
)
|
||||
|
||||
for record in self:
|
||||
for secret_field in secret_fields:
|
||||
try:
|
||||
record._cache[secret_field] = self.SECRET_VALUE_PLACEHOLDER
|
||||
except Exception: # pylint: disable=except-pass
|
||||
# skip SpecialValue
|
||||
# (e.g. for missing record or access right)
|
||||
pass
|
||||
|
||||
@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
|
||||
because it doesn't 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
|
||||
@@ -1,24 +0,0 @@
|
||||
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()
|
||||
@@ -1,79 +0,0 @@
|
||||
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",
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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,tree,form",
|
||||
"domain": [("partner_id", "child_of", self.id)],
|
||||
"context": {"default_partner_id": self.id},
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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
|
||||
@@ -1,3 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
@@ -1 +0,0 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.
|
||||
@@ -1,4 +0,0 @@
|
||||
[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.
|
||||
@@ -1,250 +0,0 @@
|
||||
## 16.0.3.0.1 (2026-03-27)
|
||||
|
||||
- Bugfixes: Waypoint behavior improvements. (5313)
|
||||
|
||||
|
||||
## 16.0.3.0.0 (2026-03-23)
|
||||
|
||||
- Features: Jets! (4700)
|
||||
|
||||
|
||||
## 16.0.2.2.14 (2026-02-17)
|
||||
|
||||
- Features: Blacklist filter for Python commands, value checker for Vault. (5253)
|
||||
|
||||
|
||||
## 16.0.2.2.13 (2026-01-12)
|
||||
|
||||
- Bugfixes: Last flight plan line post-run action was not triggered correctly. (5120)
|
||||
|
||||
|
||||
## 16.0.2.2.12 (2026-01-11)
|
||||
|
||||
- Features: Improve the 'File using template' command flow, fix the flight plan line view layout. (5197)
|
||||
|
||||
|
||||
## 16.0.2.2.11 (2026-01-08)
|
||||
|
||||
- Bugfixes: Ensure custom values can be updated even if not provided initially. (5175)
|
||||
|
||||
|
||||
## 16.0.2.2.10 (2026-01-08)
|
||||
|
||||
- Features: Scheduled tasks: allow to select specific days of week. (5190)
|
||||
|
||||
|
||||
## 16.0.2.2.8 (2025-12-22)
|
||||
|
||||
- Bugfixes: Handle malformed expressions in flight plan line conditions. (5154)
|
||||
|
||||
|
||||
## 16.0.2.2.7 (2025-12-16)
|
||||
|
||||
- Features: Support for ANSI formatting in server logs. (5141)
|
||||
|
||||
- Bugfixes: UI/UX fixed and improvements. (5141)
|
||||
|
||||
|
||||
## 16.0.2.2.6 (2025-12-11)
|
||||
|
||||
- Features: Improve search views, implement the search panel for selected views. (5139)
|
||||
|
||||
|
||||
## 16.0.2.2.5 (2025-12-10)
|
||||
|
||||
- Bugfixes: Custom values in flight plan are lost in a skipped command and are not available after it. (5129)
|
||||
|
||||
|
||||
## 16.0.2.2.4 (2025-12-10)
|
||||
|
||||
- Features: Parse empty or missing key values as 'None' instead of leaving key reference as is. (5134)
|
||||
|
||||
|
||||
## 16.0.2.2.3 (2025-12-03)
|
||||
|
||||
- Bugfixes: Save correct error message in log when SSH connection fails. (5109)
|
||||
|
||||
|
||||
## 16.0.2.2.2 (2025-12-03)
|
||||
|
||||
- Bugfixes: Make variables selectable in scheduled tasks (5105)
|
||||
|
||||
|
||||
## 16.0.2.2.0 (2025-11-12)
|
||||
|
||||
- Features: Integrate user notifications into the main module, drop the 'cetmix_tower_notify_backend' module. (5074)
|
||||
|
||||
|
||||
## 16.0.2.0.6 (2025-10-27)
|
||||
|
||||
- Features: Tag mixin and helper commands. (5039)
|
||||
|
||||
|
||||
## 16.0.2.0.5 (2025-10-16)
|
||||
|
||||
- Bugfixes: Flight plan command exception handling (4930)
|
||||
|
||||
|
||||
## 16.0.2.0.4 (2025-10-13)
|
||||
|
||||
- Features: Auto update references for related records (5005)
|
||||
|
||||
|
||||
## 16.0.2.0.3 (2025-10-13)
|
||||
|
||||
- Features: Terminate running flight plan manually (3410)
|
||||
|
||||
|
||||
## 16.0.2.0.2 (2025-10-08)
|
||||
|
||||
- Features: UI/UX improvements (4996)
|
||||
|
||||
- Bugfixes: Handle secret values when a record is duplicated using copy() (4996)
|
||||
|
||||
|
||||
## 16.0.2.0.1 (2025-10-08)
|
||||
|
||||
- Bugfixes: Improve variable value references uniqueness (4961)
|
||||
|
||||
|
||||
## 16.0.2.0.0 (2025-10-07)
|
||||
|
||||
- Features: 'Cetmix Tower Vault' - new way of centralized password/key management (4824)
|
||||
|
||||
|
||||
## 16.0.1.7.2 (2025-09-18)
|
||||
|
||||
- Features: Set 'Auto Sync' in files from file templates (4949)
|
||||
|
||||
|
||||
## 16.0.1.7.1 (2025-09-10)
|
||||
|
||||
- Bugfixes: Check custom values in flight plan line condition (4922)
|
||||
|
||||
|
||||
## 16.0.1.6.4 (2025-08-18)
|
||||
|
||||
- Features: Improve the extendability of the file upload command. (4759)
|
||||
|
||||
|
||||
## 16.0.1.6.3 (2025-08-13)
|
||||
|
||||
- Features: Improve access settings for logs (4866)
|
||||
|
||||
|
||||
## 16.0.1.6.2 (2025-08-05)
|
||||
|
||||
- Bugfixes: Pin paramiko version to "<4" to maintain compatibility with legacy installations (4891)
|
||||
|
||||
|
||||
## 16.0.1.6.0 (2025-07-30)
|
||||
|
||||
- Features: Optional behaviour when file uploaded by command already exists on the server. (4740)
|
||||
|
||||
|
||||
## 16.0.1.5.3 (2025-07-29)
|
||||
|
||||
- Features: Make file references server dependent to be more unique (4870)
|
||||
|
||||
|
||||
## 16.0.1.5.1 (2025-07-25)
|
||||
|
||||
- Features: Select secrets from dropdown list in the code fields (4853)
|
||||
|
||||
|
||||
## 16.0.1.5.0 (2025-07-22)
|
||||
|
||||
- Features: Select variables from dropdown list in the code fields (4827)
|
||||
|
||||
|
||||
## 16.0.1.3.0 (2025-07-17)
|
||||
|
||||
- Features: Add the tldextract and dnspython libraries. (4737)
|
||||
|
||||
|
||||
## 16.0.1.1.4 (2025-07-07)
|
||||
|
||||
- Bugfixes: Command log sorting (4816)
|
||||
|
||||
|
||||
## 16.0.1.1.2 (2025-06-25)
|
||||
|
||||
- Features: Required variables in servers (4779)
|
||||
|
||||
|
||||
## 16.0.1.1.1 (2025-06-21)
|
||||
|
||||
- Features: Command view improvements (4753)
|
||||
|
||||
|
||||
## 16.0.1.1.0 (2025-06-20)
|
||||
|
||||
- Features: Run commands and flight plans using scheduled tasks. (4650)
|
||||
|
||||
|
||||
## 16.0.1.0.12 (2025-06-06)
|
||||
|
||||
- Features: Improve command and flight plan log management. (4749)
|
||||
|
||||
|
||||
## 16.0.1.0.11 (2025-06-06)
|
||||
|
||||
- Bugfixes: Host key cannot be retrieved from the UI. (4747)
|
||||
|
||||
|
||||
## 16.0.1.0.10 (2025-05-24)
|
||||
|
||||
- Features: Improve command log and flight plan form views (4697)
|
||||
|
||||
|
||||
## 16.0.1.0.9 (2025-05-23)
|
||||
|
||||
- Bugfixes: Error when rendering a file not attached to a server. (4715)
|
||||
|
||||
|
||||
## 16.0.1.0.8 (2025-05-21)
|
||||
|
||||
- Features: References for secret values. (4696)
|
||||
- Features: Make the "Host key" field non-required in the form view to improve the UX. (4699)
|
||||
|
||||
|
||||
## 16.0.1.0.7 (2025-05-16)
|
||||
|
||||
- Features: Option to preserve command splitting when using sudo. (4641)
|
||||
- Features: Record references for files. (4670)
|
||||
- Features: Use `sudo` parameter to pass sudo mode to command runner instead of using context. (4678)
|
||||
|
||||
- Bugfixes: Incorrect sudo usage in commands run in wizard. Pass 'No split for sudo' property to commands run in wizard. (4679)
|
||||
|
||||
|
||||
## 16.0.1.0.6 (2025-05-16)
|
||||
|
||||
- Features: Improve the key storage functionality. (4686)
|
||||
|
||||
|
||||
## 16.0.1.0.5 (2025-05-09)
|
||||
|
||||
- Bugfixes: Non-critical issues and performance improvements. (4663)
|
||||
|
||||
|
||||
## 16.0.1.0.4 (2025-04-30)
|
||||
|
||||
- Features: UI/UX improvements. (4642)
|
||||
|
||||
|
||||
## 16.0.1.0.3 (2025-04-22)
|
||||
|
||||
- Features: Allow to pass custom variable values to commands (4524)
|
||||
- Features: Cetmix Tower Odoo Automation model: pass custom variable values to the `server_run_command` method. (4547)
|
||||
|
||||
- Bugfixes: Random id generation, sudo command parsing, record rule names, spelling errors in descriptions. (4612)
|
||||
|
||||
|
||||
## 16.0.1.0.2 (2025-04-22)
|
||||
|
||||
- Bugfixes: Refactor secret value handling, fix the new server template creation wizard. (4601)
|
||||
|
||||
|
||||
## 16.0.1.0.1
|
||||
|
||||
Release for Odoo 16.0
|
||||
@@ -1 +0,0 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.
|
||||
@@ -1,77 +0,0 @@
|
||||
@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
|
||||
@@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="ir_module_category_tower" model="ir.module.category">
|
||||
<field name="name">Cetmix Tower</field>
|
||||
<field name="sequence">199</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_module_category_tower_server" model="ir.module.category">
|
||||
<field name="parent_id" ref="ir_module_category_tower" />
|
||||
<field name="name">Access Level</field>
|
||||
</record>
|
||||
|
||||
<record id="group_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_server" />
|
||||
<field name="comment">
|
||||
Basic actions for selected servers.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="group_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_server" />
|
||||
<field name="implied_ids" eval="[(4, ref('group_user'))]" />
|
||||
<field name="comment">
|
||||
Create and modify selected servers.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="group_root" model="res.groups">
|
||||
<field name="name">Root</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_server" />
|
||||
<field name="implied_ids" eval="[(4, ref('group_manager'))]" />
|
||||
<field name="comment">
|
||||
Full control over all servers.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</odoo>
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="rule_cx_tower_command_log_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower command log: user access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("create_uid", "=", user.id),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_command_log_group_manager_read" model="ir.rule">
|
||||
<field name="name">Tower command log: manager access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
"&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="rule_cx_tower_command_log_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower command log: root access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_log" />
|
||||
<field name="domain_force">[(1, "=", 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,84 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read allowed if access_level == 1 and either command.user_ids or a related server grants access via user_ids -->
|
||||
<record id="rule_cx_tower_command_group_user_read" model="ir.rule">
|
||||
<field name="name">Command: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("user_ids", "in", [user.id]),
|
||||
("server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: read allowed if access_level <= 2 and command or server grants access -->
|
||||
<record id="rule_cx_tower_command_group_manager_read" model="ir.rule">
|
||||
<field name="name">Command: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: write & create allowed if access_level <= 2 and in command.manager_ids -->
|
||||
<record id="rule_cx_tower_command_group_manager_write" model="ir.rule">
|
||||
<field name="name">Command: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: unlink allowed if access_level <= 2, creator, and in manager_ids -->
|
||||
<record id="rule_cx_tower_command_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Command: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root: unrestricted access -->
|
||||
<record id="rule_cx_tower_command_group_root_full" model="ir.rule">
|
||||
<field name="name">Command: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: Read access rule: Allow reading file records when the current user is in the related Server's user_ids -->
|
||||
<record id="rule_cx_tower_file_group_user_read" model="ir.rule">
|
||||
<field name="name">File: User read via related server (user_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[('server_id.user_ids', 'in', [user.id])]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: Write and Create access rule: Allow update and creation when current user is in related Server's manager_ids -->
|
||||
<record id="rule_cx_tower_file_group_manager_read_write" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>File: Manager write & create via related server (manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink access rule: Allow deletion only when the current user is in the related Server's manager_ids and is the record creator -->
|
||||
<record id="rule_cx_tower_file_group_manager_unlink" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>File: Manager unlink via related server (manager_ids) and record creator</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[ ('server_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id) ]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root: Unrestricted access rule: Allow access to all files -->
|
||||
<record id="rule_cx_tower_file_group_root_full" model="ir.rule">
|
||||
<field name="name">File: Root Unrestricted Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
|
||||
<!-- Manager: Read access (if the current user is in user_ids or manager_ids) -->
|
||||
<record id="rule_cx_tower_file_template_group_manager_read" model="ir.rule">
|
||||
<field name="name">File: Manager read (user_ids or manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: Write and Create access (if the current user is in manager_ids) -->
|
||||
<record id="rule_cx_tower_file_template_group_manager_write" model="ir.rule">
|
||||
<field name="name">File: Manager write & create (manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[('manager_ids', 'in', [user.id])]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink access (if the current user is in manager_ids and is the record creator) -->
|
||||
<record id="rule_cx_tower_file_template_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">File: Manager unlink (manager_ids & creator)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("manager_ids", "in", [user.id]), ("create_uid", "=", user.id)]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root: unlimited access -->
|
||||
<record id="rule_cx_tower_file_template_group_root_full" model="ir.rule">
|
||||
<field name="name">File: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,75 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Read Access -->
|
||||
<!-- Action access level is "User" AND Template access level is "User" OR user is added in "Users" in the Jet Template OR user is added in "Users" of any Jets created from the template -->
|
||||
<record id="rule_cx_tower_jet_action_user_read" model="ir.rule">
|
||||
<field name="name">Jet Action: User Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- Action access level is "Manager" or less AND Jet Template access level is "Manager" or less OR user is added in "Users" or "Managers" in the template -->
|
||||
<record id="rule_cx_tower_jet_action_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Action: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create/Unlink Access -->
|
||||
<!-- Action access level is "Manager" or less AND user is added to "Managers" of the Jet Template -->
|
||||
<record id="rule_cx_tower_jet_action_manager_write" model="ir.rule">
|
||||
<field name="name">Jet Action: Manager Write/Create/Unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("jet_template_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<!-- Any record -->
|
||||
<record id="rule_cx_tower_jet_action_root_full" model="ir.rule">
|
||||
<field name="name">Jet Action: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- User is in "Users" or "Managers" of both the Jet and the depended Jet -->
|
||||
<record id="rule_cx_tower_jet_dependency_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Dependency: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_dependency" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
"|",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create/Delete Access -->
|
||||
<!-- User is in "Managers" of the Jet AND in "Users" or "Managers" of the depended Jet -->
|
||||
<record id="rule_cx_tower_jet_dependency_manager_crud" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Jet Dependency: Manager write & create & unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_dependency" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_dependency_root_full" model="ir.rule">
|
||||
<field name="name">Jet Dependency: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_dependency" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,93 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Read Access -->
|
||||
<!-- User is added to "Users" in jet AND user is added to "Users" in related Server -->
|
||||
<record id="rule_cx_tower_jet_user_read" model="ir.rule">
|
||||
<field name="name">Jet: User Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("user_ids", "in", [user.id]),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- User is added to "Users" or "Managers" in jet AND user is added to "Users" or "Managers" in related Server -->
|
||||
<record id="rule_cx_tower_jet_manager_read" model="ir.rule">
|
||||
<field name="name">Jet: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
"|",
|
||||
("user_ids", "in", [user.id]),
|
||||
("manager_ids", "in", [user.id]),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create Access -->
|
||||
<!-- User is added to "Managers" in jet AND user is added to "Users" or "Managers" in related Server -->
|
||||
<record id="rule_cx_tower_jet_manager_write" model="ir.rule">
|
||||
<field name="name">Jet: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("manager_ids", "in", [user.id]),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Access -->
|
||||
<!-- User is added to "Managers" in jet AND record is created by the user AND user is added to "Users" or "Managers" in related Server -->
|
||||
<record id="rule_cx_tower_jet_manager_unlink" model="ir.rule">
|
||||
<field name="name">Jet: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("manager_ids", "in", [user.id]),
|
||||
("create_uid", "=", user.id),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_root_full" model="ir.rule">
|
||||
<field name="name">Jet: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,53 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- Template access level is "Manager" or less OR user is added in "Users" or "Managers" in the Jet Template -->
|
||||
<record id="rule_cx_tower_jet_template_dependency_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Template Dependency: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_dependency" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["|",
|
||||
("template_id.access_level", "<=", "2"),
|
||||
"|",
|
||||
("template_id.user_ids", "in", [user.id]),
|
||||
("template_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create/Delete Access -->
|
||||
<!-- Template access level is "Manager" or less AND user is added in "Managers" in the Jet Template -->
|
||||
<record id="rule_cx_tower_jet_template_dependency_manager_crud" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Jet Template Dependency: Manager write & create & unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_dependency" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("template_id.access_level", "<=", "2"), ("template_id.manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_template_dependency_root_full" model="ir.rule">
|
||||
<field name="name">Jet Template Dependency: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_dependency" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- User is added to "Users" or "Managers" in the related server AND template access level is "Manager" or less OR user is added in "Users" -->
|
||||
<record id="rule_cx_tower_jet_template_install_line_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Template Install Line: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_install_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
"|",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_template_install_line_root_full" model="ir.rule">
|
||||
<field name="name">Jet Template Install Line: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_install_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- User is added to "Users" or "Managers" in the related server AND template access level is "Manager" or less OR user is added in "Users" -->
|
||||
<record id="rule_cx_tower_jet_template_install_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Template Install: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_install" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
"|",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_template_install_root_full" model="ir.rule">
|
||||
<field name="name">Jet Template Install: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template_install" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,87 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Read Access -->
|
||||
<!-- Access level is "User" or user is added in "Users" or user is added in "Users" of any Jets created from the template -->
|
||||
<record id="rule_cx_tower_jet_template_user_read" model="ir.rule">
|
||||
<field name="name">Jet Template: User Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["|","|",
|
||||
("access_level", "=", "1"),
|
||||
("user_ids", "in", [user.id]),
|
||||
("jet_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- Access level is "Manager" or less OR user is added in "Users" or "Managers" -->
|
||||
<record id="rule_cx_tower_jet_template_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Template: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["|",
|
||||
("access_level", "<=", "2"),
|
||||
"|", "|",
|
||||
("user_ids", "in", [user.id]),
|
||||
("manager_ids", "in", [user.id]),
|
||||
("jet_ids.manager_ids", "in", [user.id])
|
||||
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create Access -->
|
||||
<!-- Access level is "Manager" or less AND user is added to "Managers" -->
|
||||
<record id="rule_cx_tower_jet_template_manager_write" model="ir.rule">
|
||||
<field name="name">Jet Template: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Access -->
|
||||
<!-- Access level is "Manager" or less AND user is added to "Managers" AND the record is created by the user -->
|
||||
<record id="rule_cx_tower_jet_template_manager_unlink" model="ir.rule">
|
||||
<field name="name">Jet Template: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"), ("manager_ids", "in", [user.id]), ("create_uid", "=", user.id)]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_template_root_full" model="ir.rule">
|
||||
<field name="name">Jet Template: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,70 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- Access level is Manager (2) and User is added in "Users" or "Managers" of the Jet -->
|
||||
<record id="rule_cx_tower_jet_waypoint_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Waypoint: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"),
|
||||
"|",
|
||||
("jet_id.user_ids", "in", [user.id]),
|
||||
("jet_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create Access -->
|
||||
<!-- Access level is Manager (2) AND user is added to "Managers" of the Jet Template -->
|
||||
<record id="rule_cx_tower_jet_waypoint_manager_write" model="ir.rule">
|
||||
<field name="name">Jet Waypoint: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"),
|
||||
("jet_id.jet_template_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Access -->
|
||||
<!-- Access level is Manager (2) AND user is added to "Managers" of the Jet Template AND the record is created by the user -->
|
||||
<record id="rule_cx_tower_jet_waypoint_manager_unlink" model="ir.rule">
|
||||
<field name="name">Jet Waypoint: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"),
|
||||
("jet_id.jet_template_id.manager_ids", "in", [user.id]),
|
||||
("create_uid", "=", user.id)
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_waypoint_root_full" model="ir.rule">
|
||||
<field name="name">Jet Waypoint: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,70 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Manager Read Access -->
|
||||
<!-- Access level is Manager (2) and User is added in "Users" or "Managers" of the Jet Template -->
|
||||
<record id="rule_cx_tower_jet_waypoint_template_manager_read" model="ir.rule">
|
||||
<field name="name">Jet Waypoint Template: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"),
|
||||
"|",
|
||||
("jet_template_id.user_ids", "in", [user.id]),
|
||||
("jet_template_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create Access -->
|
||||
<!-- Access level is Manager (2) AND user is added to "Managers" of the Jet Template -->
|
||||
<record id="rule_cx_tower_jet_waypoint_template_manager_write" model="ir.rule">
|
||||
<field name="name">Jet Waypoint Template: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"),
|
||||
("jet_template_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Access -->
|
||||
<!-- Access level is Manager (2) AND user is added to "Managers" of the Jet Template AND the record is created by the user -->
|
||||
<record id="rule_cx_tower_jet_waypoint_template_manager_unlink" model="ir.rule">
|
||||
<field name="name">Jet Waypoint Template: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"),
|
||||
("jet_template_id.manager_ids", "in", [user.id]),
|
||||
("create_uid", "=", user.id)
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Full Access -->
|
||||
<record id="rule_cx_tower_jet_waypoint_template_root_full" model="ir.rule">
|
||||
<field name="name">Jet Waypoint Template: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_jet_waypoint_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,99 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager Read Rules -->
|
||||
<record id="rule_key_manager_read_users" model="ir.rule">
|
||||
<field name="name">Key: Manager Read Access - Users/Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('user_ids', 'in', [user.id]), ('manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_manager_read_secret" model="ir.rule">
|
||||
<field name="name">Key: Manager Read Access - Secret Type</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('key_type', '=', 's')]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_manager_read_ssh" model="ir.rule">
|
||||
<field name="name">Key: Manager Read Access - SSH Key</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('key_type', '=', 'k'), '|',
|
||||
('server_ssh_ids.user_ids', 'in', [user.id]),
|
||||
('server_ssh_ids.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create Rules -->
|
||||
<record id="rule_key_manager_write_managers" model="ir.rule">
|
||||
<field name="name">Key: Manager Write/Create Access - Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_manager_write_ssh" model="ir.rule">
|
||||
<field name="name">Key: Manager Write/Create Access - SSH Key</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">['&', ('key_type', '=', 'k'),
|
||||
('server_ssh_ids.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Rules -->
|
||||
<record id="rule_key_manager_unlink_managers" model="ir.rule">
|
||||
<field name="name">Key: Manager Delete Access - Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_manager_unlink_ssh" model="ir.rule">
|
||||
<field name="name">Key: Manager Delete Access - SSH Key</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('key_type', '=', 'k'),
|
||||
('server_ssh_ids.manager_ids', 'in', [user.id]),
|
||||
('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Access Rule -->
|
||||
<record id="rule_key_root" model="ir.rule">
|
||||
<field name="name">Key: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,92 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager Read Rules -->
|
||||
<record id="rule_key_value_manager_read_key" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Read Access - Key Users/Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('key_id.user_ids', 'in', [user.id]), ('key_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_value_manager_read_server" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Key Value: Manager Read Access - Server Users/Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[('key_id.key_type', '=', 's'),
|
||||
'|', '|', ('server_id', '=', False),
|
||||
('server_id.user_ids', 'in', [user.id]),
|
||||
('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Write/Create Rules -->
|
||||
<record id="rule_key_value_manager_write_key" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Write/Create Access - Key Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[('key_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_value_manager_write_server" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Key Value: Manager Write/Create Access - Server Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Rules -->
|
||||
<record id="rule_key_value_manager_unlink_key" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Delete Access - Key Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('key_id.key_type', '=', 's'),('key_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="rule_key_value_manager_unlink_server" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Delete Access - Server Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('key_id.key_type', '=', 's'),('server_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Access Rule -->
|
||||
<record id="rule_key_value_root" model="ir.rule">
|
||||
<field name="name">Key Value: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,90 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read rule for cx.tower.plan_line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_user_read" model="ir.rule">
|
||||
<field name="name">Plan Line Action: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("plan_id.user_ids", "in", [user.id]),
|
||||
("plan_id.server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: read rule for cx.tower.plan.line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_manager_read" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- The domain requires:
|
||||
1. access_level <= "2"
|
||||
2. AND either the plan itself grants access via (user_ids OR manager_ids)
|
||||
OR there are no related servers
|
||||
OR a related server grants access via (server_ids.user_ids OR server_ids.manager_ids)
|
||||
-->
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: write & create rule for cx.tower.plan_line_action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_manager_write" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&", ("access_level", "<=", "2"), ("plan_id.manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: unlink rule for cx.tower.plan.line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("plan_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root: unrestricted access rule for cx.tower.plan.line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_root_full" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,90 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_user_read" model="ir.rule">
|
||||
<field name="name">Plan Line: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("plan_id.user_ids", "in", [user.id]),
|
||||
("plan_id.server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: read rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_manager_read" model="ir.rule">
|
||||
<field name="name">Plan Line: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- The domain requires:
|
||||
1. access_level <= "2"
|
||||
2. AND either the plan itself grants access via (user_ids OR manager_ids)
|
||||
OR there are no related servers
|
||||
OR a related server grants access via (server_ids.user_ids OR server_ids.manager_ids)
|
||||
-->
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: write & create rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_manager_write" model="ir.rule">
|
||||
<field name="name">Plan Line: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&", ("access_level", "<=", "2"), ("plan_id.manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: unlink rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Plan Line: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("plan_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root: unrestricted access rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_root_full" model="ir.rule">
|
||||
<field name="name">Plan Line: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
|
||||
<record id="rule_cx_tower_plan_log_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower plan log: user access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("create_uid", "=", user.id),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_plan_log_group_manager_read" model="ir.rule">
|
||||
<field name="name">Tower plan log: manager access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
"&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
|
||||
<record id="rule_cx_tower_plan_log_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower plan log: root access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="domain_force">[(1, "=", 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
@@ -1,90 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_user_read" model="ir.rule">
|
||||
<field name="name">Plan: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("user_ids", "in", [user.id]),
|
||||
("server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: read rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_manager_read" model="ir.rule">
|
||||
<field name="name">Plan: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- The domain requires:
|
||||
1. access_level <= "2"
|
||||
2. AND either the plan itself grants access via (user_ids OR manager_ids)
|
||||
OR there are no related servers
|
||||
OR a related server grants access via (server_ids.user_ids OR server_ids.manager_ids)
|
||||
-->
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("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])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: write & create rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_manager_write" model="ir.rule">
|
||||
<field name="name">Plan: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&", ("access_level", "<=", "2"), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: unlink rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Plan: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root: unrestricted access rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_root_full" model="ir.rule">
|
||||
<field name="name">Plan: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager: Read (see his records by user_ids or via server's user_ids/manager_ids) -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_manager_read" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: manager read access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
<field name="domain_force">
|
||||
['|',
|
||||
'|',
|
||||
('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])
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Create/Write -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_manager_write" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: manager write access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
<field name="domain_force">
|
||||
[('scheduled_task_id.manager_ids', 'in', [user.id])]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink (only if in manager_ids AND creator) -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_manager_unlink" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: manager unlink access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
('scheduled_task_id.manager_ids', 'in', [user.id]),
|
||||
('create_uid', '=', user.id)
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Root: Full access -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_root_full" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: root full access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,78 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager: Read (see his records by user_ids or via server's user_ids/manager_ids) -->
|
||||
<record id="cx_tower_scheduled_task_rule_manager_read" model="ir.rule">
|
||||
<field name="name">Scheduled Task: manager read access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
<field name="domain_force">
|
||||
['|',
|
||||
'|',
|
||||
('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])
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Create/Write -->
|
||||
<record id="cx_tower_scheduled_task_rule_manager_write" model="ir.rule">
|
||||
<field name="name">Scheduled Task: manager write access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
<field name="domain_force">
|
||||
[('manager_ids', 'in', [user.id])]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink (only if in manager_ids AND creator) -->
|
||||
<record id="cx_tower_scheduled_task_rule_manager_unlink" model="ir.rule">
|
||||
<field name="name">Scheduled Task: manager unlink access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
('manager_ids', 'in', [user.id]),
|
||||
('create_uid', '=', user.id)
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Root: Full access -->
|
||||
<record id="cx_tower_scheduled_task_rule_root_full" model="ir.rule">
|
||||
<field name="name">Scheduled Task: root full access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,206 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User access rule -->
|
||||
<record id="rule_cx_tower_server_log_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower server log: user access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager access rules -->
|
||||
<record id="rule_cx_tower_server_log_group_manager_read" model="ir.rule">
|
||||
<field name="name">Tower server log: manager read access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_server_log_group_manager_write" model="ir.rule">
|
||||
<field name="name">Tower server log: manager write access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_server_log_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Tower server log: manager unlink access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root access rule -->
|
||||
<record id="rule_cx_tower_server_log_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower server log: root access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="domain_force">[(1, "=", 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
|
||||
<!-- Jet-based User access rule -->
|
||||
<record id="rule_cx_tower_server_log_group_user_jet_read" model="ir.rule">
|
||||
<field name="name">Tower server log: user jet access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("jet_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Jet-based Manager access rules -->
|
||||
<record id="rule_cx_tower_server_log_group_manager_jet_read" model="ir.rule">
|
||||
<field name="name">Tower server log: manager jet read access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("jet_id.user_ids", "in", [user.id]),
|
||||
("jet_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_server_log_group_manager_jet_write" model="ir.rule">
|
||||
<field name="name">Tower server log: manager jet write access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("jet_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_server_log_group_manager_jet_unlink" model="ir.rule">
|
||||
<field name="name">Tower server log: manager jet unlink access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("jet_id.manager_ids", "in", [user.id]),
|
||||
("create_uid", "=", user.id)
|
||||
]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
<!-- Jet Template-based User access rule -->
|
||||
<record id="rule_cx_tower_server_log_group_user_jet_template_read" model="ir.rule">
|
||||
<field name="name">Tower server log: user jet template access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("jet_template_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Jet Template-based Manager access rules -->
|
||||
<record
|
||||
id="rule_cx_tower_server_log_group_manager_jet_template_read"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field
|
||||
name="name"
|
||||
>Tower server log: manager jet template read access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("jet_template_id.user_ids", "in", [user.id]),
|
||||
("jet_template_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_server_log_group_manager_jet_template_write"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field
|
||||
name="name"
|
||||
>Tower server log: manager jet template write access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("jet_template_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_server_log_group_manager_jet_template_unlink"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field
|
||||
name="name"
|
||||
>Tower server log: manager jet template unlink access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("jet_template_id.manager_ids", "in", [user.id]),
|
||||
("create_uid", "=", user.id)
|
||||
]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Access -->
|
||||
<record id="rule_cx_tower_server_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower Server: user visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<!-- allow read if the user is in Users -->
|
||||
<field name="domain_force">[('user_ids', 'in', [user.id])]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Access -->
|
||||
<!-- Rule 1: Read access if the current user is a follower or is in manager_ids -->
|
||||
<record id="rule_cx_tower_server_group_manager_read" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Tower Server: Manager Read (if follower or in manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- allow read if the user is in Users or Managers -->
|
||||
<field name="domain_force">
|
||||
['|', ('user_ids', 'in', [user.id]),
|
||||
('manager_ids', 'in', [user.id])]
|
||||
</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Rule 2: Write and Create access if the current user is in manager_ids -->
|
||||
<record id="rule_cx_tower_server_group_manager_write" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Tower Server: Manager Write & Create (if in manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- allow write/create only if the user is in the manager_ids many2many field -->
|
||||
<field name="domain_force">[('manager_ids', 'in', [user.id])]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Rule 3: Delete access if the current user is in manager_ids and is the creator -->
|
||||
<record id="rule_cx_tower_server_group_manager_unlink" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Tower Server: Manager Delete (if in manager_ids and creator)</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- allow deletion only if the user is in manager_ids and he is the record creator -->
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
|
||||
<field name="perm_read" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
|
||||
|
||||
<!-- Root Access -->
|
||||
<record id="rule_cx_tower_server_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower Server: root visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4,ref('group_root'))]" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user