Wipe cetmix_tower_yaml (polluted by overlapping uploads)

This commit is contained in:
Tower Deploy
2026-04-27 13:43:58 +03:00
parent 18dd9c7a1f
commit 7cef9f1a32
80 changed files with 0 additions and 9275 deletions

View File

@@ -1,4 +0,0 @@
from . import cx_tower_yaml_export_wiz
from . import cx_tower_yaml_export_wiz_download
from . import cx_tower_yaml_import_wiz
from . import cx_tower_yaml_import_wiz_upload

View File

@@ -1,367 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import base64
import re
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from ..models.cx_tower_yaml_mixin import YamlExportCollector
FILE_HEADER = """
# This file is generated with Cetmix Tower.
# Details and documentation: https://cetmix.com/tower
"""
CLEAN_STR = re.compile(r"[^a-z0-9_]")
class CxTowerYamlExportWiz(models.TransientModel):
"""Cetmix Tower YAML Export Wizard"""
_name = "cx.tower.yaml.export.wiz"
_description = "Cetmix Tower YAML Export Wizard"
yaml_code = fields.Text()
yaml_file_name = fields.Char(
string="YAML File Name",
size=255,
default=lambda self: self._default_yaml_file_name(),
help="Snippet file name without extension, eg 'my_snippet'",
)
explode_child_records = fields.Boolean(
default=True,
help="Add entire child record definitions to the exported YAML file. "
"Otherwise only references to child records will be added.",
)
remove_empty_values = fields.Boolean(
string="Remove Empty x2m Field Values",
default=True,
help="Remove empty Many2one, Many2many and One2many"
" field values from the exported YAML file.",
)
preview_code = fields.Boolean()
add_manifest = fields.Boolean()
MANIFEST_FIELDS = [
"manifest_template_id",
"manifest_name",
"manifest_author_ids",
"manifest_version",
"manifest_summary",
"manifest_description",
"manifest_website",
"manifest_license",
"manifest_license_text",
"manifest_currency",
"manifest_price",
]
@api.model
def _get_manifest_license_selection(self):
return self.env["cx.tower.yaml.manifest.tmpl"]._selection_license()
@api.model
def _get_manifest_currency_selection(self):
return self.env["cx.tower.yaml.manifest.tmpl"]._selection_currency()
manifest_template_id = fields.Many2one(
"cx.tower.yaml.manifest.tmpl",
)
manifest_name = fields.Char(
compute="_compute_manifest",
readonly=False,
store=True,
string="Snippet Name",
help="Leave this field blank if you don't want to create a manifest",
)
manifest_website = fields.Char(
compute="_compute_manifest",
readonly=False,
string="Website",
store=True,
)
manifest_license = fields.Selection(
selection="_get_manifest_license_selection",
compute="_compute_manifest",
readonly=False,
string="License",
store=True,
)
manifest_author_ids = fields.Many2many(
"cx.tower.yaml.manifest.author",
compute="_compute_manifest",
readonly=False,
string="Authors",
store=True,
)
manifest_license_text = fields.Text(
compute="_compute_manifest", readonly=False, string="License Text", store=True
)
manifest_currency = fields.Selection(
selection="_get_manifest_currency_selection",
compute="_compute_manifest",
string="Currency",
readonly=False,
store=True,
)
manifest_summary = fields.Char(
string="Summary",
size=160,
help="Short summary that includes core information. 160 symbols max",
)
manifest_description = fields.Text("Description")
manifest_price = fields.Float("Price")
manifest_version = fields.Char(
compute="_compute_manifest",
readonly=False,
store=True,
string="Version",
help="Use the Major.Minor.Patch format, e.g. 1.2.3",
)
def _clean_yaml_basename(self, name: str) -> str:
"""
Return *always-valid* basename (no extension) built from arbitrary *name*.
"""
raw = (name or "").strip().lower()
base = raw[:-5] if raw.endswith(".yaml") else raw
base = CLEAN_STR.sub("_", base)
base = re.sub(r"_+", "_", base).strip("_") or "snippet"
return base
def _default_yaml_file_name(self):
"""
Build the *initial* file name shown to the user.
Pattern: <model>_<reference>, without “.yaml” suffix.
"""
records = self._get_model_record()
prefix = records._name.replace("cx.tower.", "").replace(".", "_")
ref = records.reference if len(records) == 1 else "selected"
return f"{prefix}_{ref}"
@api.depends("manifest_template_id")
def _compute_manifest(self):
mapping = {
"manifest_author_ids": "author_ids",
"manifest_website": "website",
"manifest_license": "license",
"manifest_license_text": "license_text",
"manifest_currency": "currency",
"manifest_version": "version",
}
for rec in self:
tmpl = rec.manifest_template_id
if not tmpl:
continue
for wiz_field, tmpl_field in mapping.items():
if not rec[wiz_field]:
rec[wiz_field] = tmpl[tmpl_field]
# prepend template's file prefix to YAML file name
prefix = (tmpl.file_prefix or "").strip()
if prefix:
# sanitize prefix without defaulting to a placeholder like "snippet"
raw = prefix.lower()
sanitized_prefix = re.sub(r"_+", "_", CLEAN_STR.sub("_", raw)).strip(
"_"
)
if sanitized_prefix:
# use current or default base name, then clean it
current = rec.yaml_file_name or rec._default_yaml_file_name()
base = rec._clean_yaml_basename(current)
# avoid double-prefixing
if not base.startswith(f"{sanitized_prefix}_"):
rec.yaml_file_name = rec._clean_yaml_basename(
f"{sanitized_prefix}_{base}"
)
@api.onchange("manifest_license")
def _onchange_manifest_license(self):
"""Drop price and currency when user switches off the 'custom' license.
If manifest_license != 'custom', reset manifest_price to 0.0 and
manifest_currency to False so they wont appear in the generated YAML.
"""
for rec in self:
if rec.manifest_license != "custom":
rec.manifest_price = 0.0
rec.manifest_currency = False
@api.onchange("explode_child_records", "remove_empty_values", *MANIFEST_FIELDS)
def onchange_explode_child_records(self):
"""Compute YAML code and file content."""
self.ensure_one()
# Get model records
records = self._get_model_record()
if not records:
raise ValidationError(_("No valid records selected"))
explode_related_record = self.explode_child_records
remove_empty_values = self.remove_empty_values
# Prepare YAML header
yaml_header = FILE_HEADER.rstrip("\n")
# Use the YAML export collector for unique records
collector = YamlExportCollector()
record_list = []
for rec in records:
record_yaml_dict = rec.with_context(
explode_related_record=explode_related_record,
remove_empty_values=remove_empty_values,
yaml_collector=collector,
)._prepare_record_for_yaml()
if not record_yaml_dict:
continue
if isinstance(record_yaml_dict, dict) and list(record_yaml_dict) == [
"reference"
]:
continue
if "cetmix_tower_model" not in record_yaml_dict:
record_yaml_dict["cetmix_tower_model"] = rec._name.replace(
"cx.tower.", ""
).replace(".", "_")
record_list.append(record_yaml_dict)
if not record_list:
self.yaml_code = f"{yaml_header}\n"
return
if not self.manifest_name:
manifest = {}
else:
lic = (self.manifest_license or "").lower()
fields_order = [
("name", self.manifest_name),
("summary", self.manifest_summary),
("description", self.manifest_description),
("author", self.manifest_author_ids.mapped("name")),
("version", self.manifest_version),
("website", self.manifest_website),
("license", self.manifest_license),
(
"license_text",
(self.manifest_license_text or "").strip()
if lic == "custom"
else None,
),
("price", self.manifest_price),
(
"currency",
self.manifest_currency if lic == "custom" else None,
),
]
manifest = {k: v for k, v in fields_order if v not in (False, None, "", [])}
result_dict = {
"cetmix_tower_yaml_version": self.env[
"cx.tower.yaml.mixin"
].CETMIX_TOWER_YAML_VERSION,
}
if manifest:
result_dict["manifest"] = manifest
result_dict["records"] = record_list
self.yaml_code = f"{yaml_header}\n{records._convert_dict_to_yaml(result_dict)}"
@api.onchange("yaml_file_name")
def _onchange_yaml_file_name(self):
"""
Live-clean the YAML file name as the user types:
- lowercase, trim whitespace
- replace invalid characters with “_”
- collapse repeated underscores
- ensure a single “.yaml” suffix
"""
for rec in self:
rec.yaml_file_name = rec._clean_yaml_basename(rec.yaml_file_name)
@api.constrains("manifest_version")
def _check_manifest_version_format(self):
"""
Ensure the user types a semantic version (x.y.z) in the wizard itself.
"""
semver = re.compile(r"^\d+\.\d+\.\d+$")
for rec in self:
if rec.manifest_version and not semver.match(rec.manifest_version):
raise ValidationError(
_("Version must be in format Major.Minor.Patch, e.g. 1.2.3")
)
def _validate_manifest(self):
"""Logical cross-checks before saving YAML."""
if self.manifest_price and not self.manifest_currency:
raise ValidationError(_("Currency is required when price is specified"))
if (self.manifest_license or "").lower() == "custom" and not (
self.manifest_license_text or ""
).strip():
raise ValidationError(_("License text is required for a custom license"))
def write(self, vals):
"""
Override write to always sanitize `yaml_file_name`
before persisting, making programmatic assignments safe.
"""
if "yaml_file_name" in vals:
vals["yaml_file_name"] = self._clean_yaml_basename(vals["yaml_file_name"])
return super().write(vals)
def action_generate_yaml_file(self):
"""Save YAML file"""
self.ensure_one()
self._validate_manifest()
if not self.yaml_code:
raise ValidationError(_("No YAML code is present."))
# Generate YAML file
try:
yaml_file = base64.encodebytes(self.yaml_code.encode("utf-8"))
yaml_file_name = (
f"{self.yaml_file_name or self._default_yaml_file_name()}.yaml"
)
except Exception as exc:
raise ValidationError(
_(
"Failed to encode YAML content. Please ensure all characters are UTF-8 compatible." # noqa: E501
)
) from exc
download_wizard = self.env["cx.tower.yaml.export.wiz.download"].create(
{
"yaml_file": yaml_file,
"yaml_file_name": yaml_file_name,
}
)
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.yaml.export.wiz.download",
"res_id": download_wizard.id,
"target": "new",
"view_mode": "form",
}
def _get_model_record(self):
"""Get model records based on context values
Raises:
ValidationError: in case no model or records selected
Returns:
ModelRecords: a recordset of selected records
"""
model_name = self.env.context.get("active_model")
record_ids = self.env.context.get("active_ids")
if not model_name or not record_ids:
raise ValidationError(_("No model or records selected"))
return self.env[model_name].browse(record_ids)

View File

@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_export_wiz_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.export.wiz.view.form</field>
<field name="model">cx.tower.yaml.export.wiz</field>
<field name="arch" type="xml">
<form>
<group>
<field name="yaml_file_name" placeholder="my_snippet.yaml" />
</group>
<group>
<group>
<field name="explode_child_records" />
<field name="remove_empty_values" />
</group>
<group>
<field name="add_manifest" />
<field name="preview_code" />
</group>
</group>
<group
string="Manifest"
attrs="{'invisible': [('add_manifest','=',False)]}"
>
<field
name="manifest_template_id"
placeholder="Select a pre-defined template"
help="Select a template to auto-populate manifest fields"
/>
<group string="Information">
<field
name="manifest_name"
attrs="{'required': [('add_manifest','!=',False)]}"
/>
<field
name="manifest_summary"
attrs="{'required': [('manifest_name','!=',False)]}"
placeholder="Short summary, 160 symbols max"
/>
<field
name="manifest_author_ids"
widget="many2many_tags"
attrs="{'required': [('manifest_name','!=',False)]}"
/>
<field
name="manifest_version"
placeholder="Use the Major.Minor.Patch format, e.g. 1.2.3"
/>
<field name="manifest_website" />
</group>
<group string="License and pricing">
<field
name="manifest_license"
attrs="{'required': [('manifest_name','!=',False)]}"
/>
<field
name="manifest_price"
attrs="{'invisible': [('manifest_license', '!=', 'custom')]}"
/>
<field
name="manifest_currency"
attrs="{'invisible': [('manifest_price', '=', 0)]}"
/>
</group>
</group>
<notebook>
<page
string="Description"
attrs="{'invisible': [('add_manifest','=',False)]}"
>
<field
name="manifest_description"
widget="text"
nolabel="1"
colspan="4"
placeholder="Detailed description (optional)"
/>
</page>
<page
string="License text"
attrs="{'invisible': [('manifest_license', '!=', 'custom')]}"
>
<field
name="manifest_license_text"
widget="text"
nolabel="1"
colspan="4"
placeholder="License text"
attrs="{'required': [('manifest_license', '=', 'custom')]}"
/>
</page>
<page
string="Preview code"
attrs="{'invisible': [('preview_code','=',False)]}"
>
<field
name="yaml_code"
widget="ace"
options="{'mode': 'yaml'}"
force_save="1"
nolabel="1"
colspan="4"
readonly="1"
/>
</page>
</notebook>
<footer>
<button
string="Generate YAML file"
type="object"
name="action_generate_yaml_file"
class="oe_highlight"
/>
<button string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,11 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerYamlExportWizDownload(models.TransientModel):
_name = "cx.tower.yaml.export.wiz.download"
_description = "Cetmix Tower YAML Export File Download"
yaml_file = fields.Binary(readonly=True, attachment=False)
yaml_file_name = fields.Char(readonly=True)

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_export_wiz_download_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.export.wiz.download.view.form</field>
<field name="model">cx.tower.yaml.export.wiz.download</field>
<field name="arch" type="xml">
<form>
<group>
<field name="yaml_file" filename="yaml_file_name" />
<field name="yaml_file_name" invisible="1" />
</group>
<footer>
<button string="Close" special="cancel" class="oe_highlight" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,314 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import yaml
from markupsafe import escape
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class CxTowerYamlImportWiz(models.TransientModel):
"""
Process YAML data and create records in Odoo.
"""
_name = "cx.tower.yaml.import.wiz"
_description = "Cetmix Tower YAML Import Wizard"
yaml_code = fields.Text(readonly=True)
model_names = fields.Char(readonly=True, help="Models to create records in")
if_record_exists = fields.Selection(
selection=[
("skip", "Skip record"),
("update", "Update existing record"),
("create", "Create a new record"),
],
default="skip",
required=True,
help="What to do if record with the same reference already exists",
)
secret_list = fields.Html(
help="List of secrets present in the YAML file (formatted as HTML list)",
compute="_compute_secret_list",
)
preview_code = fields.Boolean(
help="Toggle to show or hide YAML code preview",
)
manifest_name = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Snippet Name"
)
manifest_summary = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Summary"
)
manifest_description = fields.Text(
readonly=True, compute="_compute_yaml_data", string="Description"
)
manifest_author_string = fields.Char(
readonly=True,
compute="_compute_yaml_data",
help="Comma-separated list",
string="Author",
)
manifest_version = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Version"
)
manifest_website = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Website"
)
manifest_license = fields.Char(
readonly=True, compute="_compute_yaml_data", string="License"
)
manifest_license_text = fields.Text(
readonly=True, compute="_compute_yaml_data", string="License text"
)
manifest_price = fields.Float(
readonly=True, compute="_compute_yaml_data", string="Price"
)
manifest_currency = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Currency"
)
@api.depends("yaml_code")
def _compute_secret_list(self):
"""Compute list of secrets present in the YAML file"""
for record in self:
yaml_data = yaml.safe_load(record.yaml_code or "{}")
secret_list = self._extract_secret_names(yaml_data)
if not secret_list:
record.secret_list = False
continue
# Build deterministic HTML list of secrets
items = "".join(f"<li>{escape(name)}</li>" for name in sorted(secret_list))
secrets_html = f"<ul>{items}</ul>"
record.secret_list = _(
"Following secrets are used in the code:<br/>%(secrets)s",
secrets=secrets_html,
)
@api.depends("yaml_code")
def _compute_yaml_data(self):
for record in self:
data = yaml.safe_load(record.yaml_code or "{}")
manifest = data.get("manifest", {}) if isinstance(data, dict) else {}
authors = manifest.get("author")
if isinstance(authors, list | tuple):
manifest_author_string = ", ".join(authors)
elif isinstance(authors, str):
manifest_author_string = authors
else:
manifest_author_string = False
record.update(
{
"manifest_name": manifest.get("name"),
"manifest_summary": manifest.get("summary"),
"manifest_description": manifest.get("description"),
"manifest_author_string": manifest_author_string,
"manifest_version": manifest.get("version"),
"manifest_website": manifest.get("website"),
"manifest_license": manifest.get("license"),
"manifest_license_text": manifest.get("license_text"),
"manifest_price": manifest.get("price"),
"manifest_currency": manifest.get("currency"),
}
)
def action_import_yaml(self):
"""Process YAML data and create records in Odoo"""
self.ensure_one()
# Parse YAML code
yaml_data = yaml.safe_load(self.yaml_code)
records = yaml_data.get("records")
if not records:
raise ValidationError(_("YAML file doesn't contain any records"))
# Cache models
model_cache = {}
odoo_record_ids = []
# Process each record
for record in records:
record_reference = record.get("reference")
if not record_reference:
raise ValidationError(_("Record reference is missing"))
model_name = record.get("cetmix_tower_model")
if not model_name:
raise ValidationError(
_("Record model is missing for record %s", record_reference)
)
# Get model from cache or create new one
model = model_cache.get(model_name)
if not model:
model = self.env[
f"cx.tower.{model_name.replace('_', '.')}"
].with_context(skip_ssh_settings_check=(model_name == "server"))
model_cache[model_name] = model
# Get existing record by reference
# NOTE: we don't validate models here because they are
# already validated in the file upload wizard.
odoo_record = model.get_by_reference(record_reference)
# Skip
if self.if_record_exists == "skip" and odoo_record:
_logger.info(
"Skipping record '%s' in model '%s'" " because it already exists",
record_reference,
model_name,
)
continue
# Update existing record
elif self.if_record_exists == "update" and odoo_record:
try:
record_values = model.with_context(
force_create_related_record=False,
)._post_process_yaml_dict_values(record)
odoo_record.with_context(
from_yaml=True,
).write(record_values)
odoo_record_ids.append(odoo_record.id)
except Exception as e:
raise ValidationError(
_(
"Error updating record %(reference)s: %(error)s",
reference=record_reference,
error=e,
)
) from e
_logger.info(
f"Updated record '{record_reference}' in model '{model_name}'"
)
continue
# Or create a new record
record_values = model.with_context(
force_create_related_record=self.if_record_exists == "create",
)._post_process_yaml_dict_values(record)
try:
odoo_record = model.with_context(
from_yaml=True,
).create(record_values)
odoo_record_ids.append(odoo_record.id)
except Exception as e:
raise ValidationError(
_(
"Error creating record '%(reference)s' in model"
" '%(model)s': %(error)s",
reference=record_reference,
model=model_name,
error=e,
)
) from e
_logger.info(f"Created record '{record_reference}' in model '{model_name}'")
# No records were created or updated
if not odoo_record_ids:
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Record Import"),
"message": _("No records were created or updated"),
"sticky": True,
"type": "warning",
"next": {"type": "ir.actions.act_window_close"},
},
}
# All records from the same model
elif len(model_cache) == 1:
model = list(model_cache.values())[0]
action = {
"name": _("Import result: %(model)s", model=model._description),
"type": "ir.actions.act_window",
"res_model": model._name,
"target": "current",
"domain": [("id", "in", odoo_record_ids)],
}
if len(odoo_record_ids) == 1:
# Open single record in form view
action["res_id"] = odoo_record_ids[0]
action["view_mode"] = "form"
else:
# Open list view of all records
action["view_mode"] = "list,form"
# Records from different models
else:
model_names = ", ".join(
f"'{model._description}'" for model in model_cache.values()
)
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Record Import"),
"message": _(
"Records of the following models were created "
"or updated: %(models)s",
models=model_names,
),
"sticky": True,
"type": "success",
"next": {"type": "ir.actions.act_window_close"},
},
}
return action
def _extract_secret_names(self, data: dict) -> list:
"""Extract names of secrets from YAML data.
Supports both formats:
- secret_ids -> [{name: ...}]
- secret_ids -> [{key_id: {name: ...}}]
"""
secret_names = set()
def _recursive_extract(node):
"""Recursively extract secret names from nested structures."""
if isinstance(node, dict):
if "secret_ids" in node and isinstance(node["secret_ids"], list):
for item in node["secret_ids"]:
if not isinstance(item, dict):
continue
# Format 1: direct name
if "name" in item:
secret_names.add(item["name"])
# Format 2: nested key_id -> name
elif (
"key_id" in item
and isinstance(item["key_id"], dict)
and "name" in item["key_id"]
):
secret_names.add(item["key_id"]["name"])
# Handle single ssh_key_id
if "ssh_key_id" in node and isinstance(node["ssh_key_id"], dict):
if "name" in node["ssh_key_id"]:
secret_names.add(node["ssh_key_id"]["name"])
# Recursively process the rest of the dictionary
for value in node.values():
_recursive_extract(value)
elif isinstance(node, list):
for item in node:
_recursive_extract(item)
_recursive_extract(data)
return list(secret_names)

View File

@@ -1,153 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_import_wiz_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.import.wiz.view.form</field>
<field name="model">cx.tower.yaml.import.wiz</field>
<field name="arch" type="xml">
<form>
<group>
<field name="if_record_exists" />
</group>
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('secret_list', '=', False)]}"
>
<field name="secret_list" nolabel="1" />
</div>
<group>
<field name="preview_code" widget="boolean_toggle" />
</group>
<group
attrs="{'invisible': [
('manifest_name', '=', False),
]}"
>
<group string="Information">
<field name="manifest_name" string="Name" />
<field name="manifest_summary" string="Summary" />
<field name="manifest_author_string" string="Author" />
<field name="manifest_version" string="Version" />
<field
name="manifest_website"
string="Website"
attrs="{'invisible': [('manifest_website', '=', False)]}"
/>
</group>
<group string="License and pricing">
<field name="manifest_license" string="License" />
<field
name="manifest_price"
string="Price"
attrs="{'invisible': [('manifest_price', '=', False)]}"
/>
<field
name="manifest_currency"
string="Currency"
attrs="{'invisible': [('manifest_currency', '=', False)]}"
/>
</group>
</group>
<notebook>
<page
string="Description"
attrs="{'invisible':[('manifest_description','=',False)]}"
>
<field
name="manifest_description"
widget="text"
nolabel="1"
colspan="4"
/>
</page>
<page
string="License text"
attrs="{'invisible':[('manifest_license_text','=',False)]}"
>
<field
name="manifest_license_text"
widget="text"
nolabel="1"
colspan="4"
/>
</page>
<page
string="Code preview"
attrs="{'invisible': [('preview_code', '=', False)]}"
>
<group>
<field
name="yaml_code"
widget="ace"
options="{'mode': 'yaml'}"
force_save="1"
nolabel="1"
colspan="4"
/>
</group>
</page>
</notebook>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('if_record_exists', '!=', 'create')]}"
style="margin-bottom:0px;"
>
<p>
<strong
>Important:</strong> To maintain data consistency, the following
model records will always be updated if they exist in Odoo:
</p>
<ul>
<li>Variables</li>
<li>Variable Options</li>
<li>Key/Secrets</li>
<li>Tags</li>
<li>OSs</li>
</ul>
<p>
To create new entities instead of updating existing ones, remove or modify
the <code
>reference</code> field in the YAML code for those entities.
</p>
</div>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('if_record_exists', '!=', 'update')]}"
style="margin-bottom:0px;"
>
<p>
Existing record will be updated with the new data. Related records, present in the YAML code, will be updated too.
If any of those related records doesn't exist, it will be created automatically.
</p>
</div>
<footer>
<button
string="Import"
type="object"
name="action_import_yaml"
class="oe_highlight"
attrs="{'invisible': [('if_record_exists', '!=', 'update')]}"
confirm="This may overwrite existing records. Proceed?"
/>
<button
string="Import"
type="object"
name="action_import_yaml"
class="oe_highlight"
attrs="{'invisible': [('if_record_exists', '=', 'update')]}"
/>
<button string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,137 +0,0 @@
import binascii
from base64 import b64decode
import yaml
from odoo import _, fields, models
from odoo.exceptions import ValidationError
class CxTowerYamlImportWizUpload(models.TransientModel):
"""
Upload YAML file and perform initial validation.
Submit YAML data to import wizard for further processing.
"""
_name = "cx.tower.yaml.import.wiz.upload"
_description = "Cetmix Tower YAML Import Wizard Upload"
file_name = fields.Char()
yaml_file = fields.Binary(required=True)
def action_import_yaml(self):
"""Parse YAML data to the import wizard
Returns:
Action Window: Action to open the import wizard
"""
decoded_file = self._extract_yaml_data()
import_wizard = self.env["cx.tower.yaml.import.wiz"].create(
{
"yaml_code": decoded_file,
}
)
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.yaml.import.wiz",
"res_id": import_wizard.id,
"view_mode": "form",
"target": "new",
}
def _extract_yaml_data(self):
"""Extract data from YAML file and validate them
Returns:
decoded_file (Text): YAML code
Raises:
ValidationError: If the YAML file is invalid
or contains unsupported data
"""
self.ensure_one()
# Decode base64 file
try:
raw_bytes = b64decode(self.yaml_file or b"")
except (TypeError, binascii.Error) as e:
# Not a valid base-64 payload
raise ValidationError(_("File is not a valid base64-encoded file")) from e
if not raw_bytes:
raise ValidationError(_("File is empty"))
try:
decoded_file = raw_bytes.decode("utf-8")
except UnicodeDecodeError as e:
raise ValidationError(_("YAML file cannot be decoded properly")) from e
# Parse YAML file
try:
yaml_data = yaml.safe_load(decoded_file)
except yaml.YAMLError as e:
raise ValidationError(_("Invalid YAML file")) from e
if not yaml_data or not isinstance(yaml_data, dict):
raise ValidationError(_("Yaml file doesn't contain valid data"))
# Check Cetmix Tower YAML version
yaml_version = yaml_data.pop("cetmix_tower_yaml_version", None)
supported_version = self.env["cx.tower.yaml.mixin"].CETMIX_TOWER_YAML_VERSION
if (
yaml_version
and isinstance(yaml_version, int)
and yaml_version > supported_version
):
raise ValidationError(
_(
"YAML version is higher than version"
" supported by your Cetmix Tower instance."
" %(code_version)s > %(tower_version)s",
code_version=yaml_version,
tower_version=supported_version,
)
)
# Get records from YAML
records = yaml_data.get("records")
if not records:
raise ValidationError(_("YAML file doesn't contain any records"))
# Collect and validate all record models
ir_model_obj = self.env["ir.model"]
unique_models = {}
# First pass: check all records have models and collect unique models
for record in records:
record_model = record.get("cetmix_tower_model")
if not record_model:
raise ValidationError(
_(
"Record model is missing for record %s",
record.get("reference", ""),
)
)
if record_model not in unique_models:
odoo_model = f"cx.tower.{record_model}".replace("_", ".")
unique_models[record_model] = odoo_model
# Second pass: validate all unique models in a single query
odoo_models = list(unique_models.values())
valid_models = {
model.model: model
for model in ir_model_obj.search([("model", "in", odoo_models)])
}
# Third pass: check models exist and support YAML import
for record_model, odoo_model in unique_models.items():
if odoo_model not in valid_models:
raise ValidationError(_("'%s' is not a valid model", record_model))
if not hasattr(self.env[odoo_model], "yaml_code"):
raise ValidationError(
_("Model '%s' does not support YAML import", record_model)
)
return decoded_file

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_import_wiz_upload_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.import.wiz.upload.view.form</field>
<field name="model">cx.tower.yaml.import.wiz.upload</field>
<field name="arch" type="xml">
<form>
<group>
<field name="file_name" invisible="1" />
<field
name="yaml_file"
filename="file_name"
options="{'accepted_file_extensions': '.yaml,.yml'}"
/>
</group>
<footer>
<button
string="Process"
type="object"
name="action_import_yaml"
class="oe_highlight"
attrs="{'invisible': [('yaml_file', '=', False)]}"
/>
<button string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_cx_tower_yaml_import_wiz_upload" model="ir.actions.act_window">
<field name="name">Import YAML</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">cx.tower.yaml.import.wiz.upload</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>