Tower: upload cetmix_tower_server 18.0.2.0.0 (was 18.0.2.0.0, via marketplace)

This commit is contained in:
2026-05-03 18:54:38 +00:00
parent 5880120a84
commit c83da26305
235 changed files with 89704 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
from . import cx_tower_command_run_wizard
from . import cx_tower_plan_run_wizard
from . import cx_tower_server_template_create_wizard
from . import cx_tower_server_host_key_wizard
from . import cx_tower_jet_template_install_wizard
from . import cx_tower_jet_state_wizard
from . import cx_tower_jet_action_wizard
from . import cx_tower_jet_create_wizard
from . import cx_tower_jet_clone_wizard

View File

@@ -0,0 +1,564 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from ansi2html import Ansi2HTMLConverter
from odoo import _, api, fields, models
from odoo.exceptions import AccessError, ValidationError
from ..models.tools import generate_random_id
html_converter = Ansi2HTMLConverter(inline=True)
class CxTowerCommandRunWizard(models.TransientModel):
"""
Wizard to run a command on selected servers.
"""
_name = "cx.tower.command.run.wizard"
_inherit = "cx.tower.template.mixin"
_description = "Run Command in Wizard"
server_ids = fields.Many2many(
"cx.tower.server",
string="Servers",
compute="_compute_server_ids",
readonly=False,
required=True,
store=True,
)
jet_ids = fields.Many2many(
"cx.tower.jet",
string="Jets",
help="Jets to run the command on",
)
command_id = fields.Many2one(
"cx.tower.command",
)
note = fields.Text(related="command_id.note", readonly=True)
action = fields.Selection(
selection=[
("ssh_command", "SSH command"),
("python_code", "Python code"),
],
default="ssh_command",
required=True,
)
path = fields.Char(
compute="_compute_code",
readonly=False,
store=True,
help="Put custom path to run the command.\n"
"IMPORTANT: this field does NOT support variables!",
)
command_domain = fields.Binary(
compute="_compute_command_domain",
)
tag_ids = fields.Many2many(
comodel_name="cx.tower.tag",
string="Tags",
)
use_sudo = fields.Boolean(
string="Use sudo",
help="Will use sudo based on server settings."
"If no sudo is configured will run without sudo",
)
code = fields.Text(compute="_compute_code", readonly=False, store=True)
applicability = fields.Selection(
selection=[
("this", "For selected server(s)"),
("shared", "Non server restricted"),
],
default="shared",
required=True,
compute="_compute_show_servers",
readonly=False,
store=True,
help="Selected server(s): only Commands that are specific"
" to the selected server(s)\n"
"Non server restricted: all Commands that are "
"not specific to any server",
)
rendered_code = fields.Text(
compute="_compute_rendered_code",
compute_sudo=True,
)
result = fields.Html()
show_servers = fields.Boolean(
compute="_compute_show_servers",
store=True,
)
show_jets = fields.Boolean(
compute="_compute_show_jets",
compute_sudo=True,
)
os_compatibility_warning = fields.Text(
compute="_compute_os_compatibility_warning",
compute_sudo=True,
help="Warning about OS compatibility of the command",
)
command_variable_ids = fields.Many2many(
"cx.tower.variable",
related="command_id.variable_ids",
readonly=True,
string="Command Variables",
)
custom_variable_value_ids = fields.One2many(
"cx.tower.command.run.wizard.variable.value",
"wizard_id",
)
have_access_to_server = fields.Boolean(
compute="_compute_have_access_to_server",
)
has_missing_required_values = fields.Boolean(
compute="_compute_has_missing_required_values"
)
missing_required_variables_message = fields.Text(
compute="_compute_has_missing_required_values"
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if not self._is_privileged_user():
res["applicability"] = "this"
return res
@api.depends("jet_ids")
def _compute_server_ids(self):
for rec in self:
if rec.jet_ids:
rec.server_ids = rec.jet_ids.server_id
@api.depends("server_ids", "jet_ids")
def _compute_show_servers(self):
for rec in self:
rec.show_servers = (
bool(rec.server_ids and len(rec.server_ids) > 1)
and not rec.jet_ids
and not rec.result
)
@api.depends("jet_ids")
def _compute_show_jets(self):
for rec in self:
rec.show_jets = bool(rec.jet_ids and len(rec.jet_ids) > 1)
@api.depends("command_id", "server_ids", "action")
def _compute_code(self):
"""
Set code after change command
"""
for record in self:
if record.command_id and record.server_ids:
# Render code preview for the first server only.
record.update(
{
"code": record.command_id.code,
"path": record.server_ids[0]
._render_command(record.command_id)
.get("rendered_path"),
}
)
else:
record.update({"code": False, "path": False})
@api.depends("code", "server_ids", "action", "custom_variable_value_ids.value_char")
def _compute_rendered_code(self):
for record in self:
if record.server_ids and len(record.server_ids) == 1:
# Render code preview for the first server only.
if record.jet_ids:
server_id = record.jet_ids[0].server_id
else:
server_id = record.server_ids[0]
# Get variable list
variables = record.get_variables()
# Get variable values
variable_values = self.env[
"cx.tower.variable"
]._get_variable_values_by_references(
variables.get(str(record.id)),
server=server_id,
jet_template=record.jet_ids[0].jet_template_id
if record.jet_ids
else None,
jet=record.jet_ids[0] if record.jet_ids else None,
)
if variable_values and record.custom_variable_value_ids:
custom_vals = {
custom_value.variable_id.reference: custom_value.value_char
for custom_value in record.custom_variable_value_ids
if custom_value.variable_id
}
variable_values.update(custom_vals)
# Render template
if variable_values:
record.rendered_code = record.render_code(
pythonic_mode=record.action == "python_code",
**variable_values,
)[record.id] # pylint: disable=no-member
else:
record.rendered_code = record.code
else:
record.rendered_code = record.code
@api.depends("applicability", "server_ids", "tag_ids", "action")
def _compute_command_domain(self):
"""
Compose domain based on condition
"""
for record in self:
domain = [("action", "=", record.action)]
if record.applicability == "shared":
domain.append(("server_ids", "=", False))
elif record.applicability == "this":
domain.append(("server_ids", "in", record.server_ids.ids))
if record.tag_ids:
domain.append(("tag_ids", "in", record.tag_ids.ids))
record.command_domain = domain
@api.depends("command_id", "server_ids")
def _compute_os_compatibility_warning(self):
for wizard in self:
# Skip if command is not SSH command or no OS compatibility is defined
if (
not wizard.command_id
or not wizard.server_ids
or wizard.command_id.action != "ssh_command"
or not wizard.command_id.os_ids
):
wizard.os_compatibility_warning = False
continue
warning_list = []
for server in wizard.server_ids:
if server.os_id not in wizard.command_id.os_ids:
warning_list.append(
_(
"OS %(os)s used by the server '%(srv)s' is not present"
" in the command's OS compatibility list",
os=server.os_id.name,
srv=server.name,
)
)
wizard.os_compatibility_warning = (
"\n".join(warning_list) if warning_list else False
)
@api.depends("server_ids")
def _compute_have_access_to_server(self):
"""
Compute have_access_to_server field
"""
for record in self:
if not record.server_ids:
record.have_access_to_server = False
continue
record.have_access_to_server = all(
server._have_access_to_server("write") for server in record.server_ids
)
@api.depends(
"custom_variable_value_ids.value_char",
"custom_variable_value_ids.required",
)
def _compute_has_missing_required_values(self):
"""
Mark the wizard when at least one *required* variable
has an empty value **and** build a human-readable message.
"""
for wiz in self:
missing = wiz.custom_variable_value_ids.filtered(
lambda var_line: var_line.required and not var_line.value_char
)
wiz.has_missing_required_values = bool(missing)
wiz.missing_required_variables_message = (
_(
"Please provide values for the following "
"configuration variables: %(vars)s",
vars=", ".join(missing.mapped("variable_id.name")),
)
if missing
else False
)
@api.onchange("action", "applicability")
def _onchange_action(self):
"""
Reset command after change action
"""
self.command_id = False
@api.onchange("command_variable_ids", "server_ids")
def _onchange_command_variable_ids(self):
"""
Reset custom variable values after code change
"""
self.ensure_one()
# Remove existing custom variable values
self.custom_variable_value_ids = False
if (
self.jet_ids
or not self.command_variable_ids
or not self.server_ids
or len(self.server_ids) > 1
):
return
# Add new custom variable values
# Render values for the first server only.
server = self.server_ids[0]
# Get variable list
variables = self.get_variables()
# Get variable values
variable_values = self.env[
"cx.tower.variable"
]._get_variable_values_by_references(
variables.get(str(self.id)),
server=server._origin if hasattr(server, "_origin") else server,
)
# Filter variables current user has access to
command_variables = self.command_variable_ids.search(
[("id", "in", self.command_variable_ids.ids)]
)
self.custom_variable_value_ids = [
(
0,
0,
{
"variable_id": variable.id,
"value_char": variable_values.get(variable.reference),
"option_id": variable.option_ids.filtered(
lambda o, v=variable: o.value_char
== variable_values.get(v.reference)
).id
if variable.variable_type == "o"
else None,
"variable_value_id": server.variable_value_ids.filtered(
lambda v, var=variable: v.variable_id == var
)[:1].id,
},
)
for variable in command_variables
]
def action_run_command(self):
"""
Return wizard action to select command and execute it
"""
context = self.env.context.copy()
if self.jet_ids:
context["default_jet_ids"] = self.jet_ids.ids
else:
context["default_server_ids"] = self.server_ids.ids
return {
"type": "ir.actions.act_window",
"name": _("Run Command"),
"res_model": "cx.tower.command.run.wizard",
"view_mode": "form",
"target": "new",
"context": context,
}
def run_command_on_server(self):
"""Run command on selected servers or jets"""
self.ensure_one()
# Check if all required values are set
if self.has_missing_required_values:
raise ValidationError(self.missing_required_variables_message)
# Check if command is selected
if not self.command_id:
raise ValidationError(_("Please select a command to execute"))
# Generate custom label. Will be used later to locate the command log
log_label = generate_random_id(4)
path_value = (
self.env.user.has_group("cetmix_tower_server.group_manager") and self.path
)
# Add custom values for log
kwargs = {
"log": {"label": log_label},
"variable_values": {
value.variable_id.reference: value.value_char
for value in self.custom_variable_value_ids
},
}
if self.jet_ids:
for jet in self.jet_ids:
jet.run_command(
command=self.command_id,
sudo=self.use_sudo,
path=path_value,
**kwargs,
)
else:
for server in self.server_ids:
server.run_command(
command=self.command_id,
sudo=self.use_sudo,
path=path_value,
**kwargs,
)
return {
"type": "ir.actions.act_window",
"name": _("Command Log"),
"res_model": "cx.tower.command.log",
"view_mode": "list,form",
"target": "current",
"context": {"search_default_label": log_label},
}
def run_command_in_wizard(self):
"""
Runs a given code as is in wizard
"""
self.ensure_one()
# Check if multiple servers are selected
if len(self.server_ids) > 1:
raise ValidationError(
_("You cannot run custom code on multiple servers at once.")
)
# Check if multiple jets are selected
if len(self.jet_ids) > 1:
raise ValidationError(
_("You cannot run custom code on multiple jets at once.")
)
# From now we have one server or one jet selected
# Raise access error if non manager is trying to call this method
if not self._is_privileged_user():
raise AccessError(_("You are not allowed to execute commands in wizard"))
# Check if jet is currently executing an action
if self.jet_ids and self.jet_ids.current_action_id:
raise ValidationError(
_(
"Jet '%(jet)s' is currently executing an action",
jet=self.jet_ids.display_name,
)
)
if not self.command_id.allow_parallel_run:
running_count = (
self.env["cx.tower.command.log"]
.sudo()
.search_count(
[
("server_id", "in", self.server_ids.ids),
("command_id", "=", self.command_id.id),
("is_running", "=", True),
]
)
)
# Create log record and continue to the next one
# if the same command is currently running on the same server
# Log result
if running_count > 0:
raise ValidationError(
_("Another instance of the command is already running")
)
if not self.rendered_code:
raise ValidationError(_("You cannot execute an empty command"))
# check that we can execute the command for selected servers
command_servers = self.command_id.server_ids
if command_servers and not all(
[server in command_servers for server in self.server_ids]
):
raise ValidationError(_("Some servers don't support this command"))
result = ""
# Set the "no_split_for_sudo" property
no_split_for_sudo = bool(self.command_id and self.command_id.no_split_for_sudo)
for server in self.server_ids:
server_name = server.name
# Prepare key renderer values
key_vals = {
"server_id": server.id,
"partner_id": server.partner_id.id if server.partner_id else None,
}
kwargs = {
"key": key_vals,
"no_split_for_sudo": no_split_for_sudo,
"log": {
"jet_id": self.jet_ids and self.jet_ids[0].id
if self.jet_ids
else None,
"jet_template_id": self.jet_ids
and self.jet_ids[0].jet_template_id.id
if self.jet_ids
else None,
},
}
if self.action == "python_code":
command_result = server._run_python_code(
code=self.rendered_code, **kwargs
)
else:
command_result = server._run_command_using_ssh(
server._get_ssh_client(raise_on_error=True),
self.rendered_code,
self.path or None,
sudo=self.use_sudo and server.use_sudo,
**kwargs,
)
command_error = command_result["error"]
command_response = command_result["response"]
if command_error:
result = f"{result}\n[{server_name}]: ERROR: {command_error}"
if command_response:
result = f"{result}\n[{server_name}]: {command_response}"
if not result.endswith("\n"):
result = f"{result}\n"
if result:
self.result = html_converter.convert(result)
return {
"type": "ir.actions.act_window",
"name": _("Run Result"),
"res_model": "cx.tower.command.run.wizard",
"res_id": self.id, # pylint: disable=no-member
"view_mode": "form",
"target": "new",
}
def _is_privileged_user(self):
"""Return True if current user is in Manager or Root group."""
return self.env.user.has_group(
"cetmix_tower_server.group_manager"
) or self.env.user.has_group("cetmix_tower_server.group_root")
class CxTowerCommandRunWizardVariableValue(models.TransientModel):
"""
Custom variable values for command run wizard
"""
_inherit = "cx.tower.custom.variable.value.mixin"
_name = "cx.tower.command.run.wizard.variable.value"
_description = "Custom variable values for command run wizard"
variable_id = fields.Many2one(
readonly=True,
)
wizard_id = fields.Many2one(
"cx.tower.command.run.wizard",
string="Wizard",
)

View File

@@ -0,0 +1,213 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_command_run_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.command.run.wizard.view.form</field>
<field name="model">cx.tower.command.run.wizard</field>
<field name="arch" type="xml">
<form string="Run Command">
<field name="has_missing_required_values" invisible="1" />
<div
class="alert alert-warning"
role="alert"
invisible="action != 'python_code'"
style="margin-bottom:0px;"
>
<p>
Remember: Python code is executed on the Tower server, not on the remote one.
</p>
</div>
<field
name="os_compatibility_warning"
nolabel="1"
readonly="1"
invisible="not os_compatibility_warning"
class="alert alert-warning"
role="alert"
/>
<group>
<field name="show_jets" invisible="1" />
<field
name="jet_ids"
string="Run on"
widget="many2many_tags"
invisible="not show_jets"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
/>
<field name="show_servers" invisible="1" />
<field
name="server_ids"
widget="many2many_tags"
string="Run on"
invisible="not show_servers"
readonly="not jet_ids"
required="not jet_ids"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
/>
<label
for="applicability"
string="Show Commands"
invisible="result"
/>
<div class="o_row" invisible="result">
<field
name="applicability"
widget="radio"
options="{'horizontal': True}"
invisible="result"
readonly="show_servers"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
/>
<field
name="tag_ids"
widget="many2many_tags"
invisible="result"
placeholder="with tags"
options="{'no_create': True}"
/>
</div>
<label for="command_id" invisible="result" />
<div class="o_row" invisible="result">
<field name="action" nolabel="1" />
<span invisible="action != 'ssh_command'">sudo</span>
<field name="use_sudo" invisible="action != 'ssh_command'" />
<field
name="command_id"
domain="command_domain"
default_focus="1"
options="{'no_create': True}"
/>
<field name="command_domain" invisible="1" />
</div>
<field
name="path"
widget="ace_tower"
groups="cetmix_tower_server.group_manager"
invisible="action == 'python_code' or result"
placeholder="e.g. /home/user This field does NOT support variables"
/>
<field name="note" readonly="1" invisible="not note" />
</group>
<field name="result" readonly="1" invisible="not result" />
<notebook invisible="result" groups="cetmix_tower_server.group_manager">
<page name="code" string="Code">
<field
name="code"
widget="ace_tower"
options="{'mode': 'python'}"
groups="cetmix_tower_server.group_manager"
/>
<div
class="alert alert-danger"
role="alert"
invisible="not has_missing_required_values"
>
<span
>Fill in required configuration variables on the “Configuration Values” tab before you can run the command.</span>
</div>
</page>
<page
name="configuration_values"
string="Configuration Values"
invisible="not custom_variable_value_ids"
>
<field name="command_variable_ids" invisible="1" />
<field name="have_access_to_server" invisible="1" />
<div
class="alert alert-warning"
role="alert"
invisible="have_access_to_server"
style="margin-bottom:5px;"
>
You need 'Manager' access to the server to override the default configuration values.
Without this access, the server's configured values will be used.
</div>
<div
class="alert alert-warning"
role="alert"
style="margin-bottom:5px;"
>
Only values that the current user has access to are shown.
</div>
<field
name="custom_variable_value_ids"
force_save="1"
options="{'no_create': True, 'no_open': True}"
readonly="not have_access_to_server"
>
<list create="0" delete="0" editable="bottom">
<field name="variable_id" force_save="1" />
<field name="variable_type" column_invisible="True" />
<field
name="value_char"
readonly="variable_type != 's'"
/>
<field
name="option_id"
readonly="variable_type != 'o'"
options="{'no_create': True}"
/>
<field name="required" readonly="1" />
</list>
</field>
<div
class="alert alert-danger"
role="alert"
invisible="not has_missing_required_values"
>
<field
name="missing_required_variables_message"
readonly="1"
nolabel="1"
/>
</div>
</page>
<page name="preview" string="Preview" invisible="show_servers">
<field
name="rendered_code"
widget="ace"
options="{'mode':'python'}"
groups="cetmix_tower_server.group_manager"
/>
</page>
</notebook>
<footer>
<button
name="run_command_on_server"
type="object"
string="Run"
class="oe_highlight"
help="Run code using server method and log result"
invisible="result or not command_id or has_missing_required_values"
/>
<button
name="run_command_in_wizard"
type="object"
string="Run in wizard"
help="Run code as it appears in 'Rendered code' in wizard and return to wizard. Result will not be logged"
class="oe_highlight"
groups="cetmix_tower_server.group_manager"
invisible="result or not code or show_servers or has_missing_required_values"
/>
<button string="Cancel" special="cancel" />
<button
name="action_run_command"
type="object"
string="Run New Command"
class="oe_highlight"
invisible="not result"
/>
</footer>
</form>
</field>
</record>
<record id="cx_tower_command_run_wizard_action" model="ir.actions.act_window">
<field name="name">Cetmix Tower Run Command</field>
<field name="res_model">cx.tower.command.run.wizard</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="view_id" ref="cx_tower_command_run_wizard_view_form" />
<field name="context">{'default_server_ids': [id]}</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerJetActionWizard(models.TransientModel):
"""
Wizard to trigger jet actions.
"""
_name = "cx.tower.jet.action.wizard"
_description = "Trigger Jet Action Wizard"
action_id = fields.Many2one(
comodel_name="cx.tower.jet.action",
required=True,
domain="[('id', 'in', action_available_ids)]",
)
jet_ids = fields.Many2many(
comodel_name="cx.tower.jet",
readonly=True,
)
action_available_ids = fields.Many2many(
comodel_name="cx.tower.jet.action",
compute="_compute_available_actions",
help="Actions that are available for all selected jets",
)
@api.depends("jet_ids")
def _compute_available_actions(self):
"""Compute available actions based on selected jets"""
for wizard in self:
if not wizard.jet_ids:
wizard.action_available_ids = False
continue
# Get actions that are available to ALL selected jets
# Start with the first jet's available actions
first_jet = wizard.jet_ids[0]
available_actions = first_jet.action_available_ids
# Intersect with actions available to all other jets
for jet in wizard.jet_ids[1:]:
available_actions &= jet.action_available_ids
wizard.action_available_ids = available_actions
def action_confirm(self):
"""Trigger the action for the selected jets"""
for wizard in self:
if wizard.jet_ids and wizard.action_id:
for jet in wizard.jet_ids:
jet._trigger_action(wizard.action_id)
return {
"type": "ir.actions.act_window_close",
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_jet_action_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.jet.action.wizard.view.form</field>
<field name="model">cx.tower.jet.action.wizard</field>
<field name="arch" type="xml">
<form string="Trigger Jet Action">
<group>
<field
name="action_id"
options="{'no_create': True}"
placeholder="Select an action..."
domain="[('id', 'in', action_available_ids)]"
/>
<field name="action_available_ids" invisible="1" />
<field name="jet_ids" />
</group>
<footer>
<button
name="action_confirm"
string="Confirm"
type="object"
class="btn-primary"
invisible="not action_id or not jet_ids"
confirm="Trigger the action for the selected jets?"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<!-- Context Action for triggering jet actions -->
<record id="action_cx_tower_jet_trigger_action" model="ir.actions.act_window">
<field name="name">Trigger Action</field>
<field name="res_model">cx.tower.jet.action.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_cx_tower_jet" />
<field name="binding_view_types">list</field>
<field name="context">{'default_jet_ids': active_ids}</field>
</record>
</odoo>

View File

@@ -0,0 +1,140 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerJetCloneWizard(models.TransientModel):
"""Clone jet"""
_name = "cx.tower.jet.clone.wizard"
_description = "Clone jet"
jet_id = fields.Many2one(
"cx.tower.jet",
required=True,
readonly=True,
)
jet_template_id = fields.Many2one(
"cx.tower.jet.template",
related="jet_id.jet_template_id",
readonly=True,
)
same_server = fields.Selection(
selection=[("y", "Yes"), ("n", "No")],
default="y",
required=True,
)
server_id = fields.Many2one(
"cx.tower.server",
domain="[('jet_template_ids', 'in', jet_template_id)]",
)
partner_id = fields.Many2one(
"res.partner",
compute="_compute_partner_id",
store=True,
readonly=False,
help="Partner associated with the cloned jet",
)
name = fields.Char(help="The name of the new jet")
name_type = fields.Selection(
selection=[("a", "will be auto-generated"), ("m", "I will put myself")],
default="a",
required=True,
)
url = fields.Char(help="The URL of the jet")
url_type = fields.Selection(
selection=[("a", "will be auto-generated"), ("m", "I will put myself")],
default="a",
required=True,
)
state_id = fields.Many2one(
"cx.tower.jet.state", required=True, help="Requested state of the jet"
)
state_domain = fields.Binary(compute="_compute_state_domain")
use_custom_variables = fields.Selection(
selection=[("n", "default settings"), ("y", "custom settings")],
default="n",
required=True,
)
line_ids = fields.One2many(
"cx.tower.jet.clone.wizard.variable.line",
"wizard_id",
string="Variable Lines",
)
@api.depends("jet_id")
def _compute_partner_id(self):
"""
Compute the partner associated with the cloned jet
"""
for wizard in self:
if wizard.partner_id:
continue
if wizard.jet_id and wizard.jet_id.partner_id:
wizard.partner_id = wizard.jet_id.partner_id.id
@api.depends("jet_template_id")
def _compute_state_domain(self):
"""
Compute the domain for the states
"""
for wizard in self:
if not wizard.jet_id:
wizard.state_domain = []
continue
wizard.state_domain = [
("id", "in", wizard.jet_template_id.action_ids.state_to_id.ids)
]
def action_confirm(self):
"""
Clone the jet
"""
self.ensure_one()
kwargs = {}
# Add custom variables
custom_variables = {}
if self.line_ids:
custom_variables = {
line.variable_id.reference: line.value_char for line in self.line_ids
}
if custom_variables:
kwargs["variable_values"] = custom_variables
# Add partner
if self.partner_id:
kwargs["partner_id"] = self.partner_id.id
# Add url
if self.url_type == "m" and self.url:
kwargs["url"] = self.url
jet = self.jet_id.clone(
server=self.server_id,
name=self.name,
state=self.state_id,
**kwargs,
)
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.jet",
"res_id": jet.id,
"view_mode": "form",
"target": "current",
}
class CxTowerJetCloneWizardVariableLine(models.TransientModel):
"""Custom variable values for jet create wizard"""
_name = "cx.tower.jet.clone.wizard.variable.line"
_inherit = "cx.tower.custom.variable.value.mixin"
_description = "Variable lines"
wizard_id = fields.Many2one("cx.tower.jet.clone.wizard")
# Override from mixin to make variable_id editable
variable_id = fields.Many2one(
readonly=False,
)

View File

@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_jet_clone_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.jet.clone.wizard.view.form</field>
<field name="model">cx.tower.jet.clone.wizard</field>
<field name="arch" type="xml">
<form string="Clone Jet">
<group>
<label for="jet_id" string="Clone" />
<div class="o_row">
<field name="jet_id" />
<span class="o_form_label">for</span>
<field
name="partner_id"
placeholder="select a partner if required"
/>
</div>
<field name="jet_template_id" invisible="1" />
<field
name="same_server"
widget="radio"
string="on the same server"
invisible="not jet_id"
/>
<field
name="server_id"
string="to"
invisible="same_server == 'y'"
required="same_server == 'n'"
/>
<field
name="state_id"
string="in the state"
invisible="not jet_id"
options="{'no_create': True, 'no_create_edit': True}"
domain="state_domain"
/>
<field name="state_domain" invisible="1" />
<label for="name_type" string="with the name that" />
<div class="o_row">
<field
name="name_type"
widget="radio"
string="with the name that"
/>
<field
name="name"
placeholder="put the name here"
invisible="name_type in ('a', False)"
required="name_type == 'm'"
/>
</div>
<label for="url_type" string="with the URL that" />
<div class="o_row">
<field name="url_type" widget="radio" />
<field
name="url"
placeholder="eg 'https://myjet.example.com'"
invisible="url_type in ('a', False)"
required="url_type == 'm'"
/>
</div>
<field
name="use_custom_variables"
widget="radio"
string="that will use"
/>
</group>
<field name="line_ids" invisible="use_custom_variables == 'n'">
<list editable="bottom">
<field name="variable_id" />
<field name="variable_type" column_invisible="True" />
<field
name="value_char"
readonly="variable_type == 'o'"
required="variable_type == 's'"
/>
<field
name="option_id"
options="{'no_create': True, 'no_create_edit': True}"
readonly="variable_type == 's'"
required="variable_type == 'o'"
/>
</list>
</field>
<footer>
<button
name="action_confirm"
string="Confirm"
confirm="Jet will be cloned. Are you sure?"
type="object"
class="btn-primary"
invisible="not jet_id or not state_id or not use_custom_variables or not name_type"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<!-- Context Action for cloning jets -->
<record id="action_cx_tower_jet_clone" model="ir.actions.act_window">
<field name="name">Clone</field>
<field name="res_model">cx.tower.jet.clone.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_cx_tower_jet" />
<field name="binding_view_types">form</field>
<field name="context">{'default_jet_id': active_id}</field>
</record>
</odoo>

View File

@@ -0,0 +1,206 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerJetCreateWizard(models.TransientModel):
"""Create new jet from template"""
_name = "cx.tower.jet.create.wizard"
_description = "Create new jet"
name = fields.Char(help="The name of the jet")
name_type = fields.Selection(
selection=[("a", "will be auto-generated"), ("m", "I will put myself")],
default="a",
required=True,
)
note = fields.Text(related="jet_template_id.note", readonly=True)
url = fields.Char(help="The URL of the jet")
url_type = fields.Selection(
selection=[("a", "will be auto-generated"), ("m", "I will put myself")],
default="a",
required=True,
)
partner_id = fields.Many2one(
"res.partner",
compute="_compute_partner_id",
store=True,
readonly=False,
help="Partner associated with the jet",
)
jet_template_id = fields.Many2one(
"cx.tower.jet.template",
required=True,
)
jet_template_domain = fields.Binary(
compute="_compute_jet_template_domain",
help="Domain for jet template",
)
jet_template_message = fields.Text(
compute="_compute_jet_template_domain",
help="Message for the user",
)
server_domain = fields.Binary(
compute="_compute_server_domain",
help="Domain for server",
)
server_id = fields.Many2one(
"cx.tower.server",
)
state_id = fields.Many2one("cx.tower.jet.state", help="Requested state of the jet")
state_domain = fields.Binary(compute="_compute_state_domain")
use_custom_variables = fields.Selection(
selection=[("n", "default settings"), ("y", "custom settings")],
default="n",
required=True,
)
line_ids = fields.One2many(
"cx.tower.jet.create.wizard.variable.line",
"wizard_id",
string="Variable Lines",
)
@api.depends("server_id")
def _compute_partner_id(self):
"""
Compute the partner associated with the jet
"""
for wizard in self:
# Do not modify partner if it is already set
if wizard.partner_id:
continue
# Set partner from server
if wizard.server_id and wizard.server_id.partner_id:
wizard.partner_id = wizard.server_id.partner_id.id
@api.depends("server_id")
def _compute_jet_template_domain(self):
"""
Compute the domain and message for the jet templates
"""
template_obj = self.env["cx.tower.jet.template"]
all_templates_domain = [("show_in_create_wizard", "=", True)]
all_templates = template_obj.search(all_templates_domain)
for wizard in self:
if not all_templates:
wizard.jet_template_message = _(
"No jet templates are currently configured as 'Show in Wizard'."
" Please check your jet template settings."
)
wizard.jet_template_domain = all_templates_domain
continue
if not wizard.server_id:
# All templates that can be shown in the create wizard
jet_template_message = False
jet_template_domain = all_templates_domain
else:
# All templates that can be shown in the create wizard and
# are installed on the selected server
jet_template_domain = [
("show_in_create_wizard", "=", True),
("server_ids", "in", wizard.server_id.ids),
]
available_templates = all_templates.filtered_domain(jet_template_domain)
if not available_templates:
jet_template_message = _(
"No jet templates configured as 'Show in Wizard' are"
" installed on the selected server."
" Please check your jet template settings."
)
else:
jet_template_message = False
# Set the domain and message
wizard.jet_template_domain = jet_template_domain
wizard.jet_template_message = jet_template_message
@api.depends("jet_template_id")
def _compute_server_domain(self):
"""
Compute the domain for the servers
"""
for wizard in self:
if not wizard.jet_template_id:
wizard.server_domain = []
continue
wizard.server_domain = [("id", "in", wizard.jet_template_id.server_ids.ids)]
@api.depends("jet_template_id")
def _compute_state_domain(self):
"""
Compute the domain for the states
"""
for wizard in self:
if not wizard.jet_template_id:
wizard.state_domain = []
continue
wizard.state_domain = [
("id", "in", wizard.jet_template_id.action_ids.state_to_id.ids)
]
def action_confirm(self):
"""
Create a new jet
"""
self.ensure_one()
# Check if server is selected
if not self.server_id:
raise ValidationError(_("Please select a server to create a jet."))
kwargs = {}
# Add custom variables
variable_values = {}
if self.use_custom_variables == "y" and self.line_ids:
variable_values = {
line.variable_id.reference: line.value_char for line in self.line_ids
}
kwargs["variable_values"] = variable_values
# Add partner
if self.partner_id:
kwargs["partner_id"] = self.partner_id.id
# Add url
if self.url_type == "m" and self.url:
kwargs["url"] = self.url
jet = self.jet_template_id.create_jet(
self.server_id,
name=self.name,
state=self.state_id,
**kwargs,
)
if not jet:
raise ValidationError(
_(
"Failed to create jet. "
"Please check the server and template settings."
)
)
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.jet",
"res_id": jet.id,
"view_mode": "form",
"target": "current",
}
class CxTowerJetCreateWizardVariableLine(models.TransientModel):
"""Custom variable values for jet create wizard"""
_name = "cx.tower.jet.create.wizard.variable.line"
_inherit = "cx.tower.custom.variable.value.mixin"
_description = "Variable lines"
wizard_id = fields.Many2one("cx.tower.jet.create.wizard")
# Override from mixin to make variable_id editable
variable_id = fields.Many2one(
readonly=False,
)

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_jet_create_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.jet.create.wizard.view.form</field>
<field name="model">cx.tower.jet.create.wizard</field>
<field name="arch" type="xml">
<form string="Create Jet">
<field
name="jet_template_message"
invisible="not jet_template_message"
class="alert alert-warning"
role="alert"
/>
<field name="note" invisible="not note" />
<group>
<field
name="jet_template_id"
string="I want a new"
domain="jet_template_domain"
options="{'no_create': True, 'no_create_edit': True}"
/>
<field name="jet_template_domain" invisible="1" />
<label
for="server_id"
string="on the server"
invisible="not jet_template_id"
/>
<div class="o_row" invisible="not jet_template_id">
<field
name="server_id"
string="on the server"
domain="server_domain"
options="{'no_create': True, 'no_create_edit': True}"
/>
<span class="o_form_label">for</span>
<field
name="partner_id"
placeholder="select a partner if required"
/>
</div>
<field name="server_domain" invisible="1" />
<field
name="state_id"
string="in the state"
invisible="not jet_template_id or not server_id"
options="{'no_create': True, 'no_create_edit': True}"
domain="state_domain"
/>
<field name="state_domain" invisible="1" />
<label
for="name_type"
string="with the name that"
invisible="not state_id"
/>
<div class="o_row">
<field
name="name_type"
widget="radio"
invisible="not state_id"
/>
<field
name="name"
placeholder="put the name here"
invisible="not state_id or name_type in ('a', False)"
required="name_type == 'm'"
/>
</div>
<label
for="url_type"
string="with the URL that"
invisible="not state_id"
/>
<div class="o_row">
<field
name="url_type"
widget="radio"
invisible="not state_id"
/>
<field
name="url"
placeholder="eg 'https://myjet.example.com'"
invisible="not state_id or url_type in ('a', False)"
required="url_type == 'm'"
/>
</div>
<field
name="use_custom_variables"
widget="radio"
invisible="not state_id"
string="that will use"
/>
</group>
<field name="line_ids" invisible="use_custom_variables == 'n'">
<list editable="bottom">
<field name="variable_id" />
<field name="variable_type" column_invisible="True" />
<field
name="value_char"
readonly="variable_type == 'o'"
required="variable_type == 's'"
/>
<field
name="option_id"
options="{'no_create': True, 'no_create_edit': True}"
readonly="variable_type == 's'"
required="variable_type == 'o'"
/>
</list>
</field>
<footer>
<button
name="action_confirm"
string="Confirm"
confirm="New Jet will be created. Are you sure?"
type="object"
class="btn-primary"
invisible="(not jet_template_id or not server_id or not state_id or not use_custom_variables or not name_type or name_type == 'm') and not name"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<!-- Context Action for creating new jets -->
<record id="action_cx_tower_jet_create" model="ir.actions.act_window">
<field name="name">Launch New Jet</field>
<field name="res_model">cx.tower.jet.create.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,79 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerJetStateWizard(models.TransientModel):
"""
Wizard to set state for selected jets.
"""
_name = "cx.tower.jet.state.wizard"
_description = "Set Jet State Wizard"
jet_ids = fields.Many2many(
comodel_name="cx.tower.jet",
string="Jets",
required=True,
readonly=True,
)
state_id = fields.Many2one(
comodel_name="cx.tower.jet.state",
required=True,
domain="[('id', 'in', available_state_ids)]",
)
available_state_ids = fields.Many2many(
comodel_name="cx.tower.jet.state",
string="Available States",
compute="_compute_available_states",
help="States that appear in the 'state_to' field "
"of jet templates of all selected jets",
)
@api.depends("jet_ids", "jet_ids.jet_template_id.action_ids.state_to_id")
def _compute_available_states(self):
"""Compute available states based on selected jets' templates"""
# Used as a placeholder for no available states
state_obj = self.env["cx.tower.jet.state"]
for wizard in self:
if not wizard.jet_ids:
wizard.available_state_ids = False
continue
# Get states that are available to ALL selected jets
# Start with the first jet's available states
first_jet = wizard.jet_ids[0]
if not first_jet.jet_template_id.action_ids:
wizard.available_state_ids = False
continue
available_states = first_jet.jet_template_id.action_ids.mapped(
"state_to_id"
)
# Intersect with states available to all other jets
for jet in wizard.jet_ids[1:]:
actions = jet.jet_template_id.action_ids
# If no actions, no available states
if not actions:
available_states = state_obj
break
jet_states = actions.mapped("state_to_id")
available_states &= jet_states
# Remove current state from available states if only one jet is selected
if len(wizard.jet_ids) == 1:
available_states -= wizard.jet_ids.state_id
wizard.available_state_ids = available_states
def action_confirm(self):
"""Bring the jets to the target state"""
for wizard in self:
if wizard.jet_ids and wizard.state_id:
for jet in wizard.jet_ids:
jet.bring_to_state(wizard.state_id.reference)

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_jet_state_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.jet.state.wizard.view.form</field>
<field name="model">cx.tower.jet.state.wizard</field>
<field name="arch" type="xml">
<form string="Set Jet State">
<div class="alert alert-info" role="alert">
<p>
Select a state to set for the selected jets. Only states that appear in the
"State to" field of jet templates of all selected jets are available.
</p>
</div>
<group>
<field
name="state_id"
options="{'no_create': True}"
default_focus="1"
placeholder="Select a state..."
/>
<field name="available_state_ids" invisible="1" />
</group>
<field name="jet_ids" />
<footer>
<button
name="action_confirm"
string="Confirm"
type="object"
class="btn-primary"
invisible="not jet_ids or not state_id"
confirm="State of selected jets will be changed. Are you sure?"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<!-- Context Action for bringing jets to state -->
<record id="action_cx_tower_jet_bring_to_state" model="ir.actions.act_window">
<field name="name">Bring to State</field>
<field name="res_model">cx.tower.jet.state.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_cx_tower_jet" />
<field name="binding_view_types">list</field>
<field name="context">{'default_jet_ids': active_ids}</field>
</record>
</odoo>

View File

@@ -0,0 +1,72 @@
# Copyright 2025 Cetmix OÜ
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0)
from odoo import api, fields, models
class CxTowerJetTemplateInstallWizard(models.TransientModel):
"""
Wizard to install a Jet Template on selected servers.
"""
_name = "cx.tower.jet.template.install.wiz"
_description = "Install Jet Template on Selected Servers"
jet_template_id = fields.Many2one(
"cx.tower.jet.template",
required=True,
)
server_ids = fields.Many2many(
"cx.tower.server",
string="Servers",
)
jet_template_domain = fields.Binary(
compute="_compute_jet_template_domain",
)
server_domain = fields.Binary(
compute="_compute_server_domain",
)
@api.depends("server_ids", "server_ids.jet_template_ids")
def _compute_jet_template_domain(self):
"""
Show only templates that are not installed on the selected server.
"""
for wizard in self:
if wizard.server_ids and len(wizard.server_ids) == 1:
server = wizard.server_ids[0]
templates_installed = server.jet_template_ids
wizard.jet_template_domain = [("id", "not in", templates_installed.ids)]
else:
wizard.jet_template_domain = []
@api.depends("jet_template_id", "jet_template_id.server_ids")
def _compute_server_domain(self):
"""
Show only servers where the template is not installed.
"""
for wizard in self:
if wizard.jet_template_id:
servers_installed = wizard.jet_template_id.server_ids
wizard.server_domain = (
[("id", "not in", servers_installed.ids)]
if servers_installed
else []
)
else:
wizard.server_domain = []
def action_install_template(self):
"""
Install the Jet Template on the selected servers.
"""
if self.server_ids:
self.jet_template_id.install_on_servers(self.server_ids)
# Close the wizard
return {
"type": "ir.actions.act_window_close",
"params": {
"next": {"type": "ir.actions.client", "tag": "soft_reload"},
},
}

View File

@@ -0,0 +1,49 @@
<odoo>
<record id="cx_tower_jet_template_install_wiz_view_form" model="ir.ui.view">
<field name="name">cx.tower.jet.template.install.wiz.form</field>
<field name="model">cx.tower.jet.template.install.wiz</field>
<field name="arch" type="xml">
<form string="Install Jet Template">
<group>
<field
name="jet_template_id"
placeholder="Select a jet template"
domain="jet_template_domain"
/>
</group>
<field
name="server_ids"
required="1"
default_focus="1"
domain="server_domain"
options="{'no_create': True, 'no_create_edit': True}"
/>
<field name="server_domain" invisible="1" />
<field name="jet_template_domain" invisible="1" />
<footer>
<button
name="action_install_template"
type="object"
string="Install"
class="oe_highlight"
invisible="not server_ids"
confirm="Are you sure you want to install the template on the selected servers?"
/>
<button string="Cancel" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="cx_tower_jet_template_install_wiz_action" model="ir.actions.act_window">
<field name="name">Install on Servers</field>
<field name="res_model">cx.tower.jet.template.install.wiz</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="view_id" ref="cx_tower_jet_template_install_wiz_view_form" />
<field name="context">{'default_jet_template_id': active_id}</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_cx_tower_jet_template" />
<field name="binding_view_types">form,list</field>
</record>
</odoo>

View File

@@ -0,0 +1,178 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from ..models.tools import generate_random_id
class CxTowerPlanRunWizard(models.TransientModel):
"""
Wizard to run a flight plan on selected servers.
"""
_name = "cx.tower.plan.run.wizard"
_description = "Run Flight Plan in Wizard"
server_ids = fields.Many2many(
"cx.tower.server",
string="Servers",
required=True,
compute="_compute_server_ids",
readonly=False,
store=True,
)
jet_ids = fields.Many2many(
"cx.tower.jet",
string="Jets",
)
plan_id = fields.Many2one(
string="Flight Plan",
comodel_name="cx.tower.plan",
required=True,
)
note = fields.Text(related="plan_id.note", readonly=True)
plan_domain = fields.Binary(
compute="_compute_plan_domain",
)
tag_ids = fields.Many2many(
comodel_name="cx.tower.tag",
string="Tags",
)
applicability = fields.Selection(
selection=[
("this", "For selected server(s)"),
("shared", "Non server restricted"),
],
default="shared",
required=True,
compute="_compute_show_servers",
readonly=False,
store=True,
help="Selected server(s): only Flight Plans that are specific"
" to the selected server(s)\n"
"Non server restricted: all Flight Plans that are "
"not specific to any server",
)
# Lines
plan_line_ids = fields.One2many(
string="Commands",
comodel_name="cx.tower.plan.line",
compute="_compute_plan_line_ids",
compute_sudo=True,
groups="cetmix_tower_server.group_manager",
)
show_servers = fields.Boolean(
compute="_compute_show_servers",
store=True,
)
show_jets = fields.Boolean(
compute="_compute_show_jets",
compute_sudo=True,
)
custom_variable_value_ids = fields.One2many(
"cx.tower.plan.run.wizard.variable.value",
"wizard_id",
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if not self._is_privileged_user():
res["applicability"] = "this"
return res
@api.depends("jet_ids")
def _compute_server_ids(self):
for rec in self:
if rec.jet_ids:
rec.server_ids = rec.jet_ids.server_id
@api.depends("server_ids")
def _compute_show_servers(self):
for rec in self:
rec.show_servers = (
bool(rec.server_ids and len(rec.server_ids) > 1) and not rec.jet_ids
)
@api.depends("jet_ids")
def _compute_show_jets(self):
for rec in self:
rec.show_jets = bool(rec.jet_ids and len(rec.jet_ids) > 1)
@api.depends("plan_id")
def _compute_plan_line_ids(self):
"""Sel lines in wizard based on selected plan"""
for rec in self:
if rec.plan_id and rec.plan_id.line_ids:
rec.plan_line_ids = rec.plan_id.line_ids
else:
rec.plan_line_ids = None
@api.depends("applicability", "server_ids", "tag_ids")
def _compute_plan_domain(self):
"""Compose domain based on condition"""
for record in self:
domain = []
if record.applicability == "shared":
domain = [("server_ids", "=", False)]
elif record.applicability == "this":
domain.append(("server_ids", "in", record.server_ids.ids))
if record.tag_ids:
domain.append(("tag_ids", "in", record.tag_ids.ids))
record.plan_domain = domain
@api.onchange("applicability")
def _onchange_applicability(self):
"""Reset plan after change record type"""
self.plan_id = False
def run_flight_plan(self):
"""Run flight plan for selected servers"""
if self.plan_id and self.server_ids:
# Generate custom label. Will be used later to locate the command log
plan_label = generate_random_id(4)
# Add custom values for log
variable_values = {
value.variable_id.reference: value.value_char
for value in self.custom_variable_value_ids
}
custom_values = {
"plan_log": {"label": plan_label},
"variable_values": variable_values,
}
if self.jet_ids:
for jet in self.jet_ids:
jet.run_flight_plan(self.plan_id, **custom_values)
else:
for server in self.server_ids:
server.run_flight_plan(self.plan_id, **custom_values)
return {
"type": "ir.actions.act_window",
"name": _("Plan Log"),
"res_model": "cx.tower.plan.log",
"view_mode": "list,form",
"target": "current",
"context": {"search_default_label": plan_label},
}
def _is_privileged_user(self):
"""Return True if current user is in Manager or Root group."""
return self.env.user.has_group(
"cetmix_tower_server.group_manager"
) or self.env.user.has_group("cetmix_tower_server.group_root")
class CxTowerPlanRunWizardVariableValue(models.TransientModel):
"""
Custom variable values for flight plan run wizard
"""
_inherit = "cx.tower.custom.variable.value.mixin"
_name = "cx.tower.plan.run.wizard.variable.value"
_description = "Custom variable values for plan run wizard"
wizard_id = fields.Many2one(
"cx.tower.plan.run.wizard",
string="Wizard",
)

View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_plan_run_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.plan.run.wizard.view.form</field>
<field name="model">cx.tower.plan.run.wizard</field>
<field name="arch" type="xml">
<form string="Run Plan">
<group>
<field name="show_jets" invisible="1" />
<field
name="jet_ids"
string="Run on"
widget="many2many_tags"
invisible="not show_jets"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
/>
<field name="show_servers" invisible="1" />
<field
name="server_ids"
widget="many2many_tags"
string="Run on"
invisible="not show_servers"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
/>
<label for="applicability" string="Show Flight Plans" />
<div class="o_row">
<field
name="applicability"
widget="radio"
options="{'horizontal': True}"
readonly="show_servers"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
/>
<field
name="tag_ids"
widget="many2many_tags"
placeholder="with tags"
options="{'no_create': True}"
/>
</div>
<label for="plan_id" />
<div class="o_row">
<field
name="plan_id"
domain="plan_domain"
default_focus="1"
options="{'no_create': True}"
/>
<field name="plan_domain" invisible="1" />
</div>
<field name="note" readonly="1" invisible="not note" />
</group>
<notebook>
<page name="commands" string="Commands" invisible="not plan_id">
<field name="plan_line_ids" invisible="not plan_id">
<list decoration-bf="action=='plan'">
<field name="name" />
<field name="action" optional="show" />
<field
name="tag_ids"
optional="show"
groups="cetmix_tower_server.group_manager"
widget="many2many_tags"
options="{'color_field': 'color'}"
/>
</list>
</field>
</page>
<page
name="configuration_values"
string="Configuration Values"
invisible="not plan_id"
>
<field name="custom_variable_value_ids">
<list editable="bottom">
<field name="variable_id" />
<field name="variable_type" column_invisible="True" />
<field
name="value_char"
readonly="variable_type != 's'"
/>
<field
name="option_id"
readonly="variable_type != 'o'"
options="{'no_create': True}"
/>
</list>
</field>
</page>
</notebook>
<footer>
<button
name="run_flight_plan"
type="object"
string="Run"
class="oe_highlight"
/>
<button string="Cancel" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="cx_tower_plan_run_wizard_action" model="ir.actions.act_window">
<field name="name">Cetmix Tower Run Flight Plan</field>
<field name="res_model">cx.tower.plan.run.wizard</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="view_id" ref="cx_tower_plan_run_wizard_view_form" />
<field name="context">{'default_server_ids': [id]}</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,30 @@
# Copyright 2025 Cetmix Oy
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0).
from odoo import _, fields, models
class CxTowerServerHostKeyWizard(models.TransientModel):
"""Wizard to show host key"""
_name = "cx.tower.server.host.key.wizard"
_description = "Show Host Key"
is_error = fields.Boolean()
host_key = fields.Char()
server_id = fields.Many2one("cx.tower.server")
def action_insert_host_key(self):
"""Show the host key"""
self.ensure_one()
self.server_id.write({"host_key": self.host_key, "skip_host_key": False})
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"type": "success",
"title": _("Host Key"),
"message": _("Key inserted successfully!"),
"next": {"type": "ir.actions.act_window_close"},
},
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- pylint:disable=duplicate-xml-fields -->
<odoo>
<record id="view_cx_tower_server_host_key_wizard_form" model="ir.ui.view">
<field name="name">cx.tower.server.host.key.wizard.form</field>
<field name="model">cx.tower.server.host.key.wizard</field>
<field name="arch" type="xml">
<form string="Host Key">
<field name="is_error" invisible="1" />
<div class="alert alert-warning" role="status" invisible="is_error">
Check the key before inserting in the server settings. Do not insert the key if you have any doubts!
</div>
<field name="host_key" readonly="1" invisible="is_error" />
<field
name="host_key"
readonly="1"
invisible="not is_error"
class="alert alert-danger"
role="status"
/>
<footer>
<button
name="action_insert_host_key"
type="object"
string="Insert Key"
class="btn-primary"
invisible="is_error"
confirm="I confirm that the key is correct and I want to insert it in the server settings"
/>
<button special="cancel" string="Close" class="btn-secondary" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,247 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
class CxTowerServerTemplateCreateWizard(models.TransientModel):
"""Create new server from template"""
_name = "cx.tower.server.template.create.wizard"
_description = "Create new server from template"
server_template_id = fields.Many2one(
"cx.tower.server.template",
string="Server Template",
readonly=True,
)
name = fields.Char(
string="Server Name",
required=True,
)
partner_id = fields.Many2one(
"res.partner",
)
color = fields.Integer(help="For better visualization in views")
os_id = fields.Many2one(
string="Operating System",
comodel_name="cx.tower.os",
)
tag_ids = fields.Many2many(
comodel_name="cx.tower.tag",
string="Tags",
)
ip_v4_address = fields.Char(string="IPv4 Address")
ip_v6_address = fields.Char(string="IPv6 Address")
ssh_port = fields.Integer(string="SSH port", default=22)
ssh_username = fields.Char(
string="SSH Username",
required=True,
help="This is required, however you can change this later "
"in the server settings",
)
ssh_password = fields.Char(string="SSH Password")
ssh_key_id = fields.Many2one(
comodel_name="cx.tower.key",
string="SSH Private Key",
domain=[("key_type", "=", "k")],
)
ssh_auth_mode = fields.Selection(
string="SSH Auth Mode",
selection=[
("p", "Password"),
("k", "Key"),
],
default="p",
required=True,
)
use_sudo = fields.Selection(
string="Use sudo",
selection=[("n", "Without password"), ("p", "With password")],
help="Run commands using 'sudo'",
)
host_key = fields.Char(
help="Host key to verify the server",
)
skip_host_key = fields.Boolean(
string="Don't Check Key",
help="Enable to skip host key verification",
)
line_ids = fields.One2many(
comodel_name="cx.tower.server.template.create.wizard.line",
inverse_name="wizard_id",
string="Configuration Variables",
)
has_missing_required_values = fields.Boolean(
compute="_compute_has_missing_required_values",
)
missing_required_variables = fields.Text(
compute="_compute_missing_required_variables_message",
)
missing_required_variables_message = fields.Text(
compute="_compute_missing_required_variables_message",
)
@api.depends("line_ids.value_char", "line_ids.required")
def _compute_has_missing_required_values(self):
"""
Compute whether there are required variables with missing values.
"""
for wizard in self:
missing_vars = wizard.line_ids.filtered(
lambda line: line.required and not line.value_char
)
wizard.has_missing_required_values = bool(missing_vars)
wizard.missing_required_variables = ", ".join(
missing_vars.mapped("variable_id.name")
)
@api.depends("has_missing_required_values")
def _compute_missing_required_variables_message(self):
"""
Computes the user-friendly message for missing required variables.
"""
for wizard in self:
if wizard.has_missing_required_values and wizard.missing_required_variables:
wizard.missing_required_variables_message = _(
"Please provide values for the following "
"configuration variables: %(variables)s",
variables=wizard.missing_required_variables,
)
else:
wizard.missing_required_variables_message = False
def action_confirm(self):
"""
Create and open new created server from template
"""
self.ensure_one()
kwargs = self._prepare_server_parameters()
server = self.server_template_id._create_new_server(
self.name, pick_all_template_variables=False, **kwargs
)
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_server"
)
action.update(
{"view_mode": "form", "res_id": server.id, "views": [(False, "form")]}
)
return action
def _prepare_server_parameters(self):
"""Prepare new server parameters
Returns:
dict(): New server parameters
"""
res = {
"ip_v4_address": self.ip_v4_address,
"ip_v6_address": self.ip_v6_address,
"ssh_port": self.ssh_port,
"ssh_username": self.ssh_username,
"ssh_password": self.ssh_password,
"ssh_key_id": self.ssh_key_id.id,
"ssh_auth_mode": self.ssh_auth_mode,
"use_sudo": self.use_sudo,
"partner_id": self.partner_id.id,
"os_id": self.os_id.id,
"tag_ids": [(4, tag_id) for tag_id in self.tag_ids.ids],
"skip_host_key": self.skip_host_key,
"host_key": self.host_key if not self.skip_host_key else None,
}
if self.line_ids:
res.update(
{
"configuration_variables": {
line.variable_reference: line.value_char
for line in self.line_ids
},
"configuration_variable_options": {
line.variable_reference: line.option_id.reference
for line in self.line_ids
if line.option_id
},
}
)
return res
class CxTowerServerTemplateCreateWizardVariableLine(models.TransientModel):
"""Configuration variables"""
_name = "cx.tower.server.template.create.wizard.line"
_description = "Create new server from template variables"
wizard_id = fields.Many2one("cx.tower.server.template.create.wizard")
variable_value_id = fields.Many2one(
comodel_name="cx.tower.variable.value",
)
variable_id = fields.Many2one(
comodel_name="cx.tower.variable",
compute="_compute_variable_id",
readonly=False,
store=True,
)
variable_reference = fields.Char(related="variable_id.reference", readonly=True)
value_char = fields.Char(
string="Value",
compute="_compute_value_char",
readonly=False,
store=True,
)
required = fields.Boolean(
related="variable_value_id.required",
help="Indicates if this variable is mandatory for server creation",
readonly=True,
store=True,
)
variable_type = fields.Selection(
related="variable_id.variable_type",
readonly=True,
)
option_id = fields.Many2one(
comodel_name="cx.tower.variable.option",
domain="[('variable_id', '=', variable_id)]",
readonly=False,
compute="_compute_variable_id",
store=True,
)
@api.depends("variable_value_id")
def _compute_variable_id(self):
for rec in self:
variable_value = rec.variable_value_id
if variable_value:
rec.update(
{
"variable_id": variable_value.variable_id.id,
"option_id": variable_value.option_id.id,
"value_char": variable_value.value_char,
}
)
@api.depends("option_id", "variable_id", "variable_type")
def _compute_value_char(self):
for rec in self:
if rec.variable_id and rec.variable_type == "o" and rec.option_id:
rec.value_char = rec.option_id.value_char
else:
rec.value_char = ""
@api.onchange("variable_id")
def _onchange_variable_id(self):
"""
Reset option_id when variable changes.
"""
self.update({"option_id": None})
@api.onchange("value_char")
def _onchange_value_char(self):
"""
Check value before saving
"""
if self.variable_id:
valid, message = self.variable_id._validate_value(self.value_char)
if not valid:
return {"warning": {"title": _("Value is invalid"), "message": message}}

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_server_template_create_wizard_view_form" model="ir.ui.view">
<field name="name">cx.tower.server.template.create.wizard.view.form</field>
<field name="model">cx.tower.server.template.create.wizard</field>
<field name="arch" type="xml">
<form>
<div class="oe_title">
<h1 class="d-flex flex-grow justify-content-between">
<field name="name" placeholder="new server name" />
</h1>
<div>
<field name="color" widget="color_picker" />
<field
name="tag_ids"
widget="many2many_tags"
placeholder="Tags"
options="{'color_field': 'color'}"
/>
</div>
</div>
<group name="main">
<group name="general">
<field name="partner_id" />
<field name="os_id" />
<field name="ip_v4_address" />
<field name="ip_v6_address" />
<field name="host_key" invisible="skip_host_key" />
<field name="skip_host_key" />
</group>
<group name="ssh">
<field name="ssh_auth_mode" />
<field name="ssh_port" />
<field
name="ssh_username"
placeholder="this can be changed later"
/>
<field name="ssh_password" password="True" />
<field
name="ssh_key_id"
invisible="ssh_auth_mode == 'p'"
context="{'default_key_type': 'k', 'secrets_only': True}"
/>
<field name="use_sudo" placeholder="No/Undefined" />
</group>
<field name="line_ids">
<list
editable="bottom"
decoration-danger="value_char == False and required == True"
decoration-info="value_char != False and required == True"
>
<field name="variable_reference" column_invisible="1" />
<field name="variable_id" />
<field name="variable_type" column_invisible="1" />
<field
name="value_char"
readonly="variable_type == 'o'"
required="variable_type == 's' and required"
/>
<field
name="option_id"
options="{'no_create': True, 'no_create_edit': True}"
readonly="variable_type == 's'"
required="variable_type == 'o' and required"
/>
<field name="required" readonly="1" />
</list>
</field>
</group>
<field
name="missing_required_variables_message"
nolabel="1"
readonly="1"
invisible="not has_missing_required_values"
class="alert alert-warning"
role="alert"
/>
<field name="has_missing_required_values" invisible="1" />
<field name="missing_required_variables" invisible="1" />
<footer>
<button
name="action_confirm"
type="object"
string="Confirm"
confirm="Are you sure?"
class="oe_highlight"
invisible="has_missing_required_values"
/>
<button string="Cancel" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>