[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:
parent
1e4cc4f471
commit
8ce4b93292
2 changed files with 198 additions and 0 deletions
158
script/odoo/migration/neutralize_cow_views.py
Executable file
158
script/odoo/migration/neutralize_cow_views.py
Executable 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())
|
||||||
|
|
@ -1634,6 +1634,11 @@ class TodoUpgrade:
|
||||||
database_name_upgrade, f"before_{next_version}"
|
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(
|
status, cmd_executed = self.todo_upgrade_execute(
|
||||||
cmd_upgrade,
|
cmd_upgrade,
|
||||||
new_env={
|
new_env={
|
||||||
|
|
@ -1861,6 +1866,41 @@ class TodoUpgrade:
|
||||||
wait_at_error=False,
|
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):
|
def diff_cow_views(self, database_name, label_before, label_after):
|
||||||
"""Print what the version bump did to the website COW views."""
|
"""Print what the version bump did to the website COW views."""
|
||||||
directory = os.path.join(
|
directory = os.path.join(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue