482 lines
17 KiB
Python
482 lines
17 KiB
Python
# 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
|