Files
odoo-addons/addons/cetmix_tower_server/models/cx_tower_variable_value.py

542 lines
19 KiB
Python

# 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
from odoo.osv.expression import OR
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"
)
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_server",
"unique (variable_id, server_id)",
"A variable value cannot be assigned multiple times to the same server!",
),
(
"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!"
),
),
]
# -- 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")
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")
def _check_single_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)
)
if count_assigned > 1:
raise ValidationError(
_(
"Variable '%(var)s' can only be assigned to one of the models "
"at a time: "
"Server, Server Template, or Plan Line Action.",
var=record.variable_id.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
"""
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 get_by_variable_reference(
self,
variable_reference,
server_id=None,
server_template_id=None,
check_global=True,
):
"""Get record based on its reference.
Important: references are case sensitive!
Args:
variable_reference (Char): variable reference
server_reference (Int): Server ID
server_template_reference (Int): Server template ID
Returns:
Dict: Variable values that match provided reference
"""
domain = [("variable_reference", "=", variable_reference)]
# Server or server template specific
if server_id:
domain.append(("server_id", "=", server_id))
elif server_template_id:
domain.append(("server_template_id", "=", server_template_id))
if check_global:
domain = OR(
[
domain,
[
("variable_reference", "=", variable_reference),
("is_global", "=", True),
],
]
)
search_result = self.search(domain)
result = {}
if search_result:
if server_id:
value_char = search_result.filtered("server_id").mapped("value_char")
result.update(
{"server": value_char and value_char[0] if value_char else None}
)
if server_template_id:
value_char = search_result.filtered("server_template_id").mapped(
"value_char"
)
result.update(
{
"server_template": value_char and value_char[0]
if value_char
else None
}
)
if check_global:
value_char = search_result.filtered("is_global").mapped("value_char")
result.update(
{"global": value_char and value_char[0] if value_char else None}
)
return result
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"),
}
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