[ADD] migration: neutralize the COW views that would break a version bump

key is the only thing pairing a website copy with the module view it came
from. A copy whose key matches nothing is never paired, so it never receives
the new inherit_id, never changes shape, and never joins a view combination.
Renaming the key is therefore enough to take it out of the way:

    UPDATE ir_ui_view SET key = '<prefix>.' || key, active = false

active = false alone would NOT work: an inactive copy keeping the same key
still shadows the module view. Nothing is deleted either, so inherit_id
ondelete='restrict' and the website_page foreign keys are never touched, and
the old arch stays in database as a readable archive. --restore undoes it.

Written in plain psql on purpose: it runs on a database not yet migrated,
where starting an Odoo shell of the target version is not guaranteed. It is a
systematic step driven by the detector, valid for every bump, so it lives in
the loop rather than in a per-version fix_migration file.

Offered rather than forced, since those copies carry real customizations.

Verified on a throwaway copy: dry-run writes nothing; apply renames the key and
deactivates while keeping the 4058-char arch; a second apply is a no-op;
restore returns the row to its exact initial state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 01:04:38 -04:00
parent 1e4cc4f471
commit 8ce4b93292
2 changed files with 198 additions and 0 deletions

View file

@ -0,0 +1,158 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Neutralize the website COW views that would break a version bump.
How it works
------------
``key`` is the only thing that pairs a website copy with the module view it
came from. A copy whose key matches nothing is never paired, so it never
receives the new ``inherit_id``, never changes shape, and never takes part in
any view combination. Renaming the key is therefore enough to take a copy out
of the way::
UPDATE ir_ui_view SET key = '<prefix>.' || key, active = false WHERE id = ?
Setting ``active = false`` alone would NOT work: an inactive copy that keeps
the same key still shadows the module view.
Nothing is deleted, so ``inherit_id ondelete='restrict'`` and the
``website_page`` foreign keys are never touched, and the 12.0 arch stays in
database as a readable archive. ``--restore`` puts everything back.
Plain psql on purpose: this must run on a database that has not been migrated
yet, where starting an Odoo shell of the target version is not guaranteed.
"""
import argparse
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from check_cow_views import analyse # noqa: E402
DEFAULT_PREFIX = "zz_cow_archive"
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
["psql", "-d", database, "-tAc", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Query failed on '{database}': {result.stderr.strip()}"
)
return result.stdout.strip()
def neutralize(database, lst_view_id, prefix):
"""Rename the key of the given views and deactivate them."""
if not lst_view_id:
return 0
ids = ",".join(str(view_id) for view_id in lst_view_id)
output = run_psql(
database,
"WITH updated AS ("
f" UPDATE ir_ui_view SET key = '{prefix}.' || key, active = false"
f" WHERE id IN ({ids}) AND key NOT LIKE '{prefix}.%'"
" RETURNING 1) SELECT count(*) FROM updated;",
)
return int(output or 0)
def restore(database, prefix):
"""Undo a neutralization: strip the prefix and reactivate."""
output = run_psql(
database,
"WITH updated AS ("
f" UPDATE ir_ui_view SET key = substring(key from {len(prefix) + 2}),"
" active = true"
f" WHERE key LIKE '{prefix}.%'"
" RETURNING 1) SELECT count(*) FROM updated;",
)
return int(output or 0)
def main():
parser = argparse.ArgumentParser(
description=(
"Neutralize the website COW views that would break a version"
" bump, by renaming their key. Dry-run unless --apply."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"-t",
"--target_version",
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"--prefix",
default=DEFAULT_PREFIX,
help=f"archive prefix for the key (default: {DEFAULT_PREFIX})",
)
parser.add_argument(
"--apply", action="store_true", help="actually write to the database"
)
parser.add_argument(
"--restore",
action="store_true",
help="undo a previous neutralization and reactivate the copies",
)
config = parser.parse_args()
if config.restore:
count = restore(config.database, config.prefix)
print(f"✅ -> {count} COW view(s) restored on '{config.database}'.")
return 0
if not config.target_version:
parser.error("--target_version is required unless --restore is used")
if not os.path.isdir(config.target_version):
print(
f"❌ Target version directory '{config.target_version}' not found."
)
return 1
lst_at_risk, _, _ = analyse(config.database, config.target_version)
if not lst_at_risk:
print("✅ -> No website COW view to neutralize.")
return 0
print(
f"⚠️ {len(lst_at_risk)} website COW view(s) would break the bump to"
f" {config.target_version}:"
)
lst_view_id = []
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
lst_view_id.append(view_id)
print(
f" - id={view_id} website={website_id} {key}"
f" : {mode} -> {target_mode} ({reason})"
)
if not config.apply:
print(
" Dry-run. Add --apply to rename their key to"
f" '{config.prefix}.<key>' and deactivate them. Reversible with"
" --restore; the arch stays in database."
)
return 0
count = neutralize(config.database, lst_view_id, config.prefix)
print(
f"✅ -> {count} COW view(s) neutralized (key prefixed with"
f" '{config.prefix}.', deactivated). The arch is kept as an archive;"
" use --restore to undo."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1634,6 +1634,11 @@ class TodoUpgrade:
database_name_upgrade, f"before_{next_version}"
)
# Take the copies that cannot survive this bump out of the way,
# otherwise the data migration dies on them. Offered, not
# forced: their arch is a real customization.
self.neutralize_cow_views(database_name_upgrade, next_version)
status, cmd_executed = self.todo_upgrade_execute(
cmd_upgrade,
new_env={
@ -1861,6 +1866,41 @@ class TodoUpgrade:
wait_at_error=False,
)
def neutralize_cow_views(self, database_name, next_version):
"""Offer to neutralize the COW views that would break this bump.
Renaming their key unpairs them from the module view, so the upgrade
stops choking on them. Nothing is deleted and the operation is
reversible (neutralize_cow_views.py --restore), but the choice belongs
to the user: those copies carry real customizations.
"""
cmd = (
f"{PYTHON_BIN} ./script/odoo/migration/neutralize_cow_views.py"
f" -d {database_name} -t odoo{next_version}.0"
)
status, cmd_executed, output = self.todo_upgrade_execute(
cmd, get_output=True, wait_at_error=False
)
if "No website COW view to neutralize" in "\n".join(output or []):
return
answer = (
input(
"💬 Neutralize these copies so the upgrade can proceed?"
" Their arch is kept and the change is reversible."
" (Y/n) : "
)
.strip()
.lower()
)
if answer == "n":
print(
"⚠️ -> Skipped. The data migration will very likely stop on"
" these views."
)
return
self.todo_upgrade_execute(f"{cmd} --apply", wait_at_error=False)
def diff_cow_views(self, database_name, label_before, label_after):
"""Print what the version bump did to the website COW views."""
directory = os.path.join(