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>
2848 lines
119 KiB
Python
Executable file
2848 lines
119 KiB
Python
Executable file
#!/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()
|