Integrate claude, improve selenium and support qemu #1
2 changed files with 258 additions and 2 deletions
235
script/odoo/migration/check_cow_views.py
Executable file
235
script/odoo/migration/check_cow_views.py
Executable file
|
|
@ -0,0 +1,235 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Predict which website COW views will break on the next version bump.
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
When a website view is customized, Odoo makes a copy-on-write (COW) copy tied
|
||||||
|
to a website_id. That copy freezes the arch AND the structure of the module
|
||||||
|
view it was copied from.
|
||||||
|
|
||||||
|
A module view can change structure between two Odoo versions. Example measured
|
||||||
|
on a real 12.0 database: ``portal.frontend_layout`` is declared ``primary`` in
|
||||||
|
12.0 (a full QWeb template) and becomes an ``extension`` in 13.0
|
||||||
|
(``inherit_id="web.frontend_layout"`` + xpath). During the upgrade the COW copy
|
||||||
|
follows the module and becomes an extension, but keeps its 12.0 full-template
|
||||||
|
arch. Odoo then applies the ``<t t-name=...>`` root as an inheritance spec,
|
||||||
|
cannot find it in the parent, and the whole upgrade stops on::
|
||||||
|
|
||||||
|
ValueError: Element '<t ... t-name="portal.frontend_layout">'
|
||||||
|
cannot be located in parent view
|
||||||
|
|
||||||
|
So the rule is:
|
||||||
|
|
||||||
|
a COW view breaks when its module counterpart changes ``mode``
|
||||||
|
between version N and version N+1.
|
||||||
|
|
||||||
|
That is predictable *before* starting a multi-hour migration: the current mode
|
||||||
|
is in the database, and the target mode is declared in the target version
|
||||||
|
sources. This script compares the two and reports the views at risk.
|
||||||
|
|
||||||
|
It only reads: no database write, no source modification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
# A view whose module counterpart cannot be found is reported separately: it is
|
||||||
|
# usually a view of a module that does not exist in the target version.
|
||||||
|
MODE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def query_cow_views(database):
|
||||||
|
"""Return [(id, key, mode, website_id)] for every website COW view."""
|
||||||
|
sql = (
|
||||||
|
"SELECT id, COALESCE(key, ''), mode, website_id FROM ir_ui_view"
|
||||||
|
" WHERE website_id IS NOT NULL ORDER BY id;"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-d", database, "-tAF", "|", "-c", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot read views from '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
lst_view = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
view_id, key, mode, website_id = line.split("|")
|
||||||
|
lst_view.append((int(view_id), key, mode, website_id))
|
||||||
|
return lst_view
|
||||||
|
|
||||||
|
|
||||||
|
def find_module_dir(odoo_version, module_name):
|
||||||
|
"""Locate a module directory inside an odoo<version> tree."""
|
||||||
|
lst_pattern = [
|
||||||
|
os.path.join(odoo_version, "odoo", "addons", module_name),
|
||||||
|
os.path.join(odoo_version, "odoo", "odoo", "addons", module_name),
|
||||||
|
os.path.join(odoo_version, "addons", "*", module_name),
|
||||||
|
]
|
||||||
|
for pattern in lst_pattern:
|
||||||
|
for path in sorted(glob.glob(pattern)):
|
||||||
|
if os.path.isdir(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def declared_mode(module_dir, template_id):
|
||||||
|
"""Return 'primary', 'extension' or None for a view declared in sources.
|
||||||
|
|
||||||
|
Handles both declaration styles: <template id="..."> and
|
||||||
|
<record model="ir.ui.view">. A view is an extension when it inherits and is
|
||||||
|
not explicitly flagged primary.
|
||||||
|
"""
|
||||||
|
pattern = os.path.join(module_dir, "**", "*.xml")
|
||||||
|
for file_path in sorted(glob.glob(pattern, recursive=True)):
|
||||||
|
try:
|
||||||
|
root = ET.parse(file_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
for element in root.iter():
|
||||||
|
if element.get("id") != template_id:
|
||||||
|
continue
|
||||||
|
if element.tag == "template":
|
||||||
|
if str(element.get("primary", "")).lower() in ("true", "1"):
|
||||||
|
return "primary"
|
||||||
|
return "extension" if element.get("inherit_id") else "primary"
|
||||||
|
if (
|
||||||
|
element.tag == "record"
|
||||||
|
and element.get("model") == "ir.ui.view"
|
||||||
|
):
|
||||||
|
mode = None
|
||||||
|
inherit = None
|
||||||
|
for field in element.findall("field"):
|
||||||
|
if field.get("name") == "mode":
|
||||||
|
mode = (field.text or "").strip()
|
||||||
|
elif field.get("name") == "inherit_id":
|
||||||
|
inherit = field
|
||||||
|
if mode:
|
||||||
|
return mode
|
||||||
|
return "extension" if inherit is not None else "primary"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def analyse(database, target_version):
|
||||||
|
"""Sort COW views into three buckets by comparing with the target sources.
|
||||||
|
|
||||||
|
- at_risk : the module view changes mode -> the copy will break
|
||||||
|
- module_absent : the module itself is gone in the target version
|
||||||
|
- no_counterpart : no module view with that id, so it is a page or a record
|
||||||
|
created from the editor. Normal, and not at risk.
|
||||||
|
"""
|
||||||
|
lst_at_risk = []
|
||||||
|
lst_module_absent = []
|
||||||
|
lst_no_counterpart = []
|
||||||
|
cache_mode = {}
|
||||||
|
|
||||||
|
for view_id, key, mode, website_id in query_cow_views(database):
|
||||||
|
if not key or "." not in key:
|
||||||
|
continue
|
||||||
|
if key not in cache_mode:
|
||||||
|
module_name, _, template_id = key.partition(".")
|
||||||
|
module_dir = find_module_dir(target_version, module_name)
|
||||||
|
if module_dir is None:
|
||||||
|
cache_mode[key] = MODE_UNKNOWN
|
||||||
|
else:
|
||||||
|
cache_mode[key] = declared_mode(module_dir, template_id)
|
||||||
|
target_mode = cache_mode[key]
|
||||||
|
if target_mode == MODE_UNKNOWN:
|
||||||
|
lst_module_absent.append((view_id, key, mode, website_id))
|
||||||
|
elif target_mode is None:
|
||||||
|
lst_no_counterpart.append((view_id, key, mode, website_id))
|
||||||
|
elif target_mode != mode:
|
||||||
|
lst_at_risk.append((view_id, key, mode, target_mode, website_id))
|
||||||
|
return lst_at_risk, lst_module_absent, lst_no_counterpart
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Report website COW views that will break on the next Odoo"
|
||||||
|
" version bump (read-only)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", "--database", required=True, help="database to inspect"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--target_version",
|
||||||
|
required=True,
|
||||||
|
help="target Odoo source directory, e.g. odoo13.0",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="also list the editor-made pages, which are not at risk",
|
||||||
|
)
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isdir(config.target_version):
|
||||||
|
print(
|
||||||
|
f"❌ Target version directory '{config.target_version}' not found."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
||||||
|
config.database, config.target_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if not lst_at_risk:
|
||||||
|
print(
|
||||||
|
"✅ -> No website COW view changes mode in"
|
||||||
|
f" {config.target_version}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving"
|
||||||
|
f" to {config.target_version}: the module view changes mode, but"
|
||||||
|
" the copy keeps an arch written for the old mode."
|
||||||
|
)
|
||||||
|
for view_id, key, mode, target_mode, website_id in lst_at_risk:
|
||||||
|
print(
|
||||||
|
f" - id={view_id} website={website_id} {key}"
|
||||||
|
f" : {mode} -> {target_mode}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Arbitrate BEFORE launching the migration: reset the copy on the"
|
||||||
|
" module view, deactivate it (active=False, reversible), or rewrite"
|
||||||
|
" the customization for the target version."
|
||||||
|
)
|
||||||
|
|
||||||
|
if lst_module_absent:
|
||||||
|
print(
|
||||||
|
f"ℹ {len(lst_module_absent)} COW view(s) belong to a module absent"
|
||||||
|
f" from {config.target_version}:"
|
||||||
|
)
|
||||||
|
for view_id, key, mode, website_id in lst_module_absent:
|
||||||
|
print(f" - id={view_id} website={website_id} {key} ({mode})")
|
||||||
|
|
||||||
|
if lst_no_counterpart:
|
||||||
|
print(
|
||||||
|
f"ℹ {len(lst_no_counterpart)} COW view(s) are pages or records made"
|
||||||
|
" from the website editor (no module view of that name): not at"
|
||||||
|
" risk." + ("" if config.verbose else " Use -v to list them.")
|
||||||
|
)
|
||||||
|
if config.verbose:
|
||||||
|
for view_id, key, mode, website_id in lst_no_counterpart:
|
||||||
|
print(f" - id={view_id} website={website_id} {key} ({mode})")
|
||||||
|
|
||||||
|
# Informative only: never fail the migration on a warning.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -845,7 +845,11 @@ class TodoUpgrade:
|
||||||
for name, reason, origin in lst_uninstall_reason:
|
for name, reason, origin in lst_uninstall_reason:
|
||||||
print(
|
print(
|
||||||
f" - {name}"
|
f" - {name}"
|
||||||
+ (f" — {reason}" if reason else " — ⚠️ no reason given")
|
+ (
|
||||||
|
f" — {reason}"
|
||||||
|
if reason
|
||||||
|
else " — ⚠️ no reason given"
|
||||||
|
)
|
||||||
+ f" [{origin}]"
|
+ f" [{origin}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -891,7 +895,10 @@ class TodoUpgrade:
|
||||||
print(f"🔷 {msg}")
|
print(f"🔷 {msg}")
|
||||||
self.add_comment_progression(msg)
|
self.add_comment_progression(msg)
|
||||||
|
|
||||||
if not self.dct_progression.get("state_2_update_all") and not already_update_state_1:
|
if (
|
||||||
|
not self.dct_progression.get("state_2_update_all")
|
||||||
|
and not already_update_state_1
|
||||||
|
):
|
||||||
status, cmd_executed = self.todo_upgrade_execute(
|
status, cmd_executed = self.todo_upgrade_execute(
|
||||||
f"./script/addons/update_addons_all.sh {database_name}",
|
f"./script/addons/update_addons_all.sh {database_name}",
|
||||||
single_source_odoo=True,
|
single_source_odoo=True,
|
||||||
|
|
@ -900,6 +907,20 @@ class TodoUpgrade:
|
||||||
self.dct_progression["state_2_update_all"] = True
|
self.dct_progression["state_2_update_all"] = True
|
||||||
self.write_config()
|
self.write_config()
|
||||||
|
|
||||||
|
# Predict the website COW views that the NEXT version bump will break.
|
||||||
|
# A copy-on-write view freezes the structure of the module view it was
|
||||||
|
# copied from; when that module view changes mode between two versions,
|
||||||
|
# the copy keeps an arch written for the old mode and the upgrade dies
|
||||||
|
# on « Element ... cannot be located in parent view ». Reporting it here
|
||||||
|
# -- before hours of migration -- leaves time to arbitrate.
|
||||||
|
# Only the next bump can be predicted: the modes in database describe
|
||||||
|
# the current version.
|
||||||
|
self.todo_upgrade_execute(
|
||||||
|
f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py"
|
||||||
|
f" -d {database_name} -t odoo{start_version + 1}.0",
|
||||||
|
wait_at_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
msg = "3 - Clean up database before data migration"
|
msg = "3 - Clean up database before data migration"
|
||||||
print(f"🔷 {msg}")
|
print(f"🔷 {msg}")
|
||||||
self.add_comment_progression(msg)
|
self.add_comment_progression(msg)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue