Wipe addons/: full reset for clean re-upload
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user