erplibre/script/todo/todo_upgrade.py
Mathieu Benoit 03ed3a6a87 [ADD] migration: a read-only statistics screen, and a way out
The interface choice offered two ways to ACT on a migration and none to just
look at one. Entry [3] reads the progression file and the traces left under
private/, writes nothing, and can be opened as often as one likes — [0] there
returns to the interface choice, and a new [0] Cancel leaves the tool entirely
instead of forcing a migration to be started.

« stats » is deliberately not storable as a default: it does nothing, so
landing on it every time would only be in the way.

The dashboard answers what was asked — which modules were removed, and how far
between Odoo versions — plus what the recorded data already knew and nobody
was showing:

  · elapsed time since the migration started
  · module count per version, with the delta per bump — a jump losing 15
    modules does not mean the same thing as one losing none
  · modules reported missing or duplicated
  · which fix hooks exist and which have run
  · COW snapshots taken, with their view count over time
  · how many commands ran, and the annotated decisions among them

Then, on demand: the removed modules with their justification, the same list
comma-separated to paste, a diff between any two COW snapshots (through the
existing snapshot_cow_views.py --diff), the decisions, and the last commands.

Computation lives in migration_stats.py as pure functions fed by
resume_context() — the same context the resume screen and its TUI already
render, so the step and version-bump state can never be described two
different ways. read_uninstall is INJECTED rather than reimplemented: it
resolves private-then-global itself, so the screen shows exactly the list that
would be applied.

Verified on a realistic progression: 1 j 03 h elapsed, 312 -> 297 -> 282
modules (-15 each), 5 removals across two bumps with their reasons, 4 COW
snapshots ordered by time, the fix applied for 14.0 and pending for 15.0, and
the progression dict byte-identical afterwards. The menu was checked to map
'' /1/2/3/0/9 to tui/tui/cli/stats/None/tui, and [0] to leave without asking
anything else.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 03:16:50 -04:00

2848 lines
119 KiB
Python
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import datetime
import json
import logging
import os
import shutil
import sys
import zipfile
from uuid import uuid4
import click
import todo_file_browser
from script.todo.version_manager import get_odoo_version
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - fallback when i18n is unavailable
def t(key: str) -> str:
return key
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from script.execute import execute
from script.git.git_tool import GitTool
_logger = logging.getLogger(__name__)
PYTHON_BIN = ".venv.erplibre/bin/python3"
UPGRADE_DATABASE_CONFIG_LOG = ".venv.erplibre/odoo_database_migration_log.json"
# UPGRADE_MODULE_CONFIG_LOG = ".venv.erplibre/odoo_module_migration_log.json"
VENV_NAME_MODULE_MIGRATOR = ".venv"
LST_PATH_OCA_ODOO_MODULE_MIGRATOR = ["script", "OCA_odoo-module-migrator"]
PATH_OCA_ODOO_MODULE_MIGRATOR = "./" + "/".join(
LST_PATH_OCA_ODOO_MODULE_MIGRATOR
)
PATH_VENV_MODULE_MIGRATOR = os.path.join(
PATH_OCA_ODOO_MODULE_MIGRATOR, VENV_NAME_MODULE_MIGRATOR
)
PATH_SOURCE_VENV_MODULE_MIGRATOR = os.path.join(
PATH_VENV_MODULE_MIGRATOR, "bin", "activate"
)
FILENAME_ODOO_VERSION = ".odoo-version"
LOCAL_MANIFEST = os.path.join(
".repo", "local_manifests", "erplibre_manifest.xml"
)
# Module lists for a version bump. The shared, versioned defaults live under
# script/; the per-database lists live under private/ (mirroring script/, like
# script/todo/todo.json -> private/todo/todo_override.json). Which modules must
# be dropped depends on the database, so that choice is never versioned.
PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration")
PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration")
# Steps of the migration, in order. Each one owns the progression keys prefixed
# with « state_<index> ». Rewinding to a step drops its keys and every later
# one, so the run replays from there. Labels go through t(): the key IS the
# English string, as everywhere else in this project.
MIGRATION_STEP = [
(0, "Prepare the environment"),
(1, "Restore and neutralize the database"),
(2, "Update all addons"),
(3, "Clean up before data migration"),
(4, "Upgrade version by version (OpenUpgrade)"),
]
class TodoUpgrade:
def __init__(self, todo):
self.file_path = None
self.dir_path = None
self.todo = todo
self.dct_progression = {}
self.lst_command_executed = []
self.dct_module_per_version = {}
self.dct_module_per_dct_version_path = {}
self.execute = execute.Execute()
def write_config(self):
if "date_create" not in self.dct_progression.keys():
self.dct_progression["date_create"] = str(datetime.datetime.now())
self.dct_progression["date_update"] = str(datetime.datetime.now())
# Always put command_executed at the end
if "command_executed" in self.dct_progression.keys():
value = self.dct_progression["command_executed"]
del self.dct_progression["command_executed"]
self.dct_progression["command_executed"] = value
with open(UPGRADE_DATABASE_CONFIG_LOG, "w") as f:
json.dump(self.dct_progression, f, indent=4)
@staticmethod
def read_progression():
"""Return the saved progression, or an empty dict if unreadable."""
try:
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
return json.load(f)
except (json.decoder.JSONDecodeError, OSError):
print(
f"⚠️ {t('The progression file is invalid, ignoring it')}:"
f" {UPGRADE_DATABASE_CONFIG_LOG}"
)
return {}
@staticmethod
def step_status(dct_progression, step):
"""Return (icon, detail) telling how far a migration step went.
Steps 0 to 3 are plain booleans. Step 4 keeps one list per version, so
its detail reports which version bumps are already migrated.
"""
prefix = f"state_{step}_"
dct_flag = {
key: value
for key, value in dct_progression.items()
if key.startswith(prefix)
}
if not dct_flag:
return "", t("not started")
if step == 4:
lst_done = dct_progression.get("state_4_upgrade_odoo_lst") or []
# The data migration list only appears once a bump succeeds, so the
# number of bumps comes from any per-version list (« *_odoo_lst »).
total = max(
[
len(value)
for key, value in dct_progression.items()
if key.startswith("state_4_")
and key.endswith("_odoo_lst")
and isinstance(value, list)
]
or [0]
)
done = sum(1 for item in lst_done if item)
detail = f"{done}/{total} " + t("version bumps migrated")
# Name the versions when the target is known: the list ends on the
# target, so the first bump is target - total + 1.
try:
last = int(float(dct_progression["target_odoo_version"]))
detail += " · " + " ".join(
f"{last - total + 1 + i}"
f"{'' if i < len(lst_done) and lst_done[i] else ''}"
for i in range(total)
)
except (KeyError, TypeError, ValueError):
pass
return ("" if total and done == total else ""), detail
if all(dct_flag.values()):
return "", t("done")
return "", t("partially done")
def resume_context(self, old_dct_progression):
"""Everything the resume screen shows, as plain data.
No I/O: the line-by-line prompt and the TUI both render THIS, so the
two can never describe the migration differently.
"""
migration_file = old_dct_progression.get("migration_file") or "?"
steps = []
for step, label in MIGRATION_STEP:
icon, detail = self.step_status(old_dct_progression, step)
steps.append(
{
"step": step,
"icon": icon,
"label": t(label),
"detail": detail,
}
)
lst_version = self.version_bumps(old_dct_progression)
done = old_dct_progression.get("state_4_upgrade_odoo_lst") or []
return {
"file": os.path.basename(migration_file),
"database": old_dct_progression.get("config_database_name") or "?",
"target": old_dct_progression.get("target_odoo_version") or "?",
"started": old_dct_progression.get("date_create") or "?",
"steps": steps,
"versions": [
{
"version": version,
"done": bool(i < len(done) and done[i]),
}
for i, version in enumerate(lst_version)
],
}
@staticmethod
def print_resume(ctx):
"""Render the resume screen on the terminal."""
print()
print(f"📍 {t('Migration in progress')}")
# Pad in code, not in the translations: the labels differ in length
# between languages and a hardcoded padding misaligns the colons.
print(f" {t('File'):<9}: {ctx['file']}")
print(
f" {t('Database'):<9}: {ctx['database']}"
f" · {t('Target')} : {ctx['target']}"
)
print(f" {t('Started'):<9}: {ctx['started']}")
print()
print(f" {t('Steps')} :")
for item in ctx["steps"]:
print(
f" [{item['step']}] {item['icon']} "
f"{item['label']:<44} {item['detail']}"
)
print()
print(f" [c] {t('Continue where it stopped')}")
print(
f" [0-4] {t('Replay from that step')}"
f" ({t('erases the progression of that step and the next ones')})"
)
if ctx["versions"]:
versions = "/".join(str(v["version"]) for v in ctx["versions"])
print(
f" [4.N] {t('Replay the upgrade from version N')}"
f" ({versions}) —"
f" {t('rebuilds the intermediate database')}"
)
print(f" [n] {t('New migration, erase everything')}")
print(f" [r] {t('Keep the zip only, ask every question again')}")
print(f" [q] {t('Quit without doing anything')}")
def apply_resume_answer(self, old_dct_progression, answer, ctx):
"""Turn the answer into (progression, changed), or None to quit.
THE decision point, shared by both interfaces: the TUI returns the
same answer strings as the prompt, so this logic is written once.
"""
answer = (answer or "").strip().lower()
lst_version = [v["version"] for v in ctx["versions"]]
if answer in ("", "c"):
return old_dct_progression, False
if answer == "q":
return None
if answer == "n":
return {}, True
if answer == "r":
return {
"migration_file": old_dct_progression.get("migration_file"),
"date_create": old_dct_progression.get("date_create"),
}, True
if answer.startswith("4.") and lst_version:
target = answer.split(".", 1)[1].strip()
if target.isdigit() and int(target) in lst_version:
return (
self.rewind_version_bump(
old_dct_progression, lst_version.index(int(target))
),
True,
)
print(f"⚠️ {t('Unknown version')} : {target}")
return old_dct_progression, False
if answer.isdigit() and 0 <= int(answer) <= MIGRATION_STEP[-1][0]:
return (
self.rewind_progression(old_dct_progression, int(answer)),
True,
)
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
return old_dct_progression, False
def prompt_resume(self, old_dct_progression, use_tui=False):
"""Show where the migration stands and ask what to do next.
Returns (progression, changed), or None if the user quits. The old
menu exposed internal key names (« Reuse database without state_4 »),
which said nothing about what would happen; this shows the real state
of every step and lets the user replay from any of them.
"""
ctx = self.resume_context(old_dct_progression)
answer = None
if use_tui:
answer = self.resume_tui(ctx)
if answer is None:
self.print_resume(ctx)
answer = input(f"💬 {t('Your choice')} : ")
return self.apply_resume_answer(old_dct_progression, answer, ctx)
@staticmethod
def ask_ui():
"""Interface of the migration: TUI, line-by-line prompts, or the
read-only statistics screen. Returns None to leave the tool.
The preference can settle it in advance (TODO > Configuration);
« ask » asks. Same contract as the QEMU deployment — except for
« stats », which is never a stored default: it does nothing, so
landing there every time would only be in the way.
"""
try:
from script.todo import todo_prefs
pref = todo_prefs.get("migration_ui")
except Exception:
pref = "ask"
if pref in ("tui", "cli"):
return pref
print(f"\n{t('Interface:')}")
print(f" [1] {t('TUI form')} *")
print(f" [2] {t('Classic questions (line by line)')}")
print(f" [3] {t('Migration statistics (read-only)')}")
print(f" [0] {t('Cancel')}")
print(f" {t('(change the default in TODO > Configuration)')}")
answer = input(t("Choice (0-3, default 1): ")).strip()
return {"0": None, "2": "cli", "3": "stats"}.get(answer, "tui")
def show_stats(self):
"""Écran de statistiques, en lecture seule : rien n'est écrit, ni
dans la base ni dans le journal de migration."""
from script.todo import migration_stats as ms
if not os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
print(f"\n {t('No migration in progress to resume.')}")
return
dct = self.read_progression()
if not dct:
print(f"\n {t('No migration in progress to resume.')}")
return
ctx = self.resume_context(dct)
database_name = dct.get("config_database_name") or ""
stats = ms.compute(
dct,
ctx,
database_name,
self.read_uninstall_module_list,
PATH_MIGRATION_PRIVATE,
PATH_MIGRATION_GLOBAL,
)
while True:
self.print_stats(ctx, stats)
print(f"\n [1] {t('Removed modules, with their reason')}")
print(f" [2] {t('Removed modules, comma-separated (copy)')}")
print(f" [3] {t('COW views: snapshots and differences')}")
print(f" [4] {t('Recorded decisions (journal)')}")
print(f" [5] {t('Executed commands (last 30)')}")
print(f" [0] {t('Back')}")
answer = input(f"💬 {t('Your choice')} : ").strip()
if answer in ("", "0"):
return
if answer == "1":
for version, detail in sorted(stats["uninstall"].items()):
print(f"\n── {version - 1}.0 → {version}.0 ──")
self.print_uninstall_reason(detail)
elif answer == "2":
flat = ms.flat_module_list(stats["uninstall"])
print(f"\n{len(flat)} {t('modules')} :\n")
print(",".join(flat))
elif answer == "3":
self.stats_cow(stats, database_name)
elif answer == "4":
for line in stats["journal"]["comments"]:
print(f" · {line}")
if not stats["journal"]["comments"]:
print(f" {t('nothing recorded')}")
elif answer == "5":
for line in stats["journal"]["commands"][-30:]:
print(f" $ {line}")
else:
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
@staticmethod
def print_stats(ctx, stats):
"""Rend le tableau de bord de la migration."""
print(f"\n📊 {t('Migration statistics')}")
print(f" {t('File'):<11}: {ctx['file']}")
print(
f" {t('Database'):<11}: {ctx['database']}"
f" · {t('Target')} : {ctx['target']}"
)
print(
f" {t('Started'):<11}: {ctx['started']}"
f" · {t('elapsed')} {stats['delay']}"
)
print(f"\n── {t('Level reached')} ──")
done = sum(1 for v in ctx["versions"] if v["done"])
total = len(ctx["versions"]) or 1
line = " "
for item in ctx["versions"]:
mark = "" if item["done"] else ""
line += f"{item['version'] - 1}.0→{item['version']}.0 {mark} "
print(line)
print(
f" {done}/{len(ctx['versions'])} "
f"{t('version bumps migrated')} ({done * 100 // total} %)"
)
print(
" "
+ " ".join(f"[{s['step']}]{s['icon']}" for s in ctx["steps"])
)
print(f"\n── {t('Modules')} ──")
if stats["origin_count"]:
print(f" {t('At the start'):<24}: {stats['origin_count']}")
for version, count, delta in stats["evolution"]:
change = "" if delta is None else f" ({delta:+d})"
print(f" {f'{version}.0':<24}: {count}{change}")
print(f" {t('Removed in total'):<24}: {stats['removed_total']}")
if stats["missing"]:
print(f" {t('Reported missing'):<24}: {len(stats['missing'])}")
if stats["duplicate"]:
print(f" {t('Duplicated'):<24}: {len(stats['duplicate'])}")
if stats["fixes"]:
print(f"\n── {t('Migration fixes')} ──")
for fix in stats["fixes"]:
mark = "" if fix["applied"] else ""
print(f" {mark} {fix['version']}.0 {fix['file']}")
print(f"\n── {t('COW views')} ──")
if stats["cow"]:
for snap in stats["cow"]:
print(
f" {snap['label']:<18} {str(snap['count']):>4} "
f"{t('views')} {snap['taken_at']}"
)
else:
print(f" {t('no snapshot')}")
print(f"\n── {t('Journal')} ──")
print(
f" {len(stats['journal']['commands'])} {t('commands')}, "
f"{len(stats['journal']['comments'])} {t('recorded decisions')}"
)
def stats_cow(self, stats, database_name):
"""Instantanés COW, et différence entre deux d'entre eux."""
snaps = stats["cow"]
if len(snaps) < 2:
print(f" {t('Need two snapshots to diff.')}")
return
for index, snap in enumerate(snaps, 1):
print(
f" [{index}] {snap['label']:<18} "
f"{str(snap['count']):>4} {t('views')} {snap['taken_at']}"
)
raw = input(
f"💬 {t('Diff which two? (e.g. 1,2 — blank to skip)')} : "
).strip()
parts = [p.strip() for p in raw.replace(",", " ").split()]
if len(parts) != 2 or not all(p.isdigit() for p in parts):
return
first, second = (int(p) - 1 for p in parts)
if not (0 <= first < len(snaps) and 0 <= second < len(snaps)):
return
self.diff_cow_views(
database_name, snaps[first]["label"], snaps[second]["label"]
)
@staticmethod
def resume_tui(ctx):
"""Resume screen as a TUI. Returns the SAME answer strings as the
prompt, or None when textual is missing (fall back to the prompt)."""
try:
from script.todo.migration_form import run_resume_tui
return run_resume_tui(ctx)
except ImportError:
print(t("Install textual for the dashboard (pip)."))
return None
@staticmethod
def version_bumps(dct_progression):
"""Odoo versions the step 4 loop walks through, e.g. [13, 14, ..., 18].
The per-version lists all end on the target, so the first bump is
« target - len + 1 ». Returns [] when step 4 has not started.
"""
total = max(
[
len(value)
for key, value in dct_progression.items()
if key.startswith("state_4_")
and key.endswith("_odoo_lst")
and isinstance(value, list)
]
or [0]
)
if not total:
return []
try:
last = int(float(dct_progression["target_odoo_version"]))
except (KeyError, TypeError, ValueError):
return []
return list(range(last - total + 1, last + 1))
@staticmethod
def rewind_version_bump(old_dct_progression, index):
"""Replay the step 4 loop from one version bump onwards.
Only the per-version lists are trimmed from `index`: earlier bumps stay
migrated and steps 0 to 3 are untouched. Resetting the clone entry is
the point — the intermediate database of a failed bump is half
migrated, so it must be dropped and rebuilt from the previous version
rather than upgraded again.
"""
dct_kept = dict(old_dct_progression)
for key, value in old_dct_progression.items():
if (
key.startswith("state_4_")
and isinstance(value, list)
and key.endswith(("_odoo_lst", "_module"))
):
dct_kept[key] = [
item if i < index else False
for i, item in enumerate(value)
]
return dct_kept
@staticmethod
def rewind_progression(old_dct_progression, step):
"""Drop the progression of `step` and of every later step.
Configuration answers (config_*), the zip and the target version are
kept: they are decisions, not progress. Only « state_* » is rewound.
"""
dct_kept = {}
for key, value in old_dct_progression.items():
if not key.startswith("state_"):
dct_kept[key] = value
continue
index = key[len("state_") :].split("_", 1)[0]
if index.isdigit() and int(index) < step:
dct_kept[key] = value
# The module search fills an in-memory dict the later steps rely on;
# it must run again even when step 0 itself is kept.
dct_kept["state_0_search_missing_module"] = False
print(
f"{t('Replaying from step')} {step}"
f" {t(dict(MIGRATION_STEP)[step])}"
)
return dct_kept
def on_file_selected(self, file_path):
self.file_path = file_path
todo_file_browser.exit_program()
def on_dir_selected(self, dir_path):
self.dir_path = dir_path
todo_file_browser.exit_program()
def execute_module_upgrade(self):
print("Welcome to Odoo module upgrade processus with ERPLibre 🤖")
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
try:
old_dct_progression = json.load(f)
self.dct_progression = old_dct_progression
self.lst_command_executed = old_dct_progression.get(
"command_executed"
)
except json.decoder.JSONDecodeError:
print(
f'⚠️ The config file "{UPGRADE_DATABASE_CONFIG_LOG}" is invalid, ignore it.'
)
print(
"Migrate a directory repo to migrate all module, or select a directory module."
)
print("[m] Migrate one module")
print("[r] Migrate one repo")
print("[] Directory browser")
lst_dir_path = []
from_version = 0
to_version = 0
status = input("💬 What do you choose : ").strip().lower()
if status == "m":
print("[p] Path (default)")
print("[n] Name")
status = input("💬 What do you choose : ").strip().lower()
if status == "n":
module_name = input("💬 Module name : ").strip().lower()
from_version = int(
input("💬 From odoo version (12 to 18) : ").strip()
)
to_version = int(
input("💬 To odoo version (12 to 18) : ").strip()
)
self.switch_odoo(from_version)
# TODO detect path
(
lst_module_missing,
lst_module_duplicate,
lst_module_exist,
lst_module_error,
) = self.check_addons_exist([module_name], get_all_info=True)
if (
lst_module_missing
or lst_module_duplicate
or lst_module_error
):
if lst_module_missing:
print(f"Missing list : {lst_module_missing}")
if lst_module_duplicate:
print(f"Duplicate list : {lst_module_duplicate}")
if lst_module_error:
print(f"Error list : {lst_module_error}")
return
lst_dir_path.extend(lst_module_exist)
else:
self.dir_path = input("💬 Path : ").strip()
elif status == "r":
self.dir_path = input("💬 Path : ").strip()
else:
self.dir_path = None
if not self.dir_path and not lst_dir_path:
initial_dir = os.getcwd()
file_browser = todo_file_browser.FileBrowser(
initial_dir, self.on_dir_selected, open_dir=True
)
file_browser.run_main_frame()
if not lst_dir_path and not os.path.exists(self.dir_path):
_logger.error(f"Path '{self.dir_path}' not exists.")
return
if not from_version:
from_version = int(
input("💬 From odoo version (12 to 18) : ").strip()
)
if not to_version:
to_version = int(input("💬 To odoo version (12 to 18) : ").strip())
# TODO ask option direct migration
lst_version_to_update = [
(a, a + 1) for a in range(from_version, to_version)
]
# lst_version_to_update = [(from_version, to_version)]
# for actual_version in range(from_version, to_version):
# next_version = actual_version + 1
for actual_version, next_version in lst_version_to_update:
set_path_migrate_addons = set()
if not lst_dir_path:
if os.path.exists(
os.path.join(self.dir_path, "__manifest__.py")
):
lst_dir_path = [
[os.path.basename(self.dir_path), self.dir_path]
]
else:
lst_dir_path = [
[a, os.path.join(self.dir_path, a)]
for a in os.listdir(self.dir_path)
if os.path.exists(
os.path.join(self.dir_path, a, "__manifest__.py")
)
]
lst_path_git_clone_migrate = []
lst_module_to_migrate_all = []
for dir_path in lst_dir_path:
source_manifest_path = os.path.join(
dir_path[1], "__manifest__.py"
).replace(f"odoo{from_version}.0", f"odoo{actual_version}.0")
is_dir_module = os.path.exists(source_manifest_path)
if not is_dir_module:
continue
source_module_path = dir_path[1].replace(
f"odoo{from_version}.0", f"odoo{actual_version}.0"
)
source_addons_path = os.path.dirname(dir_path[1]).replace(
f"odoo{from_version}.0", f"odoo{actual_version}.0"
)
target_module_path = source_module_path.replace(
f"odoo{actual_version}.0", f"odoo{next_version}.0"
)
target_manifest_path = source_manifest_path.replace(
f"odoo{actual_version}.0", f"odoo{next_version}.0"
)
target_addons_path = source_addons_path.replace(
f"odoo{actual_version}.0", f"odoo{next_version}.0"
)
dct_module = {
"source_module_path": source_module_path,
"source_manifest_path": source_manifest_path,
"source_addons_path": source_addons_path,
"target_module_path": target_module_path,
"target_manifest_path": target_manifest_path,
"target_addons_path": target_addons_path,
"module_name": dir_path[0],
"source_version_odoo": actual_version,
"target_version_odoo": next_version,
}
lst_module_to_migrate_all.append(dct_module)
set_path_migrate_addons.add(target_addons_path)
# lst_path_git_clone_migrate.append(target_addons_path)
# TODO auto detect version from
# TODO if detect from directory, check from repo list
# TODO detect from manifest version
self.switch_odoo(next_version)
self.install_OCA_odoo_module_migrator()
self.internal_module_upgrade(
next_version,
lst_module_to_migrate_all,
lst_path_git_clone_migrate,
)
for commit_path in set_path_migrate_addons:
cmd = f"./script/code/git_commit_migration_addons_path.py --path {commit_path} --odoo_version {next_version}.0"
self.todo_upgrade_execute(cmd)
print(set_path_migrate_addons)
status = input(
f"💬 Please validate git commit on repos, press to continue : "
).strip()
def internal_module_upgrade(
self,
next_version,
lst_module_to_migrate_all,
lst_path_git_clone_migrate,
):
has_cmd = False
cmd_parallel = "parallel :::"
for dct_module in lst_module_to_migrate_all:
target_addons_path = dct_module.get("target_addons_path")
source_addons_path = dct_module.get("source_addons_path")
module_name = dct_module.get("module_name")
source_version_odoo = f'{dct_module.get("source_version_odoo")}.0'
target_version_odoo = f'{dct_module.get("target_version_odoo")}.0'
source_module_path_to_copy = dct_module.get("source_module_path")
# Prepare git environment for target
if target_addons_path not in lst_path_git_clone_migrate:
lst_path_git_clone_migrate.append(target_addons_path)
self.check_and_clone_source_to_target_migration_code(
next_version,
source_addons_path,
target_addons_path,
)
cmd_migration = (
f"echo 'odoo_module_migrate {module_name}' && "
f"cp -r {source_module_path_to_copy} {target_addons_path} && "
f"cd {PATH_OCA_ODOO_MODULE_MIGRATOR} && "
f"source {VENV_NAME_MODULE_MIGRATOR}/bin/activate && "
f"python -m odoo_module_migrate --directory {target_addons_path} --modules {module_name} "
f"--init-version-name {source_version_odoo} --target-version-name {target_version_odoo} "
f"--no-commit && "
f"cd ~- "
# f"cp -r {source_module_path_to_copy} {target_module_path_to_copy} && "
# f"cd {target_module_path_to_copy} && git commit -am '[MIG] {module_name}: Migration to {target_version_odoo}' && cd ~-"
)
cmd_parallel += f' "{cmd_migration}"'
has_cmd = True
if lst_module_to_migrate_all:
if has_cmd:
self.todo_upgrade_execute(cmd_parallel)
print("List of path with migrate code :")
print(lst_path_git_clone_migrate)
print(" To show repo status :\nmake repo_show_status")
input("💬 Check migration code, press to continue : ")
# source_module_path = dct_module_result.get(
# "source_module_path"
# )
# if not source_module_path:
# _logger.error(
# f"Missing source module path '{source_module_path}'"
# )
# else:
# if os.path.exists(
# os.path.join(source_module_path, ".git")
# ):
# self.todo_upgrade_execute(
# f"cd '{source_module_path}' && git stash && cd ~-",
# )
#
# target_module_path = dct_module_result.get(
# "target_module_path"
# )
# if not target_module_path:
# _logger.error(
# f"Missing target module path '{target_module_path}'"
# )
# else:
# # TODO check if has file to commit
# self.todo_upgrade_execute(
# f"cd '{target_module_path}' && git commit -am '[MIG] {len(lst_module_to_migrate_all)} modules: Migration to {next_version}' && cd ~-",
# )
# TODO copie to next odoo version
# do commit and continue
# continue migration to loop
if next_version in [18]:
# TODO need odoo 18, validate python version without switch
status = input(
f"💬 Please validate repo is ready to run upgrade views_migration_18, press to continue : "
).strip()
# Apply modification with views_migration_18
has_cmd = False
# cmd_serial = ""
cmd_parallel = "parallel :::"
for path_git_clone_migrate in lst_path_git_clone_migrate:
cmd_migration = (
f"echo 'views_migration_18 {path_git_clone_migrate}' && "
f"./.venv.odoo18.0_python3.12.10/bin/python ./script/code/odoo_upgrade_code_with_dir_module.py --path {path_git_clone_migrate}"
)
cmd_parallel += f' "{cmd_migration}"'
# cmd_serial += f"{cmd_migration};"
has_cmd = True
if has_cmd:
# self.todo_upgrade_execute(
# cmd_serial
# )
self.todo_upgrade_execute(cmd_parallel)
print("List of module with migration 18 :")
print(lst_module_to_migrate_all)
print(" To show repo status :\nmake repo_show_status")
input("💬 Check migration 18 code, press to continue : ")
if next_version == 17:
status = input(
f"💬 Please validate repo is ready to run upgrade views_migration_17, press to continue : "
).strip()
# Apply modification with views_migration_17
has_cmd = False
cmd_serial = ""
cmd_parallel = "parallel :::"
for dct_module in lst_module_to_migrate_all:
database_migration_17_name = (
f"migration_odoo_{next_version}_{str(uuid4())[:6]}"
)
module_name = dct_module.get("module_name")
cmd_migration = (
f"echo 'views_migration_17 {module_name}' && "
f"./run.sh -d {database_migration_17_name} -i {module_name} --load=base,web,views_migration_17 --dev upgrade --no-http --stop-after-init"
)
cmd_parallel += f' "{cmd_migration}"'
cmd_serial += f"{cmd_migration};"
has_cmd = True
if has_cmd:
# self.todo_upgrade_execute(
# cmd_serial
# )
self.todo_upgrade_execute(cmd_parallel)
print("List of module with migration 17 :")
print(lst_module_to_migrate_all)
print(" To show repo status :\nmake repo_show_status")
input("💬 Check migration 17 code, press to continue : ")
def execute_odoo_upgrade(self):
# TODO update dev environment for git project
# TODO Redeploy new production after upgrade
# 2 upgrades version = 5 environnement. 0-prod init, 1-dev init, 2-dev01, 3-dev02, 4-prod final
print("Welcome to Odoo database upgrade processus with ERPLibre 🤖")
self.lst_command_executed = []
self.dct_module_per_version = {}
self.dct_module_per_dct_version_path = {}
default_database_name = "test"
# L'écran de statistiques ne fait rien : on y revient autant de fois
# qu'on veut, et on repose ensuite le choix d'interface.
while True:
ui = self.ask_ui()
if ui is None:
return
if ui != "stats":
break
self.show_stats()
use_tui = ui == "tui"
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
old_dct_progression = self.read_progression()
if old_dct_progression:
resumed = self.prompt_resume(old_dct_progression, use_tui)
if resumed is None:
return
self.dct_progression, changed = resumed
if changed:
self.write_config()
elif use_tui:
print(f" {t('No migration in progress to resume.')}")
elif use_tui:
print(f" {t('No migration in progress to resume.')}")
if "migration_file" in self.dct_progression:
self.file_path = self.dct_progression["migration_file"]
else:
print("")
print("Select the zip file of you database backup.")
self.file_path = input(
"💬 Give the path of file, or empty to use a File Browser, or type 'remote' to download from production : "
)
if not self.file_path.strip():
self.file_path = None
if not self.file_path:
initial_dir = os.path.join(os.getcwd(), "image_db")
file_browser = todo_file_browser.FileBrowser(
initial_dir, self.on_file_selected
)
file_browser.run_main_frame()
elif self.file_path == "remote":
status, self.file_path, default_database_name = (
self.todo.db_manager.download_database_backup_cli()
)
if status:
_logger.error(
"Cannot retrieve database from remote, please retry migration."
)
return
self.dct_progression["migration_file"] = self.file_path
self.write_config()
print(f"✅ Open file {self.file_path}")
with zipfile.ZipFile(self.file_path, "r") as zip_ref:
manifest_file_1 = zip_ref.open("manifest.json")
json_manifest_file_1 = json.load(manifest_file_1)
odoo_actual_version = json_manifest_file_1.get("version")
print(f"✅ Detect version Odoo CE '{odoo_actual_version}'.")
# print("What is your actual Odoo version?")
lst_version, lst_version_installed, odoo_installed_version = (
get_odoo_version()
)
lst_odoo_version = [
{"prompt_description": a.get("odoo_version")}
for a in lst_version
if float(a.get("odoo_version")) > float(odoo_actual_version)
]
help_info = self.todo.fill_help_info(lst_odoo_version)
if "target_odoo_version" in self.dct_progression:
odoo_target_version = self.dct_progression["target_odoo_version"]
else:
print("💬 Which version do you want to upgrade to?")
odoo_target_version = None
cmd_no_found = True
while cmd_no_found:
status = click.prompt(help_info)
try:
int_cmd = int(status)
if 0 < int_cmd <= len(lst_odoo_version):
cmd_no_found = False
odoo_target_version = lst_odoo_version[
int_cmd - 1
].get("prompt_description")
except ValueError:
pass
if cmd_no_found:
print("Commande non trouvée 🤖!")
self.dct_progression["target_odoo_version"] = odoo_target_version
self.write_config()
# Search nb diff to use range
start_version = int(float(odoo_actual_version))
end_version = int(float(odoo_target_version))
range_version = range(start_version, end_version)
lst_module = sorted(
list(set(json_manifest_file_1.get("modules").keys()))
)
self.dct_module_per_version[start_version] = lst_module
self.dct_progression["dct_module_per_version"] = (
self.dct_module_per_version
)
self.dct_progression["lst_module_per_version_origin"] = lst_module
# TODO need support minor version, example 18.2, the .2 (no need for OCE OCB)
print("✨ Show documentation version :")
# TODO Generate it locally and show it if asked
for next_version in range_version:
print(
f"https://oca.github.io/OpenUpgrade/coverage_analysis/modules{next_version*10}-{(next_version+1)*10}.html"
)
# ⚠️ 💬 ❗ 🔷 ✨ 🟦 🔹 🔵 ⟳ ⧖ ⚙ ✔ ✅ ❌ ⏵ ⏸ ⏹ ◆ ◇ … ➤ ⚑ ★ ☆ ☰ ⬍ ⍟ ⊗ ⌘ ⏻ ⍰
msg = "0 - Inspect zip"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
print("✅ -> Search odoo version")
print("✅ -> Find good environment, read the .zip file")
is_state_4_reach_open_upgrade = self.dct_progression.get(
"state_4_reach_open_upgrade"
)
if not is_state_4_reach_open_upgrade and not self.dct_progression.get(
"state_0_install_odoo"
):
lst_diff_version = sorted(
list(
set([f"odoo{a}.0" for a in range_version]).difference(
set(lst_version_installed)
)
)
)
for odoo_version_to_install in lst_diff_version:
iter_range_version = odoo_version_to_install.replace(
"odoo", ""
).replace(".0", "")
want_continue = input(
f"💬 Would you like to install '{odoo_version_to_install}' (y/Y) : "
)
if want_continue.strip().lower() != "y":
return
self.todo_upgrade_execute(
f"make install_odoo_{iter_range_version}"
)
if not os.path.isfile(FILENAME_ODOO_VERSION):
print(
"⚠️ You need an installed system before continue, check your Odoo installation."
)
return
self.dct_progression["state_0_install_odoo"] = True
self.write_config()
# not self.dct_progression.get("state_0_switch_odoo")
if not is_state_4_reach_open_upgrade:
self.switch_odoo(odoo_actual_version)
# self.dct_progression["state_0_switch_odoo"] = True
# self.write_config()
print("✅ -> Install environment if missing")
if not self.dct_progression.get("state_0_search_missing_module"):
self.switch_odoo(odoo_actual_version)
dct_bd_modules = json_manifest_file_1.get("modules")
lst_module_to_check = [a for a in dct_bd_modules.keys()]
(
lst_module_missing,
lst_module_duplicate,
lst_module_exist,
lst_module_error,
) = self.check_addons_exist(lst_module_to_check, get_all_info=True)
if not lst_module_missing:
lst_module_missing = []
dct_module_exist = {}
if not lst_module_exist:
lst_module_exist = []
else:
for item_lst_module_exist in lst_module_exist:
dct_module_exist[item_lst_module_exist[0]] = (
item_lst_module_exist[1].replace(os.getcwd(), ".")
)
if not lst_module_duplicate:
lst_module_duplicate = []
lst_module_missing = sorted(list(set(lst_module_missing)))
self.dct_progression["len_lst_module_missing"] = len(
lst_module_missing
)
self.dct_progression["lst_module_missing"] = lst_module_missing
self.dct_progression["len_dct_module_exist"] = len(
lst_module_exist
)
self.dct_progression["dct_module_exist"] = dct_module_exist
self.dct_progression["len_lst_module_duplicate"] = len(
lst_module_duplicate
)
self.dct_progression["lst_module_duplicate"] = lst_module_duplicate
self.write_config()
if lst_module_missing or lst_module_duplicate:
print("Cannot setup environment to begin.")
if lst_module_missing:
print("Missing module :")
print(lst_module_missing)
if lst_module_duplicate:
print("Duplicate module :")
print(lst_module_duplicate)
want_continue = input(
"💬 Detect error missing/duplicate module init, do you want to continue? (Y/N): "
)
if want_continue.strip().lower() != "y":
return
self.dct_progression["state_0_search_missing_module"] = True
self.write_config()
else:
# TODO fill from config
lst_module_missing = []
print("✅ -> Search missing module")
print(
"❌ -> Install missing module, do a research or ask to uninstall it (can break data)"
)
msg = "1 - Import database from zip"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
database_name = self.dct_progression.get("config_database_name")
if not database_name:
database_name = (
input(
f"💬 Witch database name do you want to work with? Default ({default_database_name}) : "
).strip()
or default_database_name
)
self.dct_progression["config_database_name"] = database_name
self.write_config()
do_neutralize = False
if not self.dct_progression.get("state_1_neutralize_database"):
print("[1] Ignore neutralize database")
wait_continue = (
input("💬 Neutralize database, press to continue : ")
.strip()
.lower()
)
if wait_continue != "1":
do_neutralize = True
database_name += "_neutralize"
self.dct_progression["config_database_name"] = database_name
self.write_config()
print(f"★ Work with database '{database_name}'")
if not self.dct_progression.get("state_1_restore_database"):
file_name = os.path.basename(self.file_path)
image_db_file_path = os.path.join("image_db", file_name)
str_will_copy = (
f"🤖 will copy '{self.file_path}' to '{image_db_file_path}'"
)
if not shutil._samefile(self.file_path, image_db_file_path):
do_copy = False
if os.path.exists(image_db_file_path):
status_overwrite_image_db = input(
f"{str_will_copy}, "
f"a file already exist, do you want to continue (y/Y) : "
).strip()
if status_overwrite_image_db.lower() == "y":
do_copy = True
os.remove(image_db_file_path)
else:
print(str_will_copy)
do_copy = True
if do_copy:
shutil.copy(self.file_path, image_db_file_path)
neutralize_arg = ""
if start_version >= 16 and do_neutralize:
neutralize_arg = " --neutralize"
status, cmd_executed = self.todo_upgrade_execute(
f"./script/database/db_restore.py --database {database_name} --image {file_name} --ignore_cache{neutralize_arg}",
single_source_odoo=True,
)
if not status:
self.dct_progression["state_1_restore_database"] = True
self.write_config()
print("✅ -> Restore database")
already_update_state_1 = False
if not self.dct_progression.get("state_1_update_all"):
print(
"[1] Update all addons before neutralize (already neutralize by odoo if supported)"
)
wait_continue = (
input(
"💬 Do you need to upgrade before a database neutralization, press to ignore : "
)
.strip()
.lower()
)
if wait_continue == "1":
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/update_addons_all.sh {database_name}",
single_source_odoo=True,
)
if not status:
already_update_state_1 = True
self.dct_progression["state_1_update_all"] = True
self.write_config()
print("✅ -> Update database before neutralize by module")
print("✅ -> Neutralize database")
if do_neutralize:
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/update_prod_to_dev.sh {database_name}",
single_source_odoo=True,
)
# TODO exécuter next line si status != 0 et log contient
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
# source ./.venv.odoo18.0_python3.12.10/bin/activate && cat script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py | ./odoo18.0/odoo/odoo-bin shell -d ripbylop_stage_prod_17_nov_2025
# psycopg2.errors.ForeignKeyViolation: insert or update on table "discuss_channel_member" violates foreign key constraint "discuss_channel_member_channel_id_fkey"
# DÉTAIL : Key (channel_id)=(20) is not present in table "discuss_channel".
# ./script/database/migrate/process_backup_file.py --path_backup_zip image_db/db.zip --path_output_zip image_db/dbFIX.zip --word_to_delete discuss_channel_channel_type_not_null
# Puis faire un retry de la commande, sinon rien
# Only record the step when it actually succeeded. The previous
# unconditional assignment made the test below dead code: a failed
# neutralization was remembered as done and skipped on resume.
if not status:
self.dct_progression["state_1_neutralize_database"] = True
self.write_config()
else:
self.dct_progression["state_1_neutralize_database"] = True
self.write_config()
config_state_1_uninstall_module = self.dct_progression.get(
"config_state_1_uninstall_module"
)
config_state_1_install_module = self.dct_progression.get(
"config_state_1_install_module"
)
if not is_state_4_reach_open_upgrade:
lst_module_to_uninstall, lst_uninstall_reason = (
self.read_uninstall_module_list(start_version, database_name)
)
if lst_uninstall_reason:
print("✨ Modules to uninstall before migration :")
self.print_uninstall_reason(lst_uninstall_reason)
if config_state_1_uninstall_module:
lst_module_to_uninstall = (
lst_module_to_uninstall + config_state_1_uninstall_module
)
if lst_module_to_uninstall:
self.uninstall_from_database(
lst_module_to_uninstall, database_name, start_version
)
self.dct_progression["state_1_uninstall_module"] = True
self.write_config()
self.dct_progression["config_state_1_uninstall_module"] = (
config_state_1_uninstall_module
)
self.write_config()
print("✅ -> Uninstall module")
print("✅ -> install module")
if not is_state_4_reach_open_upgrade:
lst_module_to_install = []
if config_state_1_install_module:
lst_module_to_install = (
lst_module_to_install + config_state_1_install_module
)
if lst_module_to_install:
self.install_from_database(
lst_module_to_install, database_name, start_version
)
self.dct_progression["state_1_install_module"] = True
self.write_config()
self.dct_progression["config_state_1_install_module"] = (
config_state_1_install_module
)
self.write_config()
msg = "2 - Succeed update all addons"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
if (
not self.dct_progression.get("state_2_update_all")
and not already_update_state_1
):
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/update_addons_all.sh {database_name}",
single_source_odoo=True,
)
if not status:
self.dct_progression["state_2_update_all"] = True
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"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
if not self.dct_progression.get("state_3_install_clean_database"):
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/install_addons.sh {database_name} database_cleanup",
single_source_odoo=True,
)
if not status:
self.dct_progression["state_3_install_clean_database"] = True
self.write_config()
if not self.dct_progression.get("state_3_clean_database"):
print(
"✨ Aller dans «configuration/Technique/Nettoyage.../Purger» les modules obsolètes"
)
status = input(
"💬 Did you finish to clean database? Press y/Y to open server with selenium, else ignore it : "
).strip()
if status.lower().strip() == "y":
self.todo.prompt_execute_selenium_and_run_db(database_name)
status = input("💬 Press to continue state.3 : ").strip()
self.dct_progression["state_3_clean_database"] = True
self.write_config()
self.install_OCA_odoo_module_migrator()
msg = "4 - Upgrade version with OpenUpgrade"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
self.dct_progression["state_4_reach_open_upgrade"] = True
self.write_config()
lst_next_version = [
a for a in range(start_version + 1, end_version + 1)
]
lst_database_name_upgrade = [
f"{database_name}_upgrade_{str(a)}" for a in lst_next_version
]
# Setup lst_switch_odoo
lst_clone_odoo = self.dct_progression.get(
"state_4_clone_odoo_lst", [False] * len(lst_next_version)
)
lst_switch_odoo = self.dct_progression.get(
"state_4_switch_odoo_lst", [False] * len(lst_next_version)
)
lst_module_migrate_odoo = self.dct_progression.get(
"state_4_module_migrate_odoo_lst", [False] * len(lst_next_version)
)
lst_module_uninstall_module = self.dct_progression.get(
"state_4_uninstall_module", [False] * len(lst_next_version)
)
lst_module_install_module = self.dct_progression.get(
"state_4_install_module", [False] * len(lst_next_version)
)
lst_module_search_missing_module = self.dct_progression.get(
"state_4_search_missing_module", [False] * len(lst_next_version)
)
nb_missing_value_switch_odoo = abs(
len(lst_switch_odoo) - len(lst_next_version)
)
if nb_missing_value_switch_odoo:
lst_switch_odoo += [False] * nb_missing_value_switch_odoo
# Setup lst_upgrade_odoo
lst_upgrade_odoo = self.dct_progression.get(
"state_4_upgrade_odoo_lst", [[]] * len(lst_next_version)
)
lst_fix_migration_odoo = self.dct_progression.get(
"state_4_fix_migration_odoo_lst", [[]] * len(lst_next_version)
)
nb_missing_value_upgrade_odoo = abs(
len(lst_upgrade_odoo) - len(lst_next_version)
)
if nb_missing_value_upgrade_odoo:
lst_upgrade_odoo += [[]] * nb_missing_value_upgrade_odoo
database_name_upgrade = None
lst_module_missing_next_version = []
lst_module_to_delete = []
lst_module_to_delete_last_version = []
for index, next_version in enumerate(lst_next_version):
# Reinit the list
lst_module_missing_last_version = lst_module_missing_next_version[
:
]
lst_module_to_delete_last_version.extend(lst_module_to_delete)
lst_module_to_delete = []
msg = f"4.{index} - Ready to work with version {next_version}"
self.add_comment_progression(msg)
option_comment = 0
msg = f"4.{index}.{chr(option_comment + 65)} - Search updated module list to next version"
self.add_comment_progression(msg)
if not database_name_upgrade:
last_database_name = database_name
else:
last_database_name = database_name_upgrade
database_name_upgrade = lst_database_name_upgrade[index]
lst_module_to_uninstall = []
lst_module_to_install = []
lst_module_to_analyse = self.get_rename_module(
self.dct_module_per_version[next_version - 1],
next_version,
)
self.dct_module_per_version[next_version] = sorted(
list(set(lst_module_to_analyse))
)
self.dct_progression["dct_module_per_version"] = (
self.dct_module_per_version
)
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Clone Odoo"
self.add_comment_progression(msg)
if not os.path.exists(f"odoo{next_version}.0/addons/addons"):
os.makedirs(f"odoo{next_version}.0/addons/addons")
if not lst_clone_odoo[index]:
self.switch_odoo(next_version - 1)
print(
f"⧖ -> Clone to odoo.'{next_version}', from '{database_name}' to '{database_name_upgrade}'."
)
# Delete if exist database
self.todo_upgrade_execute(
f"./script/database/db_restore.py -d {database_name_upgrade} --only_drop",
)
# Duplicate database
cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}"
status, cmd_executed = self.todo_upgrade_execute(
cmd_clone_database
)
# Everything downstream runs against this clone: if it failed,
# do not mark it done (a rerun would skip the clone and migrate
# a missing or truncated database).
if status:
print(
f"❌ -> Clone to Odoo{next_version} FAILED (status"
f" {status}). Stopping: '{database_name_upgrade}' is"
" not usable."
)
return
lst_clone_odoo[index] = True
self.dct_progression["state_4_clone_odoo_lst"] = lst_clone_odoo
self.write_config()
print(f"✅ -> Clone Odoo{next_version} done")
else:
print(f"✅ -> Clone Odoo{next_version} - nothing")
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Uninstall module"
self.add_comment_progression(msg)
config_state_4_uninstall_module = self.dct_progression.get(
"config_state_4_uninstall_module",
[False] * len(lst_next_version),
)
if not lst_module_uninstall_module[index]:
lst_module_to_uninstall = (
config_state_4_uninstall_module[index] or []
)
# Same file convention as step 1, one file per version bump:
# uninstall_module_list_odoo130_to_odoo140.txt is read HERE,
# right before the 13 -> 14 data migration. Without this the
# per-bump files existed in name only and were never read.
lst_file, lst_detail = self.read_uninstall_module_list(
next_version - 1, database_name
)
if lst_detail:
print(
f"✨ Modules to uninstall before Odoo{next_version} :"
)
self.print_uninstall_reason(lst_detail)
lst_module_to_uninstall = list(
dict.fromkeys(list(lst_module_to_uninstall) + lst_file)
)
if lst_module_to_uninstall:
self.uninstall_from_database(
lst_module_to_uninstall,
database_name_upgrade,
next_version - 1,
)
lst_module_uninstall_module[index] = True
self.dct_progression["state_4_module_migrate_odoo_lst"] = (
lst_module_uninstall_module
)
self.write_config()
self.dct_progression["config_state_4_uninstall_module"] = (
config_state_4_uninstall_module
)
self.dct_progression["state_4_uninstall_module"] = (
lst_module_uninstall_module
)
self.write_config()
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Install module"
self.add_comment_progression(msg)
config_state_4_install_module = self.dct_progression.get(
"config_state_4_install_module",
[False] * len(lst_next_version),
)
# Special case to install module to fix migration
if (
next_version == 13
and "dms" in lst_module_to_analyse
and "muk_dms"
not in self.dct_progression.get("dct_module_exist", {}).keys()
):
# Force install dms into odoo 12
if not config_state_4_install_module[index]:
config_state_4_install_module[index] = ["dms"]
elif "dms" not in config_state_4_install_module[index]:
config_state_4_install_module[index].append("dms")
if not lst_module_install_module[index]:
lst_module_to_install = config_state_4_install_module[index]
if not lst_module_to_install:
lst_module_to_install = []
if lst_module_to_install:
self.install_from_database(
lst_module_to_install,
database_name_upgrade,
next_version - 1,
)
lst_module_install_module[index] = True
self.dct_progression["state_4_module_migrate_odoo_lst"] = (
lst_module_install_module
)
self.write_config()
self.dct_progression["config_state_4_install_module"] = (
config_state_4_install_module
)
self.dct_progression["state_4_install_module"] = (
lst_module_install_module
)
self.write_config()
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Switch Odoo"
self.add_comment_progression(msg)
if not lst_switch_odoo[index]:
self.switch_odoo(next_version)
lst_switch_odoo[index] = True
self.dct_progression["state_4_switch_odoo_lst"] = (
lst_switch_odoo
)
self.write_config()
print(f"✅ -> Switch Odoo{next_version} done with update")
else:
print(f"✅ -> Switch Odoo{next_version} - nothing")
lst_state_4_module_migrate_code = self.dct_progression.get(
"config_state_4_module_to_migrate_code",
[[]] * len(lst_next_version),
)
if (
"config_state_4_module_to_migrate_code"
not in self.dct_progression.keys()
):
self.dct_progression[
"config_state_4_module_to_migrate_code"
] = lst_state_4_module_migrate_code
lst_module_to_migrate = lst_state_4_module_migrate_code[index]
option_comment += 1
msg = (
f"4.{index}.{chr(option_comment + 65)} - Search missing module"
)
self.add_comment_progression(msg)
if not lst_module_search_missing_module[index]:
lst_module_to_analyse_updated = []
for bd_module in lst_module_to_analyse:
if (
lst_module_to_uninstall
and bd_module in lst_module_to_uninstall
):
# Ignore check if uninstall before
continue
lst_module_to_analyse_updated.append(bd_module)
# TODO remove from list past module deleted
lst_module_to_check = [
a
for a in lst_module_to_analyse_updated
if a not in lst_module_to_delete_last_version
]
(
lst_module_missing_next_version,
lst_module_duplicate_next_version,
) = self.check_addons_exist(lst_module_to_check)
lst_module_missing_next_version = sorted(
list(set(lst_module_missing_next_version))
)
self.dct_progression["state_4_len_lst_module_missing"] = len(
lst_module_missing_next_version
)
self.dct_progression["state_4_lst_module_missing"] = (
lst_module_missing_next_version
)
lst_module_duplicate = sorted(
list(set(lst_module_duplicate_next_version))
)
self.dct_progression["state_4_len_lst_module_duplicate"] = len(
lst_module_duplicate
)
self.dct_progression["state_4_lst_module_duplicate"] = (
lst_module_duplicate
)
if lst_module_duplicate:
print(f"Duplicate module into odoo{next_version} : ")
print(lst_module_duplicate)
input(
f"💬 Detect error duplicate module, manage this problem manually and press to continue."
)
# if lst_module_missing_next_version and not lst_module_to_migrate:
if lst_module_missing_next_version:
# TODO support when lst_module_to_migrate is fill
lst_module_to_migrate = []
print(
f"👹 Detect error missing module, missing module into odoo{next_version} :"
)
for index_missing_module, module_missing in enumerate(
lst_module_missing_next_version
):
old_path = self.dct_progression.get(
"dct_module_exist", {}
).get(module_missing)
print(
f"[{index_missing_module}] {module_missing} - {old_path}"
)
print("[a] All list above")
print("[e] Add extra custom")
want_continue = (
input(
f"💬 Enumerate missing module separate by coma to delete it"
f". The others will be migrate : "
)
.strip()
.lower()
)
is_delete_all = False
if want_continue:
lst_want_continue = [
a.strip() for a in want_continue.split(",")
]
if "a" in lst_want_continue:
is_delete_all = True
lst_module_to_delete = [
lst_module_missing_next_version[a]
for a in range(
len(lst_module_missing_next_version)
)
]
else:
# TODO show error if the index is wrong
lst_want_continue_number = [
int(a)
for a in lst_want_continue
if a.isdigit()
]
lst_module_to_delete = [
lst_module_missing_next_version[a]
for a in lst_want_continue_number
if 0
<= a
< len(lst_module_missing_next_version)
]
if len(lst_module_to_delete) == len(
lst_module_missing_next_version
):
is_delete_all = True
if next_version == 15:
lst_module_to_delete.append("users_default_groups")
lst_module_to_delete.append(
"web_editor_backend_context"
)
lst_module_to_delete.append(
"website_google_analytics_fixed"
)
elif next_version == 17:
lst_module_to_delete.append(
"export_delete_login_log"
)
elif next_version == 18:
lst_module_to_delete.append(
"base_attachment_object_storage"
)
lst_module_to_delete.append(
"user_password_strength"
)
if "e" in lst_want_continue:
want_continue = (
input(
f"💬 Enumerate module name to delete, separate by coma : "
)
.strip()
.lower()
)
lst_to_extend = [
a.strip() for a in want_continue.split(",")
]
lst_module_to_delete.extend(lst_to_extend)
if lst_module_to_delete:
msg = f"4.{index}.{chr(option_comment + 65)}.option - Choose delete missing module"
self.add_comment_progression(msg)
self.switch_odoo(next_version - 1)
if lst_module_to_delete:
# Delete if exist database
self.todo_upgrade_execute(
f"./script/database/db_restore.py -d {database_name_upgrade} --only_drop",
)
# Duplicate database
cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}"
self.todo_upgrade_execute(cmd_clone_database)
self.uninstall_from_database(
lst_module_to_delete,
database_name_upgrade,
next_version,
)
self.install_from_database(
lst_module_to_install,
database_name_upgrade,
next_version - 1,
)
if not is_delete_all:
msg = f"4.{index}.{chr(option_comment + 65)}.option - Choose auto-fix (not implemented yet)"
self.add_comment_progression(msg)
lst_module_to_migrate_code = set(
lst_module_missing_next_version
) - set(lst_module_to_delete)
(
lst_module_missing_last,
lst_module_duplicate_last,
lst_module_exist_last,
lst_module_error_last,
) = self.check_addons_exist(
lst_module_to_migrate_code, get_all_info=True
)
if lst_module_missing_last:
print(
f"Error missing module : {lst_module_missing_last}"
)
if lst_module_duplicate_last:
print(
f"Error duplicate module : {lst_module_duplicate_last}"
)
if lst_module_error_last:
print(
f"Error error module : {lst_module_error_last}"
)
if lst_module_exist_last:
odoo_name_last_version = (
f"odoo{next_version - 1}.0"
)
odoo_name_actual_version = f"odoo{next_version}.0"
for (
module_name,
module_path,
) in lst_module_exist_last:
module_dir_path = os.path.dirname(module_path)
module_dir_path_new_version = (
module_dir_path.replace(
odoo_name_last_version,
odoo_name_actual_version,
)
)
module_dir_path_manifest = os.path.join(
module_path, "__manifest__.py"
)
module_dir_new_version = os.path.join(
module_dir_path_new_version, module_name
)
module_dir_new_version_manifest = os.path.join(
module_dir_new_version, "__manifest__.py"
)
dct_module_to_migrate_module = {
"source_module_path": module_path,
"source_manifest_path": module_dir_path_manifest,
"source_addons_path": module_dir_path,
"target_module_path": module_dir_new_version,
"target_manifest_path": module_dir_new_version_manifest,
"target_addons_path": module_dir_path_new_version,
"module_name": module_name,
"source_version_odoo": next_version - 1,
"target_version_odoo": next_version,
}
# TODO move this into config
lst_module_to_migrate.append(
dct_module_to_migrate_module
)
self.dct_progression[
"config_state_4_module_to_migrate_code"
][index] = lst_module_to_migrate
self.write_config()
self.switch_odoo(next_version)
# TODO auto-fix
# TODO try to migrate module, find in previous version, application la migration vers une nouvelle version
# TODO ajouté menu todo qui permet de faire une migration d'un module et migrer le générateur de code.
# TODO when check module, reminder provenance
# TODO implement asyncio instead of parallel
# TODO detect when duplicate path module ou module manquant, prendre décision qui ont efface si dupliqué
# TODO pourquoi web_ir_actions_act_multi est doublé dans odoo 13
lst_module_search_missing_module[index] = True
self.dct_progression["state_4_search_missing_module"] = (
lst_module_search_missing_module
)
self.write_config()
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Migrate module"
self.add_comment_progression(msg)
lst_path_git_clone_migrate = []
if not lst_module_migrate_odoo[index]:
# TODO Searching module
# search addons/addons
# search read manifest and detect branch difference, manifest into private?
# Extract module name and run migration to another list
# Maybe check if already exist and show list or continue with overwrite
# Expliquer pourquoi on ne fait pas le oca-port, c'est
config_migrate_repo = self.dct_progression.get(
"config_migrate_repo", False
)
self.dct_progression["config_migrate_repo"] = (
config_migrate_repo
)
if config_migrate_repo:
dct_module_result = self.search_module_to_move(
next_version - 1, next_version
)
# TODO code migration
# git stash
# call odoo-module-migrate, without commit
source_module_path = dct_module_result.get(
"source_module_path"
)
if not source_module_path:
_logger.error(
f"Missing source module path '{source_module_path}'"
)
else:
if os.path.exists(
os.path.join(source_module_path, ".git")
):
self.todo_upgrade_execute(
f"cd '{source_module_path}' && git stash && cd ~-"
)
target_module_path = dct_module_result.get(
"target_module_path"
)
if not target_module_path:
_logger.error(
f"Missing target module path '{target_module_path}'"
)
else:
if os.path.exists(
os.path.join(target_module_path, ".git")
):
self.todo_upgrade_execute(
f"cd '{target_module_path}' && git stash && cd ~-"
)
lst_module_to_migrate_all = dct_module_result.get(
"lst_module", []
)
else:
lst_module_to_migrate_all = []
# TODO remove duplicate au lieu d'extend
lst_module_to_migrate_all.extend(lst_module_to_migrate)
self.internal_module_upgrade(
next_version,
lst_module_to_migrate_all,
lst_path_git_clone_migrate,
)
lst_module_migrate_odoo[index] = True
self.dct_progression["state_4_module_migrate_odoo_lst"] = (
lst_module_migrate_odoo
)
self.write_config()
print(f"✅ -> Module upgrade Odoo{next_version} done")
else:
print(f"✅ -> Module upgrade Odoo{next_version} - nothing")
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Fix migrate code"
self.add_comment_progression(msg)
if not lst_fix_migration_odoo[index]:
print("")
stem = os.path.join(
PATH_MIGRATION_GLOBAL,
f"fix_migration_odoo{(next_version - 1) * 10}"
f"_to_odoo{next_version * 10}",
)
# Two flavours. « .sql » runs through psql: no Odoo registry,
# so it works on a database not yet migrated -- exactly when
# loading it with the TARGET version's code would fail. « .py »
# is piped into the Odoo shell when the ORM is really needed.
file_path_fix_migration = ""
cmd_fix_migration = ""
if os.path.exists(f"{stem}.sql"):
file_path_fix_migration = f"{stem}.sql"
cmd_fix_migration = (
f"psql -v ON_ERROR_STOP=1 -d {database_name_upgrade}"
f" -f ./{file_path_fix_migration}"
)
elif os.path.exists(f"{stem}.py"):
file_path_fix_migration = f"{stem}.py"
cmd_fix_migration = (
f"cat ./{file_path_fix_migration} |"
f" ./odoo{next_version}.0/odoo/odoo-bin shell"
f" -d {database_name_upgrade}"
)
if file_path_fix_migration:
status, cmd_executed = self.todo_upgrade_execute(
cmd_fix_migration,
single_source_odoo=True,
)
# A fix that did not run must not be recorded as applied,
# otherwise the rerun skips it and OpenUpgrade hits the very
# problem the fix exists to prevent.
if status:
print(
f"❌ -> Fix migration Odoo{next_version} FAILED"
f" (status {status}):"
f" {file_path_fix_migration}"
)
return
lst_fix_migration_odoo[index] = file_path_fix_migration
self.dct_progression["state_4_fix_migration_odoo_lst"] = (
lst_fix_migration_odoo
)
self.write_config()
print(f"✅ -> Fix migration Odoo{next_version} done")
else:
print(
f"✅ -> Fix migration Odoo{next_version} - no fix to execute"
)
else:
print(f"✅ -> Fix migration Odoo{next_version} - nothing")
for path_git_clone_migrate in lst_path_git_clone_migrate:
cmd = f"./script/code/git_commit_migration_addons_path.py --path {path_git_clone_migrate} --odoo_version {next_version}.0"
self.todo_upgrade_execute(cmd)
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Migrate database"
self.add_comment_progression(msg)
if not lst_upgrade_odoo[index]:
path_addons_openupgrade = os.path.join(
os.getcwd(), f"odoo{next_version}.0", "OCA_OpenUpgrade"
)
# Update config with OCA_OpenUpgrade
ignore_path = (
"--ignore-odoo-path " if next_version <= 13 else ""
)
extra_addons_path_extra = (
f",{path_addons_openupgrade}/addons,{path_addons_openupgrade}/odoo/addons"
if next_version <= 13
else ""
)
cmd_update_config = (
f"./script/git/git_repo_update_group.py {ignore_path}"
f"--extra-addons-path {path_addons_openupgrade}{extra_addons_path_extra} "
f"&& ./script/generate_config.sh"
)
self.todo_upgrade_execute(cmd_update_config)
print("🚸 Please, validate commits after code migration.")
print(" To show repo status :\nmake repo_show_status")
print(
f"🚸 Please, validate this path into config.conf : '{path_addons_openupgrade}'."
)
status = input(f"💬 Press to continue {msg} : ").strip()
# The technique change at version 14
if next_version <= 13:
erplibre_version = self.install_OCA_openupgrade(
next_version
)
cmd_upgrade = f".venv.{erplibre_version}/bin/python ./odoo{next_version}.0/OCA_OpenUpgrade/odoo-bin -c ./config.conf --update all --no-http --stop-after-init -d {database_name_upgrade}"
else:
cmd_upgrade = f"./run.sh --upgrade-path=./odoo{next_version}.0/OCA_OpenUpgrade/openupgrade_scripts/scripts --update all -c config.conf --stop-after-init --no-http --load=base,web,openupgrade_framework -d {database_name_upgrade}"
lst_upgrade_odoo[index] = cmd_upgrade
# Record the website COW views before the data migration. The
# upgrade silently deletes and recreates copies (measured on
# 12->13: 16 copies dropped, 13 created, children re-parented),
# and rewrites the arch of many others. Without a before/after
# record, "the site looks wrong" cannot be investigated.
self.snapshot_cow_views(
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)
# wait_at_error=False on purpose: the generic « [1] to redo the
# command » would replay OpenUpgrade on a database it has just
# half migrated, which never recovers. The failure is handled
# below by dropping the clone flag so the replay REBUILDS the
# intermediate database from the previous version.
status, cmd_executed = self.todo_upgrade_execute(
cmd_upgrade,
new_env={
"OPENUPGRADE_TARGET_VERSION": f"{next_version}.0"
},
wait_at_error=False,
)
# This is THE data migration. Recording it as done when it
# failed used to send the loop to the next version on top of a
# half-migrated database. Stop here instead: the state stays
# unset, so a rerun replays this version.
if status:
# The intermediate database is now half migrated and must
# not be reused: drop the clone flag so the rerun rebuilds
# it from the pristine source. Without this the replay
# would restart OpenUpgrade on top of the broken clone.
lst_clone_odoo[index] = False
self.dct_progression["state_4_clone_odoo_lst"] = (
lst_clone_odoo
)
self.write_config()
print(
f"\n❌ -> Database migration to Odoo{next_version}"
f" FAILED (status {status}).\n"
f" '{database_name_upgrade}' is now half migrated:"
" replaying the command on it would never recover, so"
" it is NOT offered.\n"
" The clone step has been reset. Fix the cause, then"
" relaunch the migration and answer [c] (continue):"
f" '{database_name_upgrade}' will be dropped and"
" rebuilt from the previous version before retrying."
)
return
self.snapshot_cow_views(
database_name_upgrade, f"after_{next_version}"
)
self.diff_cow_views(
database_name_upgrade,
f"before_{next_version}",
f"after_{next_version}",
)
self.dct_progression["state_4_upgrade_odoo_lst"] = (
lst_upgrade_odoo
)
self.write_config()
str_wait_next_version = (
" (or wait next version 🤖)"
if next_version != lst_next_version[-1]
else ""
)
status = (
input(
f"💬 Do you want to upgrade all{str_wait_next_version}? Press y/Y to upgrade all addons database : "
)
.strip()
.lower()
)
if status == "y":
self.todo_upgrade_execute(
f"./script/addons/update_addons_all.sh {database_name_upgrade}",
)
print(f"✅ -> Database upgrade Odoo{next_version} done")
# Update config without OCA_OpenUpgrade
cmd_update_config = f"./script/git/git_repo_update_group.py && ./script/generate_config.sh"
self.todo_upgrade_execute(cmd_update_config)
print("[y] Open server with Selenium")
status = (
input(
"💬 Do you want to test this upgrade? Choose or press to ignore it : "
)
.strip()
.lower()
)
"make repo_show_status"
if status == "y":
self.todo.prompt_execute_selenium_and_run_db(
database_name_upgrade
)
status = input(
f"💬 Press to continue 4.{index} : "
).strip()
else:
print(f"✅ -> Database upgrade Odoo{next_version} - nothing")
#
# waiting_input = input("💬 Press any keyboard key to continue...")
print("")
msg = "5 - Cleaning up database after upgrade"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
print(
"✨ Re-update i18n, purger data, tables (except mail_test and mail_test_full)"
)
# waiting_input = input("💬print Press any keyboard key to continue...")
msg = "6 - Migration finished"
print(f"🔷 {msg}")
self.add_comment_progression(msg)
cmd_backup_template = f"./odoo_bin.sh db --backup --database {database_name_upgrade} --restore_image"
cmd_backup = f"{cmd_backup_template} {database_name_upgrade}_finish_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}"
print(f"✨ Can execute backup creation :\n{cmd_backup}")
status = input(
"💬 Press y/Y or write filename.zip to export or enter to continue : "
).strip()
if status.lower():
if status.lower() != "y":
cmd_backup = f"{cmd_backup_template} {status}"
self.todo_upgrade_execute(cmd_backup)
status = input("💬 Test the migration, press y/Y : ")
if status.lower().strip() == "y":
self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade)
def get_rename_module(self, lst_module, next_version):
path_search = f"odoo{next_version}.0/OCA_OpenUpgrade/"
status, cmd_executed, lst_output = self.todo_upgrade_execute(
f"find {path_search} -name apriori.py",
get_output=True,
)
if not lst_output:
_logger.error(
f"Cannot find renamed module script apriori.py into path '{path_search}'"
)
return lst_module
apriory_py = lst_output[0].strip()
with open(apriory_py, "r") as f:
file_content = f.read()
data_vars = {}
exec(file_content, data_vars)
renamed_modules = data_vars.get("renamed_modules", {})
merged_modules = data_vars.get("merged_modules", {})
deleted_modules = data_vars.get("deleted_modules", [])
lst_index_to_delete = []
for index, module in enumerate(lst_module):
renamed_module = renamed_modules.get(module)
merged_module = merged_modules.get(module)
if renamed_module:
lst_module[index] = renamed_module
if merged_module:
lst_module[index] = merged_module
if module in deleted_modules:
lst_index_to_delete.append(index)
for index_to_delete in lst_index_to_delete[::-1]:
lst_module.pop(index_to_delete)
return list(set(lst_module))
def search_module_to_move(self, source_version_odoo, target_version_odoo):
lst_module = []
# lst_path_to_check = [
# os.path.join(f"odoo{actual_version_odoo}", "addons"),
# os.path.join(f"odoo{target_version_odoo}", "addons"),
# os.path.join(f"private", "addons"),
# ]
source_path_to_check = os.path.join(
f"odoo{source_version_odoo}.0", "addons", "addons"
)
target_path_to_check = os.path.join(
f"odoo{target_version_odoo}.0", "addons", "addons"
)
# Search
is_moving_git = False
if os.path.exists(source_path_to_check):
if os.path.exists(os.path.join(source_path_to_check, ".git")):
is_moving_git = True
if not os.path.exists(target_path_to_check):
shutil.copytree(source_path_to_check, target_path_to_check)
# if not is_moving_git:
# os.mkdir(path_to_check_target)
# else:
# # TODO clone
# pass
if not is_moving_git:
# TODO do something
# Time to compare
os.listdir(source_path_to_check)
if os.path.exists(target_path_to_check):
for dir_name in os.listdir(target_path_to_check):
source_module_path = os.path.join(
source_path_to_check, dir_name
)
source_manifest_path = os.path.join(
source_module_path, "__manifest__.py"
)
target_module_path = os.path.join(
target_path_to_check, dir_name
)
target_manifest_path = os.path.join(
target_module_path, "__manifest__.py"
)
if os.path.exists(target_manifest_path):
# TODO remove from list when module already exist in version 15
dct_module = {
"source_module_path": source_module_path,
"source_manifest_path": source_manifest_path,
"source_addons_path": source_path_to_check,
"target_module_path": target_module_path,
"target_manifest_path": target_manifest_path,
"target_addons_path": target_path_to_check,
"module_name": dir_name,
"source_version_odoo": source_version_odoo,
"target_version_odoo": target_version_odoo,
}
lst_module.append(dct_module)
dct_module = {
"lst_module": lst_module,
"source_module_path": source_path_to_check,
"target_module_path": target_path_to_check,
}
return dct_module
def snapshot_cow_views(self, database_name, label):
"""Record the website COW views of a database under private/.
Never blocks the migration: a snapshot is forensic material, its
absence must not stop an upgrade.
"""
self.todo_upgrade_execute(
f"{PYTHON_BIN} ./script/odoo/migration/snapshot_cow_views.py"
f" -d {database_name} -l {label}",
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(
PATH_MIGRATION_PRIVATE, database_name, "cow_snapshots"
)
path_before = os.path.join(directory, f"{label_before}.json")
path_after = os.path.join(directory, f"{label_after}.json")
if not (os.path.exists(path_before) and os.path.exists(path_after)):
return
self.todo_upgrade_execute(
f"{PYTHON_BIN} ./script/odoo/migration/snapshot_cow_views.py"
f" --diff {path_before} {path_after}",
wait_at_error=False,
)
@staticmethod
def parse_module_list_file(file_path):
"""Read a module list file, return [(module, reason), ...].
Accepted syntax, one module per line with an optional justification:
queue_job # blocks 12->13, trigger queue_job_notify
mgmtsystem_hazard # not ported to 13.0
Commas and several names per line are also accepted, so a list copied
from a command line works as-is. Blank lines and full-line comments are
ignored.
The previous parser was « f.readline().split() »: it kept only the FIRST
line, so a multi-line list was silently truncated, and a comma-separated
list collapsed into one bogus module name.
"""
lst_module = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
content, _, reason = line.partition("#")
for module_name in content.replace(",", " ").split():
lst_module.append((module_name, reason.strip()))
return lst_module
@staticmethod
def print_uninstall_reason(lst_detail):
"""Show WHY each module goes away.
Which modules must be dropped depends on the database, and a removal
without a stated reason is a decision nobody can review later.
"""
for name, reason, origin in lst_detail:
print(
f" - {name}"
+ (f"{reason}" if reason else " — ⚠️ no reason given")
+ f" [{origin}]"
)
def read_uninstall_module_list(self, start_version, database_name):
"""Modules to uninstall before migrating start_version -> next.
Reads the per-database private list first, then the shared versioned
defaults; duplicates are dropped, keeping the first occurrence.
Returns (lst_module, lst_detail) where lst_detail carries
(module, reason, origin_file) so the caller can justify each removal.
"""
file_name = (
f"uninstall_module_list_odoo{start_version * 10}"
f"_to_odoo{(start_version + 1) * 10}.txt"
)
lst_path = [
os.path.join(PATH_MIGRATION_PRIVATE, database_name, file_name),
os.path.join(PATH_MIGRATION_GLOBAL, file_name),
]
lst_module = []
lst_detail = []
for file_path in lst_path:
if not os.path.exists(file_path):
continue
for module_name, reason in self.parse_module_list_file(file_path):
if module_name in lst_module:
continue
lst_module.append(module_name)
lst_detail.append((module_name, reason, file_path))
return lst_module, lst_detail
def split_present_missing(self, lst_module):
"""Split a module list into (present, missing) against the ACTIVE code.
« Missing » means the addons path no longer holds the module — not
that it is absent from the database. The distinction matters because
check_addons_exist.py refuses the WHOLE uninstall for a single missing
name, so the modules that ARE there never get uninstalled either.
"""
lst_missing, _lst_duplicate = self.check_addons_exist(lst_module)
set_missing = set(lst_missing or [])
return (
[name for name in lst_module if name not in set_missing],
[name for name in lst_module if name in set_missing],
)
def prompt_uninstall_missing(self, lst_present, lst_missing):
"""Ask what to do when part of the list has no code left.
Returns the list to actually uninstall (possibly empty)."""
print()
print(
f"⚠️ {len(lst_missing)} "
f"{t('modules of the list have no code in the active Odoo:')}"
)
for name in lst_missing:
print(f" {name}")
print(f" {t('Odoo cannot uninstall a module whose code is gone;')}")
print(f" {t('one of them fails the whole uninstall command.')}")
print()
if lst_present:
print(
f" {len(lst_present)} "
f"{t('are present and can be uninstalled:')}"
)
for name in lst_present:
print(f" {name}")
else:
print(f" {t('No module of the list is present.')}")
print()
if lst_present:
print(
f" [1] {t('Uninstall the present ones, skip the missing')}"
" *"
)
print(f" [2] {t('Try the whole list anyway (it will fail)')}")
print(f" [3] {t('Uninstall nothing, continue')}")
answer = input(f"💬 {t('Your choice')} : ").strip()
if answer == "2":
return lst_present + lst_missing
if answer == "3" or not lst_present:
return []
return lst_present
def uninstall_from_database(
self, lst_module_to_uninstall, database_name, actual_version
):
if not lst_module_to_uninstall:
return
# Sort out what the active code still holds BEFORE calling the script:
# it aborts on the first missing name and takes the rest down with it.
lst_present, lst_missing = self.split_present_missing(
lst_module_to_uninstall
)
if lst_missing:
self.add_comment_progression(
"uninstall - no code for: " + ", ".join(lst_missing)
)
lst_module_to_uninstall = self.prompt_uninstall_missing(
lst_present, lst_missing
)
if not lst_module_to_uninstall:
print(f"{t('Nothing uninstalled.')}")
return
uninstall_module = ",".join(lst_module_to_uninstall)
self.todo_upgrade_execute(
f"./script/addons/uninstall_addons.sh {database_name} {uninstall_module}",
single_source_odoo=True,
)
# Update list installed module — only what was REALLY uninstalled, so
# a module left in place stays counted as installed.
self.dct_module_per_version[actual_version] = sorted(
list(
set(self.dct_module_per_version[actual_version])
- set(lst_module_to_uninstall)
)
)
self.dct_progression["dct_module_per_version"] = (
self.dct_module_per_version
)
self.write_config()
def install_from_database(
self, lst_module_to_install, database_name, actual_version
):
if not lst_module_to_install:
return
install_module = ",".join(lst_module_to_install)
self.todo_upgrade_execute(
f"./script/addons/install_addons.sh {database_name} {install_module}",
single_source_odoo=True,
)
# Update list installed module
self.dct_module_per_version[actual_version] = sorted(
list(
set(
self.dct_module_per_version[actual_version]
+ lst_module_to_install
)
)
)
self.dct_progression["dct_module_per_version"] = (
self.dct_module_per_version
)
self.write_config()
def check_addons_exist(
self, lst_module_to_check, ignore_error=True, get_all_info=False
):
str_module_to_check = ",".join(sorted(lst_module_to_check))
status, cmd_executed, dct_output = self.todo_upgrade_execute(
f"{PYTHON_BIN} ./script/addons/check_addons_exist.py --format_json --output_json -m {str_module_to_check}",
get_output=True,
output_is_json=True,
wait_at_error=not ignore_error,
)
lst_module_missing = dct_output.get("missing")
lst_module_duplicate = dct_output.get("duplicate")
if get_all_info:
lst_module_error = dct_output.get("error")
lst_module_exist = dct_output.get("exist")
return (
lst_module_missing,
lst_module_duplicate,
lst_module_exist,
lst_module_error,
)
return lst_module_missing, lst_module_duplicate
def switch_odoo(self, odoo_version):
int_odoo_version = int(float(odoo_version))
# Expect odoo_version like 12.0
lst_version, lst_version_installed, odoo_installed_version = (
get_odoo_version()
)
if odoo_installed_version != f"odoo{int_odoo_version}.0":
print(
f"⧖ -> Was '{odoo_installed_version}', Switch to odoo{int_odoo_version}.0"
)
self.todo_upgrade_execute(f"make switch_odoo_{int_odoo_version}")
self.todo_upgrade_execute("make config_gen_all")
def install_OCA_odoo_module_migrator(self):
if not os.path.exists(PATH_VENV_MODULE_MIGRATOR):
self.todo_upgrade_execute(
f"cd {PATH_OCA_ODOO_MODULE_MIGRATOR} && python -m venv {VENV_NAME_MODULE_MIGRATOR} && source {VENV_NAME_MODULE_MIGRATOR}/bin/activate && pip3 install -r requirements.txt"
)
def install_OCA_openupgrade(self, next_version):
# TODO install odoorpc==0.7.0
# openupgradelib
# openupgrade_path = f"odoo{next_version}.0/OCA_OpenUpgrade"
# venv_oca_path = f"{openupgrade_path}/.venv"
# if os.path.exists(venv_oca_path):
# return
lst_version, lst_version_installed, odoo_installed_version = (
get_odoo_version()
)
extract_version = f"{next_version}.0"
dct_erplibre_info = [
a for a in lst_version if a.get("odoo_version") == extract_version
]
if not dct_erplibre_info:
raise Exception(f"Cannot extract {extract_version}")
dct_erplibre_info = dct_erplibre_info[0]
erplibre_version = dct_erplibre_info.get("erplibre_version")
# self.todo_upgrade_execute(
# f".venv.{erplibre_version}/bin/python -m venv {venv_oca_path} && {venv_oca_path}/bin/pip3 install -r {openupgrade_path}/requirements.txt"
# )
self.todo_upgrade_execute(
f".venv.{erplibre_version}/bin/pip install odoorpc==0.7.0"
)
self.todo_upgrade_execute(
f".venv.{erplibre_version}/bin/pip install openupgradelib"
)
return erplibre_version
def todo_upgrade_execute(
self,
cmd,
single_source_odoo=False,
new_env=None,
quiet=False,
get_output=False,
output_is_json=False,
wait_at_error=True,
):
if output_is_json and not get_output:
get_output = True
output = None
if get_output:
status, cmd_executed, output = self.execute.exec_command_live(
cmd,
source_erplibre=False,
single_source_odoo=single_source_odoo,
new_env=new_env,
return_status_and_output_and_command=True,
quiet=quiet,
)
else:
status, cmd_executed = self.execute.exec_command_live(
cmd,
source_erplibre=False,
single_source_odoo=single_source_odoo,
new_env=new_env,
return_status_and_command=True,
quiet=quiet,
)
self.lst_command_executed.append(cmd_executed)
self.dct_progression["command_executed"] = self.lst_command_executed
self.write_config()
# None means « the command never reported a status » -> treat it as a
# failure, never as a success (defence in depth: exec_command_live now
# always sets one, but a silent None must not skip this prompt).
if (status is None or status) and wait_at_error:
print("[1] to redo the command")
wait_status = (
input(
"💬 Error detected, press to continue or ctrl+c to stop : "
)
.strip()
.lower()
)
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
if wait_status == "1":
return self.todo_upgrade_execute(
cmd,
single_source_odoo=single_source_odoo,
new_env=new_env,
quiet=quiet,
get_output=get_output,
output_is_json=output_is_json,
wait_at_error=wait_at_error,
)
if get_output:
if output_is_json:
str_output = json.loads("".join(output))
return status, cmd_executed, str_output
return status, cmd_executed, output
return status, cmd_executed
def check_and_clone_source_to_target_migration_code(
self, next_version, source_addons_path, target_addons_path
):
if not os.path.exists(os.path.join(source_addons_path, ".git")):
return
if os.path.exists(os.path.join(target_addons_path, ".git")):
return
# if not os.path.exists(target_addons_path):
# cmd_mkdir = f"mkdir -p {target_addons_path}"
# status, cmd_executed, lst_output = self.todo_upgrade_execute(
# cmd_mkdir,
# get_output=True,
# )
# return
source_dir_name = os.path.basename(source_addons_path)
# Clone a project for next version
# Get actual branch
cmd_git_clone_migrate_source = (
f"cd {source_addons_path} && "
f"git branch --show-current && "
f"cd ~-"
)
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone_migrate_source,
get_output=True,
)
branch_source = lst_output[0].strip() if len(lst_output) else ""
if not branch_source:
# Get branch from repo
branch_source = self.get_branch_name_from_local_manifest(
source_addons_path, f"{next_version}.0"
)
branch_target = branch_source.replace(
str(next_version - 1), str(next_version)
)
# Get remote branch for actual version
cmd_git_clone_migrate_source_same_target = (
f"cd {source_addons_path} "
f"&& git fetch --all "
f'&& git branch -vv | grep "{branch_source}" '
f"&& cd ~-"
)
cmd_git_clone_migrate_source_same_target_remote_only = (
f"cd {source_addons_path} && git remote && cd ~-"
)
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone_migrate_source_same_target,
get_output=True,
wait_at_error=False,
)
if status == 1:
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone_migrate_source_same_target_remote_only,
get_output=True,
)
if lst_output:
remote = lst_output[0].strip()
# TODO write this modification into repo manifest
else:
remote = input(
"👹 BUG, you need to push last branch. Please write the remote/ :"
)
else:
local_branch, remote, remote_branch = (
self.get_local_branch_remote_actual_branch_git(
lst_output,
)
)
# Get remote branch for next version
remote_branch_target = f"{remote}/{branch_target}"
cmd_git_clone_migrate_source_same_target = (
f"cd {source_addons_path} "
f"&& git fetch --all "
f'&& git branch --remotes -vv | grep "{remote_branch_target} " '
f"&& cd ~-"
)
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone_migrate_source_same_target,
get_output=True,
wait_at_error=False,
)
has_existing_target_branch = any([a.strip() for a in lst_output])
# TODO check config if path is added
# Get remote branch address
cmd_remote_address = (
f"cd {source_addons_path} "
f"&& git remote get-url {remote} "
f"&& cd ~-"
)
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_remote_address,
get_output=True,
)
remote_address = lst_output[0].strip()
# TODO some time, the clone has error, need to repeat
branch_to_clone = (
branch_target if has_existing_target_branch else branch_source
)
cmd_git_clone = (
f"cd {os.path.dirname(target_addons_path)} "
f"&& git clone {remote_address} {source_dir_name} -b {branch_to_clone} && cd ~-"
)
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone,
get_output=True,
)
if not has_existing_target_branch:
cmd_git_clone = (
f"cd {target_addons_path} "
f"&& if git rev-parse --verify --quiet refs/heads/{branch_target}; then "
f"git checkout {branch_target}; else git checkout -b {branch_target}; fi "
f"&& cd ~-"
)
status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone,
get_output=True,
)
def get_branch_name_from_local_manifest(self, addons_path, default_branch):
relative_addons_path = addons_path.replace(os.getcwd() + "/", "")
git_tool = GitTool()
dct_remote, dct_project, default_remote = (
git_tool.get_manifest_xml_info(filename=LOCAL_MANIFEST)
)
for dct_repo in dct_project.values():
path = dct_repo.get("@path")
if path == relative_addons_path:
revision = dct_repo.get("@revision")
return revision
return default_branch
def get_local_branch_remote_actual_branch_git(self, lst_output):
for line in lst_output:
# The current branch is marked with an asterisk (*) at the start of the line
if not line.strip().startswith("*"):
continue
# Split the line to isolate the remote and remote branch name
parts = line.split()
if len(parts) >= 4:
# The remote and remote branch are in the 4th part, e.g., '[origin/main]'
remote_info = parts[3].strip("[]")
# Split 'origin/main' into 'origin' and 'main'
# remote, remote_branch = remote_info.split("/", 1)
try:
remote, remote_branch = remote_info.split("/", 1)
except ValueError as e:
# # TODO this means no remote, take default one? or last one?
# # TODO supporter la migration 17 vers 18
# print("👹 BUG, you need to push ")
# TODO search remote and associate branch
value = input(
"👹 BUG, you need to push last branch. Please write the remote/branch_name :"
)
remote, remote_branch = value.split("/", 1)
return parts[1], remote, remote_branch
return None, None, None
def add_comment_progression(self, comment):
comment_to_add = f"# {comment}"
self.lst_command_executed.append(comment_to_add)
self.dct_progression["command_executed"] = self.lst_command_executed
self.write_config()