diff --git a/script/odoo/migration/neutralize_cow_views.py b/script/odoo/migration/neutralize_cow_views.py new file mode 100755 index 0000000..7e32425 --- /dev/null +++ b/script/odoo/migration/neutralize_cow_views.py @@ -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 = '.' || 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}.' 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()) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 156bf5b..4e15955 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -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(