Wipe cetmix_tower_yaml (polluted by overlapping uploads)
This commit is contained in:
@@ -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
|
||||
@@ -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 won’t 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)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user