diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md
index 963c3ed..199bf0d 100644
--- a/CHANGELOG.base.md
+++ b/CHANGELOG.base.md
@@ -95,6 +95,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Application store of a graphical VM: .deb only, Flatpak tooling, or snap
- Remote desktop tunnel: the SSH command is composed from ~/.ssh/config, with the hypervisor console as a third route
- The install dashboard acts on a VM without leaving it: update by parts, restart Odoo, delete; architecture column and resizable columns
+- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced
+- The website copy analysis diffs each copy against the view it shadows
- A test checks that what the tooling imports is declared
@@ -155,6 +157,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Magasin d'applications d'une VM graphique : .deb seul, outillage Flatpak, ou snap
- Tunnel vers le bureau distant : la commande SSH est composée depuis ~/.ssh/config, avec la console de l'hyperviseur comme troisième voie
- Le tableau de bord d'installation agit sur une VM sans la quitter : mise à jour par parties, redémarrage d'Odoo, suppression ; colonne d'architecture et colonnes ajustables
+- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce
+- L'analyse des copies de site compare chaque copie à la vue qu'elle masque
- Un test vérifie que ce qu'importe l'outillage est bien déclaré
@@ -249,6 +253,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- A graphical desktop install froze for thirty minutes on a snap package that could not reach the store
- The compiler was killed for lack of memory while building CPython on a small s390x guest
- The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported
+- The COW migration tools and the database upgrade speak the system language
+- The analysis and migration tools are executable
@@ -266,6 +272,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- L'installation d'un bureau graphique figeait trente minutes sur un paquet snap qui ne joignait pas le magasin
- Le compilateur était tué faute de mémoire en bâtissant CPython sur une petite VM s390x
- Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées
+- Les outils de migration COW et la mise à niveau de la base parlent la langue du système
+- Les outils d'analyse et de migration sont exécutables
## Security
diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index d04e4ed..0a4386c 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -71,6 +71,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Magasin d'applications d'une VM graphique : .deb seul, outillage Flatpak, ou snap
- Tunnel vers le bureau distant : la commande SSH est composée depuis ~/.ssh/config, avec la console de l'hyperviseur comme troisième voie
- Le tableau de bord d'installation agit sur une VM sans la quitter : mise à jour par parties, redémarrage d'Odoo, suppression ; colonne d'architecture et colonnes ajustables
+- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce
+- L'analyse des copies de site compare chaque copie à la vue qu'elle masque
- Un test vérifie que ce qu'importe l'outillage est bien déclaré
## Retiré
@@ -121,6 +123,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- L'installation d'un bureau graphique figeait trente minutes sur un paquet snap qui ne joignait pas le magasin
- Le compilateur était tué faute de mémoire en bâtissant CPython sur une petite VM s390x
- Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées
+- Les outils de migration COW et la mise à niveau de la base parlent la langue du système
+- Les outils d'analyse et de migration sont exécutables
## Sécurité
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 300e02f..2917223 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
- Application store of a graphical VM: .deb only, Flatpak tooling, or snap
- Remote desktop tunnel: the SSH command is composed from ~/.ssh/config, with the hypervisor console as a third route
- The install dashboard acts on a VM without leaving it: update by parts, restart Odoo, delete; architecture column and resizable columns
+- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced
+- The website copy analysis diffs each copy against the view it shadows
- A test checks that what the tooling imports is declared
## Removed
@@ -119,6 +121,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
- A graphical desktop install froze for thirty minutes on a snap package that could not reach the store
- The compiler was killed for lack of memory while building CPython on a small s390x guest
- The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported
+- The COW migration tools and the database upgrade speak the system language
+- The analysis and migration tools are executable
## Security
diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt
index b170dc0..81505cd 100644
--- a/requirement/erplibre_require-ments.txt
+++ b/requirement/erplibre_require-ments.txt
@@ -29,6 +29,10 @@ urwid
# Borne recopiée de script/todo/textual_setup.py (TEXTUAL_SPEC) : les écrans TUI
# sont écrits pour Textual 8, qui casse son API entre majeures. Modifier les deux.
textual>=8,<9
+# Dépendance DIRECTE, même si Textual l'installe : l'écran d'état décode
+# l'ANSI avec rich.text.Text.from_ansi. Compter sur une dépendance
+# transitive, c'est dépendre du jour où Textual changera la sienne.
+rich
# Dépendance directe : les outils d'analyse comparent des arbres XML avec lxml.
# Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc
# un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame.
diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py
new file mode 100755
index 0000000..f333d7d
--- /dev/null
+++ b/script/addons/theme_leftover.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qu'un thème laisse derrière lui une fois désinstallé. Lecture seule.
+
+Décharger un thème retire ses copies de vues et rend au site sa configuration
+par défaut. Restent des pièces jointes portant son chemin — SCSS compilé,
+images téléversées dans ses dossiers — et parfois des vues dont la clé le
+nomme encore.
+
+Elles ne cassent rien tant que le module est parti : plus personne ne les
+inclut dans un bundle. Mais elles traversent toutes les migrations suivantes,
+et l'on finit par tomber sur un `/theme_x/static/...` dont le module n'existe
+plus nulle part, sans savoir si c'est grave.
+
+Rien n'est supprimé ici. Le contenu d'une pièce jointe peut être la seule
+trace d'une personnalisation, et c'est une décision, pas un ménage.
+
+Codes de sortie : 0 rien à signaler, 1 des restes, 2 l'outil a échoué.
+"""
+
+import argparse
+import os
+import subprocess
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+try:
+ from script.todo import auto_ask
+except Exception: # pragma: no cover - repli si le pilote est absent
+ auto_ask = None
+
+
+def can_ask():
+ """Peut-on poser une question ICI ?
+
+ Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
+ terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
+ stdin laisse poser une invite qui part dans un tube : elle reste en
+ tampon, invisible, pendant que le processus attend — on croit à un
+ blocage et l'on tape Entrée à l'aveugle.
+ """
+ return sys.stdin.isatty() and sys.stdout.isatty()
+
+
+def run_psql(database, sql):
+ """Interroger la base en lecture seule, garantie côté serveur.
+
+ `default_transaction_read_only` est posé par le serveur pour toute la
+ session : ce n'est pas une promesse de l'outil, c'est PostgreSQL qui
+ refusera l'écriture même si le SQL en contenait une.
+ """
+ env = os.environ.copy()
+ env["PGOPTIONS"] = (
+ "-c default_transaction_read_only=on -c statement_timeout=30000"
+ )
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ [
+ "psql",
+ "-X",
+ "-w",
+ "-v",
+ "ON_ERROR_STOP=1",
+ "-d",
+ database,
+ "-tAF",
+ "|",
+ "-c",
+ sql,
+ ],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ raise RuntimeError(done.stderr.strip() or "psql failed")
+ return [line for line in done.stdout.splitlines() if line]
+
+
+def quote_literal(value):
+ return "'" + value.replace("'", "''") + "'"
+
+
+def collect(database, theme):
+ """Pièces jointes et vues qui nomment encore ce thème."""
+ like = quote_literal(f"%/{theme}/%")
+ key_like = quote_literal(f"{theme}.%")
+ attachments = run_psql(
+ database,
+ "SELECT id, COALESCE(url, name), create_date::date FROM ir_attachment"
+ f" WHERE url LIKE {like} ORDER BY id;",
+ )
+ views = run_psql(
+ database,
+ "SELECT id, key, website_id FROM ir_ui_view"
+ f" WHERE key LIKE {key_like} ORDER BY id;",
+ )
+ return attachments, views
+
+
+def render(theme, attachments, views):
+ if not attachments and not views:
+ return f"✅ -> {t('No leftover for theme')} '{theme}'.\n"
+ lines = []
+ if attachments:
+ lines.append(
+ f"ℹ {len(attachments)} {t('attachment(s) still under')}"
+ f" /{theme}/ :"
+ )
+ for row in attachments[:20]:
+ lines.append(f" - {row}")
+ if len(attachments) > 20:
+ lines.append(f" … {len(attachments) - 20} {t('more')}")
+ if views:
+ lines.append(
+ f"ℹ {len(views)} {t('view(s) whose key still names it')} :"
+ )
+ for row in views[:20]:
+ lines.append(f" - {row}")
+ if len(views) > 20:
+ lines.append(f" … {len(views) - 20} {t('more')}")
+ lines.append(
+ f" {t('Nothing was deleted: their content may be the only trace')}"
+ f" {t('of a customization. Read before removing.')}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def backup_attachments(database, theme, lst_row, filestore=None):
+ """Écrire le contenu des pièces jointes AVANT de les supprimer.
+
+ C'est la condition pour pouvoir répondre « efface ». Sans elle, on
+ détruirait ce dont on vient d'écrire qu'il peut être la seule trace
+ d'une personnalisation.
+ """
+ base = filestore or os.path.join(
+ os.path.expanduser("~"),
+ ".local",
+ "share",
+ "Odoo",
+ "filestore",
+ database,
+ )
+ directory = os.path.join(
+ "private", "odoo", "migration", database, "theme_backup", theme
+ )
+ os.makedirs(directory, exist_ok=True)
+ lst_saved = []
+ for row in lst_row:
+ att_id = row.split("|")[0]
+ rows = run_psql(
+ database,
+ "SELECT COALESCE(store_fname, '') FROM ir_attachment"
+ f" WHERE id = {int(att_id)};",
+ )
+ store_fname = rows[0].strip() if rows else ""
+ if not store_fname:
+ continue
+ source = os.path.join(base, store_fname)
+ if not os.path.isfile(source):
+ continue
+ target = os.path.join(
+ directory, f"{att_id}_" + os.path.basename(row.split("|")[1])
+ )
+ with open(source, "rb") as src, open(target, "wb") as dst:
+ dst.write(src.read())
+ lst_saved.append(target)
+ return lst_saved
+
+
+def delete_attachments(database, lst_row, config_path="./config.conf"):
+ """Supprimer par le shell d'Odoo, pour qu'il gère aussi le filestore.
+
+ Un DELETE en SQL laisserait les fichiers orphelins et les caches
+ incohérents ; unlink() fait le ménage complet, dans toutes les versions.
+ """
+ # int(), pas la chaîne du psql : browse(['4457']) fait échouer Odoo sur
+ # « n'a pas les identifiants (('4457',)) et a des identifiants
+ # supplémentaires ((4457,)) » — il compare des chaînes à des entiers.
+ # Mesuré sur une vraie base, l'effacement n'a rien retiré.
+ lst_id = [int(row.split("|")[0]) for row in lst_row]
+ script = (
+ f"env['ir.attachment'].browse({lst_id!r}).unlink()\n"
+ "env.cr.commit()\n"
+ )
+ done = subprocess.run(
+ ["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
+ input=script,
+ capture_output=True,
+ text=True,
+ )
+ return done.returncode, done.stdout + done.stderr
+
+
+DEFAULT_ANSWER = "d"
+
+
+def prompt(database, theme, attachments, views, config_path, ask=None):
+ """Effacer ou garder. Effacer par défaut, et la sauvegarde D'ABORD.
+
+ Ce qui rend ce défaut tenable, c'est l'ordre : le contenu part dans un
+ fichier avant que la base ne soit touchée. Sans cette sauvegarde, le
+ défaut aurait dû rester « garder » — on ne fait pas d'une décision
+ irréversible la réponse que l'on obtient en ne répondant pas.
+ """
+ if not attachments:
+ return False
+ if ask is None:
+ # Lancé à part par la migration : sans lecteur temporisé, cette
+ # question arrêtait net une exécution automatique.
+ ask = (
+ auto_ask.make_ask(DEFAULT_ANSWER)
+ if auto_ask
+ else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
+ )
+ answer = (
+ ask(
+ f"💬 {t('Delete these leftovers, or keep them?')}"
+ f" ({t('Enter = delete, after saving them')},"
+ f" k = {t('keep')}) : "
+ )
+ .strip()
+ .lower()
+ )
+ if answer != "d":
+ print(f"ℹ -> {t('Kept. Nothing was deleted.')}")
+ return False
+ lst_saved = backup_attachments(database, theme, attachments)
+ print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
+ if lst_saved:
+ print(f" {os.path.dirname(lst_saved[0])}")
+ status, output = delete_attachments(database, attachments, config_path)
+ print(output.strip()[-1500:])
+ if status:
+ print(f"❌ {t('Deletion failed, nothing was removed.')}")
+ return False
+ print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
+ return True
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=("List what an uninstalled theme left behind (read-only).")
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-t", "--theme", required=True)
+ parser.add_argument(
+ "-c",
+ "--config",
+ default="./config.conf",
+ help="Odoo config used by the shell for --delete",
+ )
+ parser.add_argument(
+ "--delete",
+ action="store_true",
+ help="delete them (WRITES; saves their content first)",
+ )
+ parser.add_argument(
+ "--report-only",
+ action="store_true",
+ help="never ask anything, even in front of a terminal",
+ )
+ config = parser.parse_args(argv)
+ try:
+ attachments, views = collect(config.database, config.theme)
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ print(render(config.theme, attachments, views))
+ if not attachments and not views:
+ return 0
+ if config.delete:
+ lst_saved = backup_attachments(
+ config.database, config.theme, attachments
+ )
+ print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
+ status, output = delete_attachments(
+ config.database, attachments, config.config
+ )
+ print(output.strip()[-1500:])
+ if status:
+ print(f"❌ {t('Deletion failed, nothing was removed.')}")
+ return 2
+ print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
+ return 0
+ # Voir ET pouvoir répondre : une invite dont la sortie part dans un
+ # tube reste invisible — Python bufferise par blocs — pendant que le
+ # processus attend. Mesuré : l'utilisateur tape Entrée à l'aveugle.
+ if not config.report_only and can_ask():
+ if prompt(
+ config.database,
+ config.theme,
+ attachments,
+ views,
+ config.config,
+ ):
+ return 0
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/addons/uninstall_addons_theme.sh b/script/addons/uninstall_addons_theme.sh
new file mode 100755
index 0000000..aa45da1
--- /dev/null
+++ b/script/addons/uninstall_addons_theme.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# Désinstaller un thème comme Odoo le fait lui-même.
+#
+# « ./run.sh --uninstall theme_x » retire le MODULE, pas le THÈME. Choisir un
+# thème (button_choose_theme, ce que pose --install-theme) fait DEUX choses :
+# copier ses vues et ressources dans chaque site, et écrire dans
+# user_values.scss une personnalisation qui DÉFINIT $o-theme-font-number et
+# ses trois voisines. Le chemin de retrait d'Odoo, _theme_remove(), défait les
+# deux.
+#
+# Le désinstaller sans lui laisse donc les copies, et surtout n'écrit jamais
+# ces définitions. Mesuré sur une migration réelle 12 -> 13 : le bundle
+# web.assets_frontend s'arrête sur « Undefined variable: $o-theme-font-number
+# ». La variable venait des fichiers option_font_body_* d'Odoo 12, supprimés
+# en 13.0 ; seul le thème la redéfinissait encore, et le retirer a mis à nu
+# une personnalisation figée depuis des années.
+#
+# Usage : ./script/addons/uninstall_addons_theme.sh [config]
+
+Red='\033[0;31m' # Red
+Color_Off='\033[0m' # Text Reset
+
+if [[ $# -lt 2 ]]; then
+ echo "Usage: $0 [config]"
+ exit 1
+fi
+
+DATABASE="$1"
+THEME="$2"
+CONFIG="${3:-./config.conf}"
+
+if [[ $# -eq 3 ]]; then
+ ./script/addons/check_addons_exist.py -m "$THEME" -c "$3"
+else
+ ./script/addons/check_addons_exist.py -m "$THEME"
+fi
+retVal=$?
+if [[ $retVal -ne 0 ]]; then
+ echo -e "${Red}Error${Color_Off} check_addons_exist.py into uninstall_addons_theme.sh"
+ exit 1
+fi
+
+echo "Unload theme '$THEME' from every website of BD '$DATABASE'"
+
+# Le déchargement passe par le shell : _theme_remove() n'a pas d'option de
+# ligne de commande, et l'écrire ici évite d'ajouter une option au fork pour
+# chaque version d'Odoo. Les journaux se mêlent à la sortie, d'où la
+# sentinelle : on ne conclut que sur ce qui la suit.
+./odoo_bin.sh shell -c "$CONFIG" -d "$DATABASE" --no-http --log-level=warn < str:
+ return key
+
+
+DEFAULT_PROGRESSION = ".venv.erplibre/odoo_database_migration_log.json"
+FILESTORE = os.path.join(
+ os.path.expanduser("~"), ".local", "share", "Odoo", "filestore"
+)
+SEP = "\x1f"
+
+
+def run_psql(database, sql):
+ """Interroger la base en lecture seule, garantie par le SERVEUR.
+
+ `default_transaction_read_only` n'est pas une promesse de l'outil :
+ PostgreSQL refusera l'écriture même si le SQL en contenait une. On
+ inspecte des bases de migration, parfois la seule copie qui reste.
+ """
+ env = os.environ.copy()
+ env["PGOPTIONS"] = "-c default_transaction_read_only=on"
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ return None
+ return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
+
+
+def read_progression(path=DEFAULT_PROGRESSION):
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ return json.load(handle)
+ except (OSError, ValueError):
+ return {}
+
+
+def chain(dct):
+ """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours.
+
+ Le nom des bases de palier suit la convention du pilote —
+ « _upgrade_ » — et la liste des paliers se déduit de la
+ cible et du nombre d'entrées `state_4_*_odoo_lst`. On ne devine donc
+ rien : on relit ce que la migration a écrit.
+ """
+ base = dct.get("config_database_name")
+ if not base:
+ return []
+ try:
+ cible = int(float(dct.get("target_odoo_version") or 0))
+ except (TypeError, ValueError):
+ return []
+ total = max(
+ [
+ len(valeur)
+ for cle, valeur in dct.items()
+ if cle.startswith("state_4_")
+ and cle.endswith("_odoo_lst")
+ and isinstance(valeur, list)
+ ]
+ or [0]
+ )
+ if not total or not cible:
+ return [(None, base)]
+ lst_version = list(range(cible - total + 1, cible + 1))
+ depart = lst_version[0] - 1
+ return [(depart, base)] + [
+ (version, f"{base}_upgrade_{version}") for version in lst_version
+ ]
+
+
+META_SQL = """
+SELECT 'odoo', latest_version FROM ir_module_module WHERE name = 'base'
+UNION ALL SELECT 'view', count(*)::text FROM ir_ui_view
+UNION ALL SELECT 'view_cow', count(*)::text FROM ir_ui_view
+ WHERE website_id IS NOT NULL
+UNION ALL SELECT 'menu', count(*)::text FROM ir_ui_menu
+UNION ALL SELECT 'action', count(*)::text FROM ir_act_window
+UNION ALL SELECT 'attachment', count(*)::text FROM ir_attachment
+UNION ALL SELECT 'attachment_stored', count(*)::text FROM ir_attachment
+ WHERE store_fname IS NOT NULL
+UNION ALL SELECT 'language', count(*)::text FROM res_lang WHERE active
+"""
+
+
+def table_counts(database):
+ """{table: lignes} pour toutes les tables. Une seule requête.
+
+ Construite côté serveur puis exécutée d'un bloc : huit cents requêtes
+ séparées coûteraient huit cents allers-retours, là où celle-ci prend
+ quatre dixièmes de seconde — mesuré sur une base de 890 tables.
+ """
+ fabrique = run_psql(
+ database,
+ "SELECT string_agg("
+ "format('SELECT %L t, count(*) n FROM %I', table_name, table_name),"
+ " ' UNION ALL ') FROM information_schema.tables"
+ " WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
+ )
+ if not fabrique or not fabrique[0][0]:
+ return {}
+ lignes = run_psql(database, fabrique[0][0])
+ if lignes is None:
+ return {}
+ return {
+ nom: int(nombre)
+ for nom, nombre in (ligne[:2] for ligne in lignes)
+ if nombre.isdigit()
+ }
+
+
+def missing_files(database, lst_store_fname):
+ """Les pièces jointes dont le FICHIER a disparu du filestore.
+
+ Une base peut référencer des milliers de pièces jointes dont le
+ contenu n'a pas suivi le clonage. Rien ne le signale : la page se
+ charge, l'image est vide.
+ """
+ racine = os.path.join(FILESTORE, database)
+ if not os.path.isdir(racine):
+ return None
+ return [
+ nom
+ for nom in lst_store_fname
+ if nom and not os.path.isfile(os.path.join(racine, nom))
+ ]
+
+
+def inspect(database):
+ """L'état d'une base, en lecture seule. `exists` False si absente."""
+ etat = {"database": database, "exists": False}
+ meta = run_psql(database, META_SQL)
+ if meta is None:
+ return etat
+ etat["exists"] = True
+ dct_meta = {ligne[0]: ligne[1] for ligne in meta if len(ligne) > 1}
+ etat["odoo"] = (dct_meta.get("odoo") or "?").rsplit(".", 2)[0]
+ for cle in (
+ "view",
+ "view_cow",
+ "menu",
+ "action",
+ "attachment",
+ "attachment_stored",
+ "language",
+ ):
+ etat[cle] = int(dct_meta.get(cle) or 0)
+
+ modules = run_psql(
+ database, "SELECT name, state FROM ir_module_module ORDER BY name"
+ )
+ etat["module"] = {
+ nom: statut for nom, statut in (m[:2] for m in modules or [])
+ }
+ etat["installed"] = sorted(
+ nom for nom, statut in etat["module"].items() if statut == "installed"
+ )
+ modeles = run_psql(database, "SELECT model FROM ir_model ORDER BY model")
+ etat["model"] = sorted(m[0] for m in modeles or [])
+ etat["table"] = table_counts(database)
+ # Les CHAMPS : un champ perdu est une colonne de données perdue, et
+ # c'est plus fin qu'un modèle — un modèle qui survit peut avoir été
+ # vidé de la moitié de ses champs sans que rien ne le dise.
+ champs = run_psql(
+ database,
+ "SELECT model || '.' || name FROM ir_model_fields ORDER BY 1",
+ )
+ etat["field"] = sorted(ligne[0] for ligne in champs or [])
+ # À quel module appartient chaque champ. Sans cela, un champ d'un
+ # module OCA compterait comme « non déclaré par OpenUpgrade » —
+ # littéralement vrai, et trompeur : OpenUpgrade n'analyse que le cœur
+ # d'Odoo, il ne pouvait rien en dire.
+ origines = run_psql(
+ database,
+ "SELECT f.model || '.' || f.name, d.module FROM ir_model_fields f"
+ " JOIN ir_model_data d ON d.model = 'ir.model.fields'"
+ " AND d.res_id = f.id",
+ )
+ dct_origine = {}
+ for ligne in origines or []:
+ if len(ligne) >= 2:
+ dct_origine.setdefault(ligne[0], set()).add(ligne[1])
+ etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
+ # Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
+ # c'est par elle qu'on les retrouve d'une version à l'autre.
+ copies = run_psql(
+ database,
+ "SELECT coalesce(key, 'id:' || id::text) FROM ir_ui_view"
+ " WHERE website_id IS NOT NULL ORDER BY 1",
+ )
+ etat["cow"] = sorted(ligne[0] for ligne in copies or [])
+
+ # Les modèles sans table : abstraits, mixins et vues SQL pour la
+ # plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit
+ # quelque chose, alors que la liste brute ne dit rien.
+ tables = set(etat["table"])
+ etat["model_without_table"] = [
+ m for m in etat["model"] if m.replace(".", "_") not in tables
+ ]
+
+ stockees = run_psql(
+ database,
+ "SELECT DISTINCT store_fname FROM ir_attachment"
+ " WHERE store_fname IS NOT NULL",
+ )
+ absents = missing_files(database, [ligne[0] for ligne in stockees or []])
+ etat["attachment_missing"] = None if absents is None else len(absents)
+ # Les NOMS, pas seulement le compte : sans eux, « 254 fichiers
+ # absents » ne dit pas lesquels, et l'on ne peut ni juger de la
+ # gravité ni retrouver ce qui a disparu. Bornés, car une base peut en
+ # aligner des dizaines de milliers et l'écran n'en montrera jamais tant.
+ etat["attachment_missing_list"] = (absents or [])[:MAX_MISSING]
+ return etat
+
+
+MAX_MISSING = 5000
+
+
+def missing_detail(database, lst_store_fname, limit=400):
+ """Les métadonnées des pièces jointes dont le fichier a disparu.
+
+ À la DEMANDE, jamais pendant l'inspection : une requête de plus par
+ base allongerait un parcours qui tient aujourd'hui en quatre secondes,
+ pour une information qu'on ne regarde qu'en la demandant.
+
+ `res_field` est le renseignement le plus utile du lot : il dit QUEL
+ champ a perdu son image — l'`image_1920` d'un pays n'a pas le même
+ poids qu'une pièce jointe de facture.
+ """
+ if not lst_store_fname:
+ return []
+ lst = list(lst_store_fname)[:limit]
+ valeurs = ", ".join("'" + nom.replace("'", "''") + "'" for nom in lst)
+ lignes = run_psql(
+ database,
+ "SELECT store_fname, coalesce(res_model, '-'),"
+ " coalesce(res_field, '-'), coalesce(res_id::text, '-'),"
+ " coalesce(mimetype, '-'), coalesce(file_size::text, '0'),"
+ " coalesce(name, '-')"
+ f" FROM ir_attachment WHERE store_fname IN ({valeurs})"
+ " ORDER BY res_model, res_field, name",
+ )
+ return [
+ {
+ "store_fname": ligne[0],
+ "model": ligne[1],
+ "field": ligne[2],
+ "res_id": ligne[3],
+ "mimetype": ligne[4],
+ "size": int(ligne[5]) if ligne[5].isdigit() else 0,
+ "name": ligne[6],
+ }
+ for ligne in lignes or []
+ if len(ligne) >= 7
+ ]
+
+
+DETAILS = ("modules", "models", "fields", "cow", "tables")
+
+
+def render_detail(diff, categorie, colour=False):
+ """La liste ENTIÈRE d'une catégorie, sans troncature.
+
+ Le résumé coupe à huit entrées, et il a raison : personne ne lit
+ trois cent soixante-quinze noms de modèles en passant. Mais quand on
+ cherche si UN module précis a survécu, la liste tronquée ne répond
+ pas — et c'est justement là qu'on a besoin d'elle.
+ """
+ from script.todo.migration_status import paint
+
+ if diff is None or diff.get("unavailable"):
+ return t("not comparable: a database is missing")
+ if categorie == "tables":
+ lignes = [f"── {t('table(s) lost rows')} ──"]
+ vers = {a: b for a, b, _n in diff["renamed"]}
+ for table, avant, apres, connu in diff["rows_lost"]:
+ note = ""
+ if connu and connu["into"]:
+ note = f" → {connu['into']}"
+ elif connu:
+ note = f" {t('retired from the database')}"
+ elif table in vers:
+ note = f" ↻ {vers[table]}"
+ teinte = "dim" if connu else "fail"
+ lignes.append(
+ f" {paint(f'{avant - apres:>8}', teinte, colour)}"
+ f" {table:<46} {avant} → {apres}{note}"
+ )
+ return "\n".join(lignes)
+
+ perdus = diff.get(f"{categorie}_lost") or []
+ gagnes = diff.get(f"{categorie}_gained") or []
+ lignes = []
+ # « − 28 copies COW » plutôt que « 28 copies COW perdus » : le signe
+ # porte déjà le sens, et l'accord d'un participe avec une catégorie
+ # dont le genre change d'une langue à l'autre ne se traduit pas.
+ for lst, symbole, teinte in (
+ (perdus, "−", "fail"),
+ (gagnes, "+", "ok"),
+ ):
+ lignes.append(
+ f"── {paint(symbole, teinte, colour)} {len(lst)}"
+ f" {t(categorie)} ──"
+ )
+ lignes.extend(f" {nom}" for nom in lst)
+ lignes.append("")
+ return "\n".join(lignes)
+
+
+def render_missing(etat, colour=False, limit=60):
+ """Ce qui manque, groupé d'abord, détaillé ensuite.
+
+ Le groupement d'abord parce qu'il tranche : deux cent trente-trois
+ drapeaux de pays et treize logos de fournisseurs de paiement sont des
+ images livrées par les modules, qu'une mise à jour restaure. Huit
+ pièces jointes éparses, non. La liste brute mettait les deux sur le
+ même plan.
+ """
+ from script.todo.migration_status import paint
+
+ absents = etat.get("attachment_missing") or 0
+ if not absents:
+ return f"✅ {t('every attachment file is present')}"
+ lignes = [
+ paint(
+ f"❌ {absents}"
+ f" {t('attachment files missing from the filestore')}",
+ "fail",
+ colour,
+ ),
+ f" {etat['database']}",
+ "",
+ ]
+ detail = missing_detail(
+ etat["database"], etat.get("attachment_missing_list") or []
+ )
+ if not detail:
+ lignes.append(t("Could not read their metadata."))
+ return "\n".join(lignes)
+
+ groupe = {}
+ for item in detail:
+ cle = (item["model"], item["field"], item["mimetype"])
+ groupe[cle] = groupe.get(cle, 0) + 1
+ lignes.append(f"── {t('by model and field')} ──")
+ for (modele, champ, mime), nombre in sorted(
+ groupe.items(), key=lambda x: -x[1]
+ ):
+ lignes.append(
+ f" {nombre:>5} {paint(modele, 'step', colour):<34}"
+ f" {champ:<22} {mime}"
+ )
+ lignes.append("")
+ lignes.append(f"── {t('one by one')} ──")
+ for item in detail[:limit]:
+ lignes.append(
+ f" {item['model']}#{item['res_id']}"
+ f" {paint(item['field'], 'dim', colour)}"
+ f" {item['size']:>8} o {item['name'][:44]}"
+ )
+ lignes.append(f" {paint(item['store_fname'], 'cmd', colour)}")
+ if len(detail) > limit:
+ lignes.append(f" … {len(detail) - limit} {t('more')}")
+ return "\n".join(lignes)
+
+
+# Ce qu'Odoo déplace ou retire de lui-même, d'une version à l'autre.
+#
+# Sans cette carte, l'outil annonçait « 81 tables ont perdu des lignes »
+# et la plus grosse d'entre elles — ir_translation, 32 984 lignes — était
+# une refonte voulue par l'éditeur. Les vraies questions se noyaient dans
+# les fausses, ce qui est la façon la plus sûre de ne pas les voir.
+#
+# Chaque entrée a été VÉRIFIÉE sur une migration réelle 12 → 18, en
+# comptant les deux côtés. Une carte écrite de mémoire vaudrait moins que
+# pas de carte : elle expliquerait des pertes qui n'en sont pas.
+SEMANTIC_MAP = (
+ # Fusions : les enregistrements continuent, ailleurs et autrement.
+ {
+ "since": 13,
+ "table": "account_invoice",
+ "into": "account_move",
+ "kind": "merged",
+ "why": "invoices became journal entries",
+ },
+ {
+ "since": 13,
+ "table": "account_invoice_line",
+ "into": "account_move_line",
+ "kind": "merged",
+ "why": "invoices became journal entries",
+ },
+ {
+ "since": 13,
+ "table": "account_invoice_tax",
+ "into": "account_move_line",
+ "kind": "merged",
+ "why": "invoices became journal entries",
+ },
+ # Renommages : les mêmes enregistrements, sous un autre nom.
+ {
+ "since": 17,
+ "table": "mail_channel",
+ "into": "discuss_channel",
+ "kind": "renamed",
+ "why": "Discuss was renamed",
+ },
+ {
+ "since": 17,
+ "table": "mail_channel_partner",
+ "into": "discuss_channel_member",
+ "kind": "renamed",
+ "why": "Discuss was renamed",
+ },
+ {
+ "since": 13,
+ "table": "website_redirect",
+ "into": "website_rewrite",
+ "kind": "renamed",
+ "why": "redirections were reworked",
+ },
+ {
+ "since": 13,
+ "table": "crm_lead_tag",
+ "into": "crm_tag",
+ "kind": "renamed",
+ "why": "tags became shared",
+ },
+ # Retraits : la fonction a quitté la base pour du code.
+ {
+ "since": 16,
+ "table": "ir_translation",
+ "into": None,
+ "kind": "retired",
+ "why": "translations moved into jsonb columns",
+ },
+ # Vérifiée palier par palier : la table ne se vide jamais, elle GROSSIT
+ # (211 en 12 → 457 en 17) puis disparaît d'un coup en 18. Les champs
+ # qu'elle portait sont des colonnes jsonb en 18 — vérifié sur
+ # res_partner.property_payment_term_id et
+ # product_template.property_account_income_id.
+ {
+ "since": 18,
+ "table": "ir_property",
+ "into": None,
+ "kind": "retired",
+ "why": "company-dependent values moved into jsonb columns",
+ },
+ {
+ "since": 17,
+ "table": "account_tax_template",
+ "into": None,
+ "kind": "retired",
+ "why": "chart templates left the database",
+ },
+ {
+ "since": 17,
+ "table": "account_account_template",
+ "into": None,
+ "kind": "retired",
+ "why": "chart templates left the database",
+ },
+ {
+ "since": 17,
+ "table": "account_chart_template",
+ "into": None,
+ "kind": "retired",
+ "why": "chart templates left the database",
+ },
+ {
+ "since": 17,
+ "table": "account_fiscal_position_template",
+ "into": None,
+ "kind": "retired",
+ "why": "chart templates left the database",
+ },
+ {
+ "since": 17,
+ "table": "account_fiscal_position_tax_template",
+ "into": None,
+ "kind": "retired",
+ "why": "chart templates left the database",
+ },
+ {
+ "since": 17,
+ "table": "account_fiscal_position_account_template",
+ "into": None,
+ "kind": "retired",
+ "why": "chart templates left the database",
+ },
+ # Vérifié palier par palier : 1269 lignes en 12, 13 et 14, la table
+ # disparaît en 15 et `mail_notification` en compte exactement 1269.
+ # Pas une perdue.
+ {
+ "since": 15,
+ "table": "mail_message_res_partner_needaction_rel",
+ "into": "mail_notification",
+ "kind": "merged",
+ "why": "needaction became notifications",
+ },
+ {
+ "since": 15,
+ "table": "stock_inventory",
+ "into": "stock_quant",
+ "kind": "merged",
+ "why": "inventory adjustments became quants",
+ },
+ {
+ "since": 15,
+ "table": "stock_inventory_line",
+ "into": "stock_quant",
+ "kind": "merged",
+ "why": "inventory adjustments became quants",
+ },
+ # Élagages : la table demeure, ces lignes non. À distinguer d'une
+ # fusion — ici les enregistrements ne continuent NULLE PART.
+ #
+ # Vérifié : en 13 `field` est un varchar (le nom du champ), en 14 c'est
+ # une clé étrangère vers ir_model_fields. En 14 aucune ligne n'a de
+ # champ nul ni de clé cassée : ce qui ne se résolvait pas a été
+ # supprimé. Sur les 2336 disparues, 1528 portaient des champs du module
+ # agile retiré (priority_id 761, agile_enabled 711, story_points 54,
+ # resolution_id 2) ; 706 autres ont SURVÉCU sous un nouveau nom
+ # (account.move.type → move_type, 651).
+ {
+ "since": 14,
+ "table": "mail_tracking_value",
+ "into": None,
+ "kind": "pruned",
+ "why": "tracking of fields that ceased to exist",
+ },
+)
+
+
+def as_version(etat):
+ """« 18.0 » -> 18. None si l'on ne sait pas."""
+ try:
+ return int(float((etat or {}).get("odoo") or 0)) or None
+ except (TypeError, ValueError):
+ return None
+
+
+_CACHE_DECLARE = {}
+
+
+def declared_index(version):
+ """L'index OpenUpgrade du palier, mis en cache par version.
+
+ Lire 402 fichiers coûte 0,04 s — négligeable une fois, pas six fois
+ par rapport quand on compare tous les paliers d'affilée.
+ """
+ if version not in _CACHE_DECLARE:
+ from script.analyse import openupgrade_analysis
+
+ _CACHE_DECLARE[version] = openupgrade_analysis.load(version)
+ return _CACHE_DECLARE[version]
+
+
+def overlay_declared(
+ version, modeles_perdus, champs_perdus, origine_champ=None
+):
+ """Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
+
+ Une perte déclarée par OpenUpgrade n'est pas un incident, c'est le
+ palier qui fait son travail. Les séparer transforme « 300 champs
+ disparus » — un chiffre devant lequel on ne peut rien faire — en une
+ poignée de champs que personne n'a annoncés, et qu'il faut regarder.
+
+ Le cas qui justifie tout : « devenu calculé ». Le champ EXISTE
+ toujours ; il n'a plus de colonne. Ni gain ni perte : transformation.
+ """
+ from script.analyse import openupgrade_analysis
+
+ if not version:
+ return {"available": False, "reason": "version"}
+ index = declared_index(version)
+ if not index["modules"]:
+ return {"available": False, "reason": "missing"}
+
+ modeles = {"obsolete": [], "renamed": [], "undeclared": []}
+ for nom in modeles_perdus:
+ change = openupgrade_analysis.model_change(nom, index)
+ if not change:
+ modeles["undeclared"].append(nom)
+ elif change[0] == "renamed":
+ modeles["renamed"].append((nom, change[1]))
+ else:
+ modeles["obsolete"].append(nom)
+
+ champs = {
+ "del": [],
+ "unstored": [],
+ "company_dependent": [],
+ "moved": [],
+ "model_gone": [],
+ "not_analysed": [],
+ "undeclared": [],
+ }
+ partis = tuple(f"{nom}." for nom in modeles_perdus)
+ analyses = openupgrade_analysis.analysed_modules(version)
+ for cle in champs_perdus:
+ change = openupgrade_analysis.field_change(cle, index)
+ if change:
+ if change[0] == "moved":
+ champs["moved"].append((cle, change[1]))
+ else:
+ champs[change[0]].append(cle)
+ continue
+ # Un champ dont le MODÈLE a disparu n'est pas une trouvaille de
+ # plus : c'est la même, comptée une fois par champ. Sur un palier
+ # réel cela triplait la liste et noyait le vrai signal.
+ if partis and cle.startswith(partis):
+ champs["model_gone"].append(cle)
+ continue
+ origines = set((origine_champ or {}).get(cle) or [])
+ if origines and not (origines & analyses):
+ champs["not_analysed"].append(cle)
+ continue
+ champs["undeclared"].append(cle)
+ return {
+ "available": True,
+ "modules": index["modules"],
+ "models": modeles,
+ "fields": champs,
+ }
+
+
+def explain_loss(table, version):
+ """Ce qu'Odoo a fait de cette table à cette version, ou None.
+
+ `version` est celle d'ARRIVÉE : une refonte de la 17 n'explique rien
+ d'un palier 13 → 14, et l'accepter ferait taire une vraie perte sous
+ prétexte qu'elle porte le nom d'une table refondue plus tard.
+ """
+ if not version:
+ return None
+ for entree in SEMANTIC_MAP:
+ if entree["table"] == table and entree["since"] <= version:
+ return entree
+ return None
+
+
+def compare(avant, apres):
+ """Ce qui a été gagné et ce qui a été perdu entre deux paliers."""
+ if not avant.get("exists") or not apres.get("exists"):
+ return {"unavailable": True}
+ inst_avant, inst_apres = set(avant["installed"]), set(apres["installed"])
+ mod_avant, mod_apres = set(avant["model"]), set(apres["model"])
+ tbl_avant, tbl_apres = avant["table"], apres["table"]
+
+ # TOUJOURS quatre éléments, le dernier étant l'explication ou None.
+ # Un tuple de taille variable obligerait chaque lecteur à s'en méfier.
+ lignes_perdues = []
+ for table, nombre in sorted(tbl_avant.items()):
+ reste = tbl_apres.get(table)
+ if reste is None and nombre:
+ lignes_perdues.append((table, nombre, 0, None))
+ elif reste is not None and reste < nombre:
+ lignes_perdues.append((table, nombre, reste, None))
+ lignes_gagnees = [
+ (table, tbl_avant.get(table, 0), nombre)
+ for table, nombre in sorted(tbl_apres.items())
+ if nombre > tbl_avant.get(table, 0)
+ ]
+ version = as_version(apres)
+ for index, (table, debut, fin, _rien) in enumerate(lignes_perdues):
+ connu = explain_loss(table, version)
+ if not connu:
+ continue
+ # Une fusion qui n'a PAS grossi la table d'accueil n'explique
+ # rien : on garde l'explication et on dit qu'elle ne tient pas.
+ arrivee = connu["into"]
+ recue = (
+ apres["table"].get(arrivee, 0) - avant["table"].get(arrivee, 0)
+ if arrivee
+ else None
+ )
+ lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue})
+ champ_avant = set(avant.get("field") or [])
+ champ_apres = set(apres.get("field") or [])
+ cow_avant = set(avant.get("cow") or [])
+ cow_apres = set(apres.get("cow") or [])
+ # Les plus grosses pertes EN TÊTE : on attaque une liste de
+ # cinquante-sept par le haut, pas par ordre alphabétique.
+ lignes_perdues.sort(key=lambda item: -(item[1] - item[2]))
+ champs_perdus = sorted(champ_avant - champ_apres)
+ modeles_perdus = sorted(mod_avant - mod_apres)
+ return {
+ "declared": overlay_declared(
+ version,
+ modeles_perdus,
+ champs_perdus,
+ avant.get("field_module"),
+ ),
+ "fields_lost": champs_perdus,
+ "fields_gained": sorted(champ_apres - champ_avant),
+ "cow_lost": sorted(cow_avant - cow_apres),
+ "cow_gained": sorted(cow_apres - cow_avant),
+ "modules_lost": sorted(inst_avant - inst_apres),
+ "modules_gained": sorted(inst_apres - inst_avant),
+ "models_lost": modeles_perdus,
+ "models_gained": sorted(mod_apres - mod_avant),
+ "rows_lost": lignes_perdues,
+ "rows_gained": lignes_gagnees,
+ "renamed": probable_renames(lignes_perdues, lignes_gagnees),
+ "delta": {
+ cle: apres.get(cle, 0) - avant.get(cle, 0)
+ for cle in (
+ "view",
+ "view_cow",
+ "menu",
+ "action",
+ "attachment",
+ "language",
+ )
+ },
+ }
+
+
+def probable_renames(perdues, gagnees):
+ """Rapprocher une table disparue d'une table apparue au même compte.
+
+ Odoo renomme des tables entre versions — `mail_channel` est devenu
+ `discuss_channel` en 17. Sans ce rapprochement, chaque renommage se
+ lit comme une perte de données doublée d'une apparition, et l'on
+ cherche un dégât là où il n'y en a pas.
+ """
+ disparues = {
+ table: avant
+ for table, avant, apres, _connu in perdues
+ if apres == 0 and avant
+ }
+ apparues = {
+ table: apres for table, avant, apres in gagnees if avant == 0 and apres
+ }
+ couples = []
+ for table, nombre in sorted(disparues.items()):
+ for autre, combien in sorted(apparues.items()):
+ if combien == nombre and looks_renamed(table, autre):
+ couples.append((table, autre, nombre))
+ del apparues[autre]
+ break
+ return couples
+
+
+RENAME_RATIO = 0.75
+
+
+def looks_renamed(un, deux):
+ """Les deux noms se ressemblent-ils assez pour être le même sujet ?
+
+ Deux garde-fous ont été essayés et rejetés, mesurés sur une vraie
+ migration. Le seul nombre de lignes accouplait
+ `account_account_tag_account_tax_template_rel` à `dms_directory` : les
+ deux comptaient sept lignes. Un mot commun d'au moins cinq lettres
+ accouplait `cleanup_purge_wizard_menu` à
+ `cleanup_create_indexes_line` — « cleanup » ne dit rien.
+
+ La ressemblance d'ENSEMBLE tranche : `muk_dms_directory` et
+ `dms_directory` se ressemblent à 87 %, `website_redirect` et
+ `website_rewrite` à 83 %, tandis que les faux couples restent sous
+ 50 %.
+ """
+ import difflib
+
+ return difflib.SequenceMatcher(None, un, deux).ratio() >= RENAME_RATIO
+
+
+def survey(dct, echo=None):
+ """Inspecter toute la chaîne, du départ à l'arrivée."""
+ lst = []
+ for version, database in chain(dct):
+ if echo:
+ echo(f"{database} …")
+ etat = inspect(database)
+ etat["version"] = version
+ lst.append(etat)
+ return lst
+
+
+def overall(lst_snapshot):
+ """La comparaison du PREMIER au DERNIER palier.
+
+ Elle ne se déduit pas des comparaisons deux à deux : un module retiré
+ en 15 puis remis en 17 n'a rien perdu du tout, et l'addition des
+ étapes le compterait deux fois.
+ """
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ if len(presents) < 2:
+ return {"unavailable": True}
+ return compare(presents[0], presents[-1])
+
+
+DECLARE_MODELES = (
+ ("obsolete", "declared obsolete", "dim"),
+ ("renamed", "renamed by Odoo", "dim"),
+ ("undeclared", "NOT declared by OpenUpgrade", "warn"),
+)
+
+# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
+# regarder finit la liste, donc reste sous les yeux.
+DECLARE_CHAMPS = (
+ ("del", "declared removed", "dim"),
+ ("moved", "moved to another module", "dim"),
+ ("company_dependent", "became a per-company jsonb column", "dim"),
+ (
+ "unstored",
+ "computed now — the field remains, the column does not",
+ "ok",
+ ),
+ ("model_gone", "their model went away too", "dim"),
+ ("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
+ ("undeclared", "NOT declared by OpenUpgrade", "warn"),
+)
+
+
+def group_by_field_name(cles):
+ """[(nom, combien, exemple)] par nom de champ, les plus nombreux d'abord.
+
+ Un champ retiré d'un MIXIN disparaît de tous les modèles qui en
+ héritent : `__last_update` s'est ainsi compté 580 fois au palier 17.
+ C'est UN changement. Listé modèle par modèle, il remplissait l'écran
+ et cachait les vingt autres ; groupé, il tient sur une ligne.
+ """
+ par_nom = {}
+ for cle in cles:
+ nom = cle.rsplit(".", 1)[-1] if "." in cle else cle
+ par_nom.setdefault(nom, []).append(cle)
+ return sorted(
+ ((nom, len(lst), sorted(lst)[0]) for nom, lst in par_nom.items()),
+ key=lambda item: (-item[1], item[0]),
+ )
+
+
+def render_declared(declare, colour, limit=8):
+ """Le théorique posé sur le pratique. Rien si on ne peut pas le lire.
+
+ Se taire quand l'analyse manque plutôt que d'afficher des zéros :
+ « 0 déclaré » et « analyse introuvable » se ressemblent à l'œil et ne
+ veulent pas du tout dire la même chose.
+ """
+ from script.todo.migration_status import paint
+
+ if not declare.get("available"):
+ if declare.get("reason") == "missing":
+ return [
+ f" {paint('⇄', 'dim', colour)}"
+ f" {t('No OpenUpgrade analysis for this step.')}"
+ ]
+ return []
+ lignes = [
+ f" {paint('⇄', 'dim', colour)} {t('Against OpenUpgrade')}"
+ f" ({declare['modules']} {t('core module(s) analysed')})"
+ ]
+ for titre, groupes, table in (
+ (t("model(s) lost"), declare["models"], DECLARE_MODELES),
+ (t("field(s) lost"), declare["fields"], DECLARE_CHAMPS),
+ ):
+ total = sum(len(groupes.get(cle) or []) for cle, _l, _c in table)
+ if not total:
+ continue
+ lignes.append(f" {total} {titre} :")
+ for cle, libelle, teinte in table:
+ trouves = groupes.get(cle) or []
+ if not trouves:
+ continue
+ lignes.append(
+ f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
+ f" {t(libelle)}"
+ )
+ if cle not in ("undeclared", "unstored"):
+ # Ces deux-là seuls méritent des noms : l'un parce qu'il
+ # faut aller voir, l'autre parce qu'on croirait à une
+ # perte. Nommer les autres noierait le rapport.
+ continue
+ plats = [
+ element[0] if isinstance(element, tuple) else element
+ for element in trouves
+ ]
+ if table is DECLARE_CHAMPS:
+ groupes_nom = group_by_field_name(plats)
+ for nom, combien, exemple in groupes_nom[:limit]:
+ suffixe = (
+ f" × {combien} {t('model(s)')}" if combien > 1 else ""
+ )
+ montre = exemple if combien == 1 else nom
+ lignes.append(f" {montre}{suffixe}")
+ if len(groupes_nom) > limit:
+ lignes.append(
+ f" … {len(groupes_nom) - limit}"
+ f" {t('other field name(s)')}"
+ )
+ else:
+ for nom in plats[:limit]:
+ lignes.append(f" {nom}")
+ if len(plats) > limit:
+ lignes.append(
+ f" … {len(plats) - limit} {t('more')}"
+ )
+ return lignes
+
+
+def render_text(lst_snapshot, colour=None, limit=8):
+ """Le rapport complet. C'est aussi le repli du plein écran."""
+ from script.todo.migration_status import paint, supports_colour
+
+ if colour is None:
+ colour = supports_colour()
+ lignes = [f"📐 {t('Migration quality, step by step')}"]
+ for etat in lst_snapshot:
+ if not etat.get("exists"):
+ lignes.append(
+ f" ⚠️ {etat['database']} : {t('database not found')}"
+ )
+ continue
+ lignes.append(
+ f" {paint(f'{etat['odoo']:<6}', 'step', colour)}"
+ f" {etat['database']:<34}"
+ f" {len(etat['installed']):>4} {t('modules')}"
+ f" · {len(etat['model']):>4} {t('models')}"
+ f" · {etat['view']:>5} {t('views')}"
+ f" · {etat['attachment']:>6} {t('attachments')}"
+ )
+ if etat.get("attachment_missing"):
+ lignes.append(
+ f" {paint('❌', 'fail', colour)}"
+ f" {etat['attachment_missing']}"
+ f" {t('attachment files missing from the filestore')}"
+ )
+
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ for avant, apres in zip(presents, presents[1:]):
+ lignes.append(f"\n🔀 {avant['odoo']} → {apres['odoo']}")
+ lignes.extend(render_compare(compare(avant, apres), colour, limit))
+
+ lignes.append(
+ f"\n🏁 {t('From start to finish')} :"
+ f" {presents[0]['odoo'] if presents else '?'}"
+ f" → {presents[-1]['odoo'] if presents else '?'}"
+ )
+ lignes.extend(render_compare(overall(lst_snapshot), colour, limit))
+ return "\n".join(lignes)
+
+
+def render_compare(diff, colour, limit=8):
+ """Les gains et les pertes d'un palier, en quelques lignes."""
+ from script.todo.migration_status import paint
+
+ if diff.get("unavailable"):
+ return [f" {t('not comparable: a database is missing')}"]
+ lignes = []
+ for cle, symbole, teinte in (
+ ("modules_lost", "−", "fail"),
+ ("modules_gained", "+", "ok"),
+ ):
+ lst = diff[cle]
+ if lst:
+ lignes.append(
+ f" {paint(symbole, teinte, colour)} {len(lst)}"
+ f" {t('modules')} : {', '.join(lst[:limit])}"
+ + (" …" if len(lst) > limit else "")
+ )
+ for cle, symbole, teinte in (
+ ("models_lost", "−", "fail"),
+ ("models_gained", "+", "ok"),
+ ):
+ lst = diff[cle]
+ if lst:
+ lignes.append(
+ f" {paint(symbole, teinte, colour)} {len(lst)}"
+ f" {t('models')} : {', '.join(lst[:limit])}"
+ + (" …" if len(lst) > limit else "")
+ )
+ if diff["renamed"]:
+ lignes.append(
+ f" ↻ {len(diff['renamed'])} {t('probable table rename(s)')} :"
+ f" {', '.join(f'{a} → {b}' for a, b, _n in diff['renamed'][:4])}"
+ )
+ perdues = diff["rows_lost"]
+ if perdues:
+ # LE signal qui compte : un module en moins se voit, une table qui
+ # passe de quatre mille lignes à zéro ne se voit nulle part.
+ #
+ # Les pertes EXPLIQUÉES sont séparées des autres, jamais retirées.
+ # Sans cette séparation on lisait « 81 tables ont perdu des lignes »
+ # dont la plus grosse — ir_translation, 32 984 lignes — était une
+ # refonte voulue par Odoo : les vraies questions se noyaient dans
+ # les fausses, ce qui est la façon la plus sûre de ne pas les voir.
+ vers = {a: b for a, b, _n in diff["renamed"]}
+ ouvertes = [item for item in perdues if not item[3]]
+ connues = [item for item in perdues if item[3]]
+ if ouvertes:
+ lignes.append(
+ f" {paint('▼', 'fail', colour)} {len(ouvertes)}"
+ f" {t('table(s) lost rows, unexplained')} :"
+ )
+ for table, avant, apres, _rien in ouvertes[:limit]:
+ note = (
+ f" ↻ {t('probably renamed to')} {vers[table]}"
+ if table in vers
+ else ""
+ )
+ lignes.append(f" {table:<40} {avant:>8} → {apres}{note}")
+ if len(ouvertes) > limit:
+ lignes.append(f" … {len(ouvertes) - limit} {t('more')}")
+ if connues:
+ lignes.append(
+ f" {paint('▽', 'dim', colour)} {len(connues)}"
+ f" {t('table(s) explained by an Odoo change')} :"
+ )
+ for table, avant, apres, connu in connues[:limit]:
+ if connu["into"]:
+ recue = connu.get("gained")
+ # Une fusion dont la table d'accueil n'a PAS grossi
+ # n'explique rien : le dire, plutôt que de classer la
+ # perte comme attendue et passer à autre chose.
+ accueil = (
+ f"+{recue} {t('there')}"
+ if recue and recue > 0
+ else paint(t("but it gained nothing"), "fail", colour)
+ )
+ ou = f"→ {connu['into']} ({accueil})"
+ elif connu["kind"] == "pruned":
+ # La table est TOUJOURS là : dire « retirée » serait
+ # faux, et cette perte-ci est réelle — les lignes ne
+ # continuent nulle part. Le mot doit le laisser voir.
+ ou = paint(
+ t("rows dropped, the table remains"), "warn", colour
+ )
+ else:
+ ou = t("retired from the database")
+ lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}")
+ lignes.append(f" {connu['why']}")
+ if len(connues) > limit:
+ lignes.append(f" … {len(connues) - limit} {t('more')}")
+ lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))
+ delta = diff["delta"]
+ lignes.append(
+ " "
+ + " ".join(
+ f"{cle} {valeur:+d}" for cle, valeur in delta.items() if valeur
+ )
+ or " ="
+ )
+ return lignes
+
+
+def main(argv=None):
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description=(
+ "Compare every database a migration left behind, step by step,"
+ " and report what was gained and what was lost."
+ )
+ )
+ parser.add_argument(
+ "-f",
+ "--file",
+ default=DEFAULT_PROGRESSION,
+ help="migration progression file",
+ )
+ parser.add_argument(
+ "--text",
+ action="store_true",
+ help="print the report instead of opening the full screen",
+ )
+ parser.add_argument("--limit", type=int, default=8)
+ config = parser.parse_args(argv)
+
+ dct = read_progression(config.file)
+ if not chain(dct):
+ print(f"ℹ️ {t('No migration in progress.')}")
+ return 0
+ lst = survey(dct, echo=lambda texte: print(f"⧖ {texte}", flush=True))
+ if not config.text:
+ try:
+ from script.analyse.check_migration_quality_tui import run_tui
+ except Exception:
+ run_tui = None
+ if run_tui and run_tui(lst):
+ return 0
+ print(render_text(lst, limit=config.limit))
+ manque = [x for x in lst if not x.get("exists")]
+ perdu = overall(lst)
+ trouvailles = bool(manque) or bool(
+ not perdu.get("unavailable")
+ and (perdu["modules_lost"] or perdu["rows_lost"])
+ )
+ return 1 if trouvailles else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py
new file mode 100644
index 0000000..fc38097
--- /dev/null
+++ b/script/analyse/check_migration_quality_tui.py
@@ -0,0 +1,361 @@
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""La qualité d'une migration, palier par palier, en plein écran.
+
+Le rapport texte dit tout d'un coup : sur six paliers il fait plusieurs
+centaines de lignes, et ce qu'on cherche — quel palier a perdu quoi — se
+trouve quelque part au milieu. Un écran qui se parcourt règle cela.
+
+Les données viennent de `check_migration_quality`, comme le rapport texte.
+Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
+états contradictoires de la même migration.
+"""
+
+import os
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+from script.analyse import check_migration_quality as quality # noqa: E402
+from script.todo import migration_status as status # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 3; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#left { width: 40; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def rows(lst_snapshot):
+ """Un palier par ligne, puis le bilan d'ensemble en dernier.
+
+ Le bilan EN DERNIER et non en tête : on descend la liste comme on a
+ vécu la migration, et la question « qu'est-ce qu'il en reste » se pose
+ une fois qu'on a vu le chemin.
+ """
+ lst = []
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ for index, etat in enumerate(lst_snapshot):
+ if not etat.get("exists"):
+ lst.append(
+ {
+ "kind": "missing",
+ "label": f"⚠️ {etat['database'][:30]}",
+ "detail": "",
+ "data": etat,
+ }
+ )
+ continue
+ precedent = None
+ rang = presents.index(etat)
+ if rang:
+ precedent = presents[rang - 1]
+ diff = quality.compare(precedent, etat) if precedent else None
+ # Le précédent voyage avec la ligne : le panneau en a besoin pour
+ # écrire « 171 (−43) », et le recalculer là-bas ferait deux
+ # sources pour la même comparaison.
+ # La colonne compte les pertes INEXPLIQUÉES : afficher 81 quand
+ # 79 sont des refontes voulues par Odoo ferait fuir le lecteur du
+ # seul chiffre qui demande une réponse.
+ perdu = (
+ 0
+ if diff is None or diff.get("unavailable")
+ else len([item for item in diff["rows_lost"] if not item[3]])
+ )
+ lst.append(
+ {
+ "kind": "step",
+ "label": f"{etat['odoo']:<6} {etat['database'][:24]}",
+ "detail": str(perdu) if perdu else "",
+ "data": etat,
+ "diff": diff,
+ "previous": precedent,
+ }
+ )
+ if len(presents) >= 2:
+ lst.append(
+ {
+ "kind": "overall",
+ "label": f"🏁 {presents[0]['odoo']} → {presents[-1]['odoo']}",
+ "detail": "",
+ "data": None,
+ "diff": quality.overall(lst_snapshot),
+ }
+ )
+ return lst
+
+
+def head_text(lst_snapshot):
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ manquants = len(lst_snapshot) - len(presents)
+ if not presents:
+ return t("No migration in progress.")
+ return (
+ f"{presents[0]['database']} · {len(presents)} {t('steps')}"
+ f" · {presents[0]['odoo']} → {presents[-1]['odoo']}"
+ + (
+ f" · ⚠️ {manquants} {t('database not found')}"
+ if manquants
+ else ""
+ )
+ )
+
+
+def statistics(etat, precedent):
+ """[(libellé, valeur, écart)] pour un palier. L'écart est None au départ.
+
+ Un chiffre seul ne dit rien : 2283 vues est un nombre, « +61 » est une
+ information. Le premier palier n'a pas de précédent, et inventer un
+ écart de zéro y laisserait croire à une comparaison qui n'existe pas.
+ """
+ lst = [
+ (
+ t("modules"),
+ len(etat["installed"]),
+ None if not precedent else len(precedent["installed"]),
+ ),
+ (
+ t("models"),
+ len(etat["model"]),
+ None if not precedent else len(precedent["model"]),
+ ),
+ (
+ t("views"),
+ etat["view"],
+ None if not precedent else precedent["view"],
+ ),
+ (
+ " · COW",
+ etat["view_cow"],
+ None if not precedent else precedent["view_cow"],
+ ),
+ (
+ t("menus"),
+ etat["menu"],
+ None if not precedent else precedent["menu"],
+ ),
+ (
+ t("attachments"),
+ etat["attachment"],
+ None if not precedent else precedent["attachment"],
+ ),
+ ]
+ return [
+ (libelle, valeur, None if avant is None else valeur - avant)
+ for libelle, valeur, avant in lst
+ ]
+
+
+def pane_text(lst_snapshot, row, colour=False, mode=None):
+ """Le détail du palier choisi, ou le bilan d'ensemble.
+
+ UN seul mode, et non deux drapeaux : « montre les fichiers absents »
+ et « montre la liste des modèles » ne peuvent pas être vrais en même
+ temps, et deux booléens laissaient écrire cet état impossible.
+ """
+ if row is None:
+ return t("Nothing to show yet.")
+ if row["kind"] == "missing":
+ return f"⚠️ {row['data']['database']} : {t('database not found')}"
+ etat = row["data"]
+ if mode == "missing":
+ if not etat:
+ return t("Pick a step to see its missing files.")
+ return quality.render_missing(etat, colour)
+ if mode in quality.DETAILS:
+ return quality.render_detail(row.get("diff"), mode, colour)
+ lignes = []
+ if etat:
+ lignes.append(
+ status.paint(f"{etat['odoo']} {etat['database']}", "step", colour)
+ )
+ lignes.append("")
+ for libelle, valeur, ecart in statistics(etat, row.get("previous")):
+ if ecart is None:
+ marque = ""
+ else:
+ marque = status.paint(
+ f"{ecart:+d}", "ok" if ecart >= 0 else "warn", colour
+ )
+ lignes.append(f" {libelle:<28} {valeur:>7} {marque}")
+ if etat.get("attachment_missing"):
+ lignes.append(
+ " "
+ + status.paint(
+ f"❌ {etat['attachment_missing']}"
+ f" {t('attachment files missing from the filestore')}",
+ "fail",
+ colour,
+ )
+ )
+ lignes.append(f" {t('press m to list them')}")
+ lignes.append("")
+ diff = row.get("diff")
+ if diff:
+ lignes.extend(quality.render_compare(diff, colour, limit=40))
+ return "\n".join(lignes)
+
+
+def mode_label(mode):
+ """Le nom du mode courant, pour le sous-titre.
+
+ Un panneau qui change de contenu sans dire pourquoi se lit comme un
+ écran cassé — et avec sept modes, on ne devine pas.
+ """
+ if mode is None:
+ return t("summary")
+ if mode == "missing":
+ return t("Missing files")
+ return t(mode)
+
+
+def build_app(lst_snapshot):
+ """Textual est importé ICI : le module reste testable sans lui."""
+ from rich.text import Text
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ class QualityApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [
+ ("q,escape", "quit", t("Quit")),
+ ("m", "toggle_missing", t("Missing files")),
+ ("d", "cycle_detail", t("Details")),
+ ]
+
+ def __init__(self, lst_snapshot):
+ super().__init__()
+ self.lst_snapshot = lst_snapshot
+ self.lst_row = rows(lst_snapshot)
+ self.index = 0
+ self.mode = None
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="left", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = t("Migration quality, step by step")
+ table = self.query_one("#left", DataTable)
+ table.add_columns(t("steps"), "▼")
+ for row in self.lst_row:
+ table.add_row(row["label"][:34], row["detail"])
+ self.query_one("#head", Static).update(
+ head_text(self.lst_snapshot)
+ )
+ self._show()
+
+ def _show(self):
+ self.sub_title = mode_label(self.mode)
+ row = (
+ self.lst_row[self.index]
+ if self.lst_row and self.index < len(self.lst_row)
+ else None
+ )
+ self.query_one("#content", Static).update(
+ Text.from_ansi(
+ pane_text(
+ self.lst_snapshot,
+ row,
+ colour=True,
+ mode=self.mode,
+ )
+ )
+ )
+
+ def action_toggle_missing(self):
+ """Basculer entre les chiffres du palier et ses fichiers absents.
+
+ Les métadonnées ne sont lues qu'ICI : une requête de plus par
+ base allongerait un parcours qui tient en quatre secondes, pour
+ une information qu'on ne regarde qu'en la demandant.
+ """
+ self.mode = None if self.mode == "missing" else "missing"
+ self._show()
+
+ def action_cycle_detail(self):
+ """Parcourir les listes ENTIÈRES, une catégorie à la fois.
+
+ Le résumé coupe à huit entrées et il a raison ; mais quand on
+ cherche si UN module précis a survécu, la liste tronquée ne
+ répond pas. On enchaîne donc résumé → modules → modèles →
+ champs → copies COW → tables, et l'on revient.
+ """
+ suite = (None,) + quality.DETAILS
+ courant = self.mode if self.mode in suite else None
+ self.mode = suite[(suite.index(courant) + 1) % len(suite)]
+ self._show()
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "left" and self.lst_row:
+ self.index = event.cursor_row
+ self._show()
+
+ return QualityApp(lst_snapshot)
+
+
+def run_tui(lst_snapshot, run_app=True):
+ """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
+ if not lst_snapshot:
+ return False
+ if not sys.stdout.isatty():
+ print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
+ return False
+ try:
+ from script.todo import textual_setup
+ except Exception:
+ textual_setup = None
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(lst_snapshot)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ return False
+ if not run_app:
+ return app
+ if in_event_loop():
+ # Filet de sécurité : `app.run()` appelle `asyncio.run()`, qui
+ # lève dans une boucle déjà en cours. Le dire vaut mieux que la
+ # trace de quarante lignes que cela produit — et l'appelant doit
+ # passer par un sous-processus.
+ print(
+ f"ℹ️ {t('Already inside a running screen: open it in its own')}"
+ f" {t('process instead.')}"
+ )
+ return False
+ app.run()
+ return True
+
+
+def in_event_loop():
+ """Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
+ import asyncio
+
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return False
+ return True
diff --git a/script/analyse/lib_analyse.py b/script/analyse/lib_analyse.py
old mode 100644
new mode 100755
diff --git a/script/analyse/openupgrade_analysis.py b/script/analyse/openupgrade_analysis.py
new file mode 100644
index 0000000..ff27f61
--- /dev/null
+++ b/script/analyse/openupgrade_analysis.py
@@ -0,0 +1,267 @@
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qu'OpenUpgrade DÉCLARE changer, pour l'opposer à ce qui a changé.
+
+Le rapport de qualité comptait des « + » et des « − ». C'est vrai mais
+insuffisant : un champ qui cesse d'être stocké disparaît de la base sans
+rien perdre — il est calculé maintenant. Compté comme une perte, il
+alarme ; ignoré, il masquerait une vraie perte. Il faut un troisième
+état, et OpenUpgrade le connaît déjà.
+
+Chaque module cœur d'Odoo porte, dans OpenUpgrade, un fichier
+`upgrade_analysis.txt` qui liste ce que le palier change :
+
+ ---Models in module 'account'---
+ obsolete model account.unreconcile [transient]
+ new model product.combo (renamed from pos.combo in module point_of_sale)
+ ---Fields in module 'account'---
+ account / account.account / code (char) : not stored anymore
+ account / account.cash.rounding / loss_account_id (many2one) : needs
+ conversion to v18-style company dependent
+
+C'est la même information que la page « coverage analysis » publiée par
+l'OCA — elle est engendrée depuis ces fichiers — mais lue dans le dépôt,
+donc sans réseau, et à la version exacte du checkout.
+
+Ce qu'on n'y trouvera pas
+-------------------------
+Ces fichiers ne couvrent que les modules CŒUR d'Odoo. Un champ d'un
+module OCA ou maison n'y sera jamais, et son absence ne veut donc pas
+dire « perte non déclarée » au même titre. Le rapport le dit plutôt que
+de laisser conclure.
+"""
+
+import os
+import re
+
+REPO_ROOT = os.path.normpath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
+)
+
+# `new model x.y [transient]` ou `new model x.y (renamed from a.b in module m)`
+MODELE_NEUF = re.compile(
+ r"^new model (\S+)(?:\s+\(renamed from (\S+) in module (\S+)\))?"
+)
+MODELE_OBSOLETE = re.compile(r"^obsolete model (\S+)")
+# `module / model / champ (type) : description` — le type est absent sur
+# les pseudo-champs comme `_order`, et le « : » n'est pas toujours espacé.
+CHAMP = re.compile(
+ r"^(\S+)\s+/\s+(\S+)\s+/\s+(\S+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$"
+)
+# `module is now 'sale' ('point_of_sale')`
+DEPLACE = re.compile(r"module is now '([^']+)'")
+
+SECTIONS = {
+ "Models": "model",
+ "Fields": "field",
+ "XML records": "xml",
+}
+
+
+def scripts_dir(version, root=None):
+ """Où vivent les analyses pour ce palier d'ARRIVÉE."""
+ return os.path.join(
+ root or REPO_ROOT,
+ f"odoo{version}.0",
+ "OCA_OpenUpgrade",
+ "openupgrade_scripts",
+ "scripts",
+ )
+
+
+def analysis_files(version, root=None):
+ """Les `upgrade_analysis.txt` de ce palier, triés.
+
+ On filtre sur le dossier de version : à côté des `18.0.1.0` vivent un
+ `tests` et, en 14, un `0.0` — les lire mélangerait les paliers.
+ """
+ base = scripts_dir(version, root)
+ trouves = []
+ if not os.path.isdir(base):
+ return trouves
+ prefixe = f"{version}."
+ for module in sorted(os.listdir(base)):
+ dossier = os.path.join(base, module)
+ if not os.path.isdir(dossier):
+ continue
+ for release in sorted(os.listdir(dossier)):
+ if not release.startswith(prefixe):
+ continue
+ chemin = os.path.join(dossier, release, "upgrade_analysis.txt")
+ if os.path.isfile(chemin):
+ trouves.append(chemin)
+ return trouves
+
+
+_CACHE_MODULES = {}
+
+
+def analysed_modules(version, root=None):
+ """Les modules cœur qu'OpenUpgrade analyse à ce palier.
+
+ Les noms de dossiers SONT la liste. Elle sert à distinguer « aucune
+ déclaration » de « hors de son champ » : OpenUpgrade ne dit rien d'un
+ module OCA, et le compter comme non déclaré accuserait à tort.
+ """
+ if version in _CACHE_MODULES:
+ return _CACHE_MODULES[version]
+ base = scripts_dir(version, root)
+ trouves = set()
+ if os.path.isdir(base):
+ trouves = {
+ nom
+ for nom in os.listdir(base)
+ if os.path.isdir(os.path.join(base, nom))
+ }
+ if root is None:
+ _CACHE_MODULES[version] = trouves
+ return trouves
+
+
+def blank_index():
+ return {
+ "models_new": set(),
+ "models_obsolete": set(),
+ "models_renamed": {},
+ "fields_new": set(),
+ "fields_del": set(),
+ "fields_unstored": set(),
+ "fields_stored": set(),
+ "fields_moved": {},
+ "fields_company_dependent": set(),
+ "fields_other": {},
+ "xml_new": 0,
+ "xml_del": 0,
+ "modules": 0,
+ }
+
+
+def classify(description):
+ """La catégorie d'un changement de champ, d'après sa description.
+
+ L'ordre compte : « NEW relation: … » commence par NEW, et
+ « not stored anymore » contient « stored ». On teste donc du plus
+ précis au plus général plutôt que par appartenance de sous-chaîne.
+ """
+ texte = description.strip()
+ if texte.startswith("NEW"):
+ return "new"
+ if texte.startswith("DEL"):
+ return "del"
+ if texte.startswith("not stored anymore"):
+ return "unstored"
+ if texte.startswith("is now stored"):
+ return "stored"
+ if "needs conversion to" in texte and "company dependent" in texte:
+ return "company_dependent"
+ if DEPLACE.search(texte):
+ return "moved"
+ return "other"
+
+
+def parse(texte, index=None):
+ """Lire UN fichier d'analyse dans l'index (créé au besoin)."""
+ index = blank_index() if index is None else index
+ section = None
+ for ligne in texte.splitlines():
+ entete = re.match(r"^---(.+?) in module '([^']+)'---", ligne)
+ if entete:
+ section = SECTIONS.get(entete.group(1))
+ continue
+ if not ligne.strip():
+ continue
+ if section == "model":
+ _read_model(ligne, index)
+ elif section == "field":
+ _read_field(ligne, index)
+ elif section == "xml":
+ if ligne.startswith("NEW"):
+ index["xml_new"] += 1
+ elif ligne.startswith("DEL"):
+ index["xml_del"] += 1
+ return index
+
+
+def _read_model(ligne, index):
+ trouve = MODELE_NEUF.match(ligne)
+ if trouve:
+ index["models_new"].add(trouve.group(1))
+ if trouve.group(2):
+ # Le renommage se lit du côté du NOUVEAU modèle : c'est là
+ # qu'OpenUpgrade note d'où il vient. L'ancien n'apparaît nulle
+ # part comme « obsolete », donc sans cette ligne sa
+ # disparition passerait pour une perte sèche.
+ index["models_renamed"][trouve.group(2)] = trouve.group(1)
+ return
+ trouve = MODELE_OBSOLETE.match(ligne)
+ if trouve:
+ index["models_obsolete"].add(trouve.group(1))
+
+
+def _read_field(ligne, index):
+ trouve = CHAMP.match(ligne)
+ if not trouve:
+ return
+ _module, modele, champ, _type, description = trouve.groups()
+ if champ.startswith("_"):
+ # `_order`, `_sql_constraints` : ce ne sont pas des champs, et les
+ # compter fausserait le rapprochement avec `ir_model_fields`.
+ return
+ cle = f"{modele}.{champ}"
+ genre = classify(description)
+ if genre == "new":
+ index["fields_new"].add(cle)
+ elif genre == "del":
+ index["fields_del"].add(cle)
+ elif genre == "unstored":
+ index["fields_unstored"].add(cle)
+ elif genre == "stored":
+ index["fields_stored"].add(cle)
+ elif genre == "company_dependent":
+ index["fields_company_dependent"].add(cle)
+ elif genre == "moved":
+ index["fields_moved"][cle] = DEPLACE.search(description).group(1)
+ else:
+ index["fields_other"].setdefault(cle, []).append(description.strip())
+
+
+def load(version, root=None):
+ """L'index complet d'un palier. `modules` à 0 = rien de lisible."""
+ index = blank_index()
+ for chemin in analysis_files(version, root):
+ try:
+ with open(chemin, "r", encoding="utf-8") as handle:
+ parse(handle.read(), index)
+ except OSError:
+ continue
+ index["modules"] += 1
+ return index
+
+
+def model_change(nom, index):
+ """Ce qu'OpenUpgrade déclare pour ce modèle disparu, ou None."""
+ if nom in index["models_renamed"]:
+ return ("renamed", index["models_renamed"][nom])
+ if nom in index["models_obsolete"]:
+ return ("obsolete", None)
+ return None
+
+
+def field_change(cle, index):
+ """Ce qu'OpenUpgrade déclare pour ce champ disparu, ou None.
+
+ « unstored » est le cas qui justifie tout l'outil : le champ EXISTE
+ toujours, il n'a plus de colonne parce qu'il est calculé. Ce n'est ni
+ un gain ni une perte — c'est une transformation, et la confondre avec
+ une perte fait crier au loup à chaque palier.
+ """
+ if cle in index["fields_del"]:
+ return ("del", None)
+ if cle in index["fields_unstored"]:
+ return ("unstored", None)
+ if cle in index["fields_company_dependent"]:
+ return ("company_dependent", None)
+ if cle in index["fields_moved"]:
+ return ("moved", index["fields_moved"][cle])
+ return None
diff --git a/script/analyse/shell/view_file_arch.py b/script/analyse/shell/view_file_arch.py
old mode 100644
new mode 100755
diff --git a/script/execute/execute.py b/script/execute/execute.py
index cfb9ff3..dcb360f 100644
--- a/script/execute/execute.py
+++ b/script/execute/execute.py
@@ -178,12 +178,22 @@ class Execute:
env=my_env,
)
+ sink = getattr(self, "log_sink", None)
while True:
line = process.stdout.readline()
if not line:
break
if not quiet:
print(line, end="")
+ if sink:
+ # Chaque ligne passe DÉJÀ ici : c'est le seul endroit où
+ # journaliser sans rien changer à ce que le terminal
+ # montre. Une erreur d'écriture ne doit jamais faire
+ # échouer la commande qu'on est en train de suivre.
+ try:
+ sink.write(line)
+ except Exception:
+ sink = None
if (
return_status_and_output
or return_status_and_output_and_command
diff --git a/script/odoo/migration/check_cow_views.py b/script/odoo/migration/check_cow_views.py
index 07321a0..8526a2b 100755
--- a/script/odoo/migration/check_cow_views.py
+++ b/script/odoo/migration/check_cow_views.py
@@ -50,6 +50,18 @@ import subprocess
import sys
import xml.etree.ElementTree as ET
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
# A view whose module counterpart cannot be found at all.
MODE_UNKNOWN = "unknown"
@@ -232,6 +244,9 @@ def analyse(database, target_version):
# The decisive test: the target expects inheritance specs but the copy
# holds a standalone template, or the reverse.
if is_spec is not None and target_inherits != is_spec:
+ # La clé anglaise est stockée, la traduction se fait à
+ # l'affichage : cow_drift et neutralize la relisent, et une
+ # valeur déjà traduite les obligerait à traduire en sens inverse.
reason = (
"target inherits, copy holds a standalone template"
if target_inherits
@@ -281,24 +296,29 @@ def main():
if not os.path.isdir(config.target_version):
print(
- f"❌ Target version directory '{config.target_version}' not found."
+ f"❌ {t('Target version directory not found')} :"
+ f" '{config.target_version}'"
)
- return 1
+ return 2
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
config.database, config.target_version
)
+ database = config.database
+ target_version = config.target_version
if not lst_at_risk:
print(
- "✅ -> No website COW view changes mode in"
+ f"✅ -> {t('No website COW view changes shape in')}"
f" {config.target_version}."
)
else:
print(
- f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving"
- f" to {config.target_version}: the copy keeps an arch whose shape"
- " no longer matches what the target module view expects."
+ f"⚠️ {len(lst_at_risk)}"
+ f" {t('website COW view(s) will break when moving to')}"
+ f" {config.target_version} :"
+ f" {t('the copy keeps an arch whose shape no longer matches what')}"
+ f" {t('the target module view expects.')}"
)
for (
view_id,
@@ -310,37 +330,55 @@ def main():
) in lst_at_risk:
print(
f" - id={view_id} website={website_id} {key}"
- f" : {mode} -> {target_mode} ({reason})"
+ f" : {mode} -> {target_mode} ({t(reason)})"
)
+ # La base et la version cible sont connues ici : les remplacer par
+ # « DB » et « odooXX.0 » oblige à les retrouver, au moment précis où
+ # l'on veut juste copier-coller la commande.
print(
- " Arbitrate BEFORE launching the migration. To neutralize a copy,"
- " rename its key (UPDATE ir_ui_view SET key='zz_cow_archive.'||key,"
- " active=false): an unmatched key is never paired with the module"
- " view, so the copy never receives the new inherit_id. Setting"
- " active=false alone is NOT enough -- an inactive copy that keeps"
- " the same key still shadows the module view."
+ f" {t('The migration offers to neutralize them, and shows what')}"
+ f" {t('each copy holds before you answer. To look now:')}"
+ f"\n ./script/odoo/migration/cow_drift.py -d {database}"
+ f" -t {target_version} ({t('what each copy holds')})"
+ f"\n ./script/odoo/migration/cow_drift.py -d {database}"
+ f" -t {target_version} --shape ({t('why it breaks')})"
+ f"\n ./script/odoo/migration/neutralize_cow_views.py"
+ f" -d {database} -t {target_version} --apply"
+ f" ({t('reversible with --restore')})"
+ )
+ print(
+ f" {t('Or by hand. To neutralize a copy, rename its key')}"
+ " (UPDATE ir_ui_view SET key='zz_cow_archive.'||key,"
+ f" active=false) : {t('an unmatched key is never paired with the')}"
+ f" {t('module view, so the copy never receives the new')}"
+ f" inherit_id. {t('Setting active=false alone is NOT enough:')}"
+ f" {t('an inactive copy keeping the same key still shadows it.')}"
)
if lst_module_absent:
print(
- f"ℹ {len(lst_module_absent)} COW view(s) belong to a module absent"
- f" from {config.target_version}:"
+ f"ℹ {len(lst_module_absent)}"
+ f" {t('COW view(s) belong to a module absent from')}"
+ f" {config.target_version} :"
)
for view_id, key, mode, website_id in lst_module_absent:
print(f" - id={view_id} website={website_id} {key} ({mode})")
if lst_no_counterpart:
print(
- f"ℹ {len(lst_no_counterpart)} COW view(s) are pages or records made"
- " from the website editor (no module view of that name): not at"
- " risk." + ("" if config.verbose else " Use -v to list them.")
+ f"ℹ {len(lst_no_counterpart)}"
+ f" {t('COW view(s) are pages or records made in the website')}"
+ f" {t('editor (no module view of that name): not at risk.')}"
+ + ("" if config.verbose else f" {t('Use -v to list them.')}")
)
if config.verbose:
for view_id, key, mode, website_id in lst_no_counterpart:
print(f" - id={view_id} website={website_id} {key} ({mode})")
- # Informative only: never fail the migration on a warning.
- return 0
+ # 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
+ # Le pilote lisait le texte anglais de cette sortie pour savoir s'il
+ # devait poser sa question : traduire le message le rendait aveugle.
+ return 1 if lst_at_risk else 0
if __name__ == "__main__":
diff --git a/script/odoo/migration/check_hidden_models.py b/script/odoo/migration/check_hidden_models.py
new file mode 100755
index 0000000..dc567ad
--- /dev/null
+++ b/script/odoo/migration/check_hidden_models.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Les modèles qui ont des données que PLUS PERSONNE ne peut voir.
+
+D'où vient ce test
+------------------
+Après une migration 12 → 18, les documents DMS avaient « disparu ». Ils
+n'avaient pas disparu : 69 fichiers et 23 Mo étaient intacts en base. Ce
+qui avait changé, c'est le modèle de sécurité — OCA DMS pose une règle
+GLOBALE sur une permission accordée par un `dms.access.group`, et la
+conversion depuis MuK n'en avait créé aucun, faute d'équivalent à
+convertir.
+
+Rien ne pouvait le voir venir. Les comptages de lignes disaient « tout est
+là », et c'était vrai. Le test de fumée ouvrait des pages publiques, et
+elles répondaient. Le trou est exactement entre les deux : des données
+présentes, et une règle qui les masque intégralement.
+
+Ce qu'on cherche
+----------------
+Un modèle tel que : il a des lignes, il porte au moins une règle GLOBALE,
+et AUCUN utilisateur interne actif n'en voit une seule. « Aucun » est le
+mot important — un modèle que seul un comptable voit est normal ; un
+modèle que personne ne voit est soit une refonte de sécurité ratée, soit
+des données devenues inatteignables.
+
+Pourquoi pas en SQL
+-------------------
+Une règle est un domaine Odoo, parfois avec `user.`, `company_ids`, ou un
+champ calculé cherchable — `permission_read` de DMS en est un, et il
+déclenche une sous-requête que rien dans `ir_rule` ne laisse deviner. Il
+faut l'ORM pour l'évaluer, donc le shell.
+
+Le super-utilisateur est exclu : les règles ne s'appliquent pas à lui, et
+un comptage fait en son nom déclarerait saine une base muette. C'est
+précisément l'erreur qui aurait laissé passer le cas DMS.
+
+Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
+"""
+
+import argparse
+import os
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+from script.odoo.migration import database_cleanup # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+DEBUT = database_cleanup.START
+FIN = database_cleanup.END
+
+# Ces modèles sont invisibles PAR DESSEIN : ils portent des règles globales
+# et ne s'adressent à personne en particulier. Les signaler à chaque
+# migration noierait la vraie trouvaille sous le bruit, et un rapport
+# qu'on apprend à ignorer ne sert plus à rien.
+ATTENDUS = (
+ "ir.rule",
+ "ir.model.access",
+ "res.users.log",
+ "bus.bus",
+ "bus.presence",
+ "mail.notification",
+ "ir.logging",
+ "ir.autovacuum",
+)
+
+SCRIPT = """
+import json
+LIMITE = {limite}
+ATTENDUS = set({attendus!r})
+rapport = {{"models": [], "checked": 0, "users": []}}
+try:
+ # Les utilisateurs INTERNES actifs, sans le super-utilisateur : les
+ # règles ne s'appliquent pas à lui, et compter en son nom
+ # déclarerait saine une base que personne ne peut lire.
+ membres = env["res.users"].sudo().search([
+ ("active", "=", True),
+ ("id", "!=", 1),
+ ("share", "=", False),
+ ], limit=LIMITE)
+ rapport["users"] = membres.mapped("login")
+ if not membres:
+ rapport["no_user"] = True
+ else:
+ vus = set(
+ env["ir.rule"].sudo()
+ .search([("global", "=", True), ("active", "=", True)])
+ .mapped("model_id.model")
+ )
+ for nom in sorted(vus - ATTENDUS):
+ modele = env.get(nom)
+ if modele is None or modele._abstract or modele._transient:
+ continue
+ try:
+ total = modele.sudo().search_count([])
+ except Exception:
+ continue
+ if not total:
+ continue
+ rapport["checked"] += 1
+ # Court-circuit : dès qu'UN utilisateur voit une ligne, le
+ # modèle n'est pas muet. Inutile d'interroger les autres.
+ visible = False
+ for membre in membres:
+ try:
+ if modele.with_user(membre).search([], limit=1):
+ visible = True
+ break
+ except Exception:
+ # Un refus d'accès n'est pas une ligne visible.
+ continue
+ if not visible:
+ rapport["models"].append({{"model": nom, "rows": total}})
+except Exception as exc:
+ rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
+print({debut!r})
+print(json.dumps(rapport))
+print({fin!r})
+"""
+
+
+def build_script(limite=25):
+ return SCRIPT.format(
+ limite=limite, attendus=list(ATTENDUS), debut=DEBUT, fin=FIN
+ )
+
+
+def render(rapport):
+ if rapport.get("no_user"):
+ return [f"⚠ {t('No internal user to test visibility with.')}"]
+ muets = rapport.get("models") or []
+ lignes = [
+ f"🔍 {rapport.get('checked', 0)}"
+ f" {t('model(s) with a global rule and some data,')}"
+ f" {t('checked against')} {len(rapport.get('users') or [])}"
+ f" {t('internal user(s)')}"
+ ]
+ if not muets:
+ lignes.append(f" ✅ {t('Every one of them is visible to someone.')}")
+ return lignes
+ lignes.append(
+ f" ❌ {len(muets)} {t('model(s) nobody can see a single row of')} :"
+ )
+ for entree in sorted(muets, key=lambda item: -item["rows"]):
+ lignes.append(
+ f" {entree['model']:<38} {entree['rows']:>8}"
+ f" {t('row(s)')}"
+ )
+ lignes.append(
+ f" {t('The data is there; a global rule hides all of it.')}"
+ )
+ return lignes
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Report models that hold data no internal user can see,"
+ " because a global record rule filters every row out."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", default="config.conf")
+ parser.add_argument(
+ "--users",
+ type=int,
+ default=25,
+ help="how many internal users to test with (default: 25)",
+ )
+ config = parser.parse_args(argv)
+
+ souci = database_cleanup.require_matching_version(config.database)
+ if souci:
+ print(f"❌ {souci}")
+ return 2
+ try:
+ rapport = database_cleanup.run_shell(
+ config.database,
+ config.config,
+ build_script(config.users),
+ echo=lambda texte: print(f"⧖ {texte}", flush=True),
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ if rapport.get("error"):
+ print(f"❌ {rapport['error']}")
+ return 2
+ print("\n".join(render(rapport)))
+ return 1 if rapport.get("models") else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py
new file mode 100755
index 0000000..b5ec509
--- /dev/null
+++ b/script/odoo/migration/check_stale_scss.py
@@ -0,0 +1,667 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Predict which customized SCSS will break the next version bump.
+
+Background
+----------
+Customizing a website writes SCSS into ``ir_attachment``: files whose URL
+carries ``.custom.``. That copy is frozen the day it is written, and it keeps
+using the variables of THAT version.
+
+A module can rename its variables between two Odoo versions. Measured on a
+real database: ``website/static/src/scss/primary_variables.scss`` declared
+``$o-theme-font-number`` in 12.0 and replaced the whole mechanism in 13.0 with
+a ``$o-website-values-palettes`` map. A customization written in 2020 still
+asked for the old name. The bundle then stops on::
+
+ Error: Undefined variable: "$o-theme-font-number".
+ This error occured while compiling the bundle 'web.assets_frontend'
+
+So the rule is the same as for COW views:
+
+ a customization breaks when what it USES stops being DEFINED between
+ version N and version N+1.
+
+Why not just start Odoo and open a page
+---------------------------------------
+Because that only tells you *after* the bump, on a page that renders a
+« Style error » with no name attached: not which attachment, not which
+variable, not since when. And a page has to be reached at all — a broken
+frontend bundle is exactly when it is not.
+
+Reading the stored SCSS against the target sources answers before the bump,
+in a second, and names the attachment. Nothing is written here.
+
+What it can miss
+----------------
+Which files a bundle really contains is decided by Odoo at runtime. This
+compares against every SCSS of the INSTALLED modules of the target version,
+which is wider: a variable defined in a module file that the bundle does not
+include would be counted as defined. That direction is deliberate — it under-
+reports rather than crying wolf on every customization.
+
+Exit codes: 0 nothing to report, 1 customizations at risk, 2 tool failure.
+"""
+
+import argparse
+import glob
+import os
+import re
+import subprocess
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+try:
+ from script.todo import auto_ask
+except Exception: # pragma: no cover - repli si le pilote est absent
+ auto_ask = None
+
+
+def can_ask():
+ """Peut-on poser une question ICI ?
+
+ Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
+ terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
+ stdin laisse poser une invite qui part dans un tube : elle reste en
+ tampon, invisible, pendant que le processus attend — on croit à un
+ blocage et l'on tape Entrée à l'aveugle.
+ """
+ return sys.stdin.isatty() and sys.stdout.isatty()
+
+
+# Une variable SCSS commence par $ ; celles dont le nom commence par un tiret
+# sont locales au fichier par convention Odoo ($-seen, $-font-numbers) et se
+# définissent toujours au-dessus de leur usage.
+RE_ANY = re.compile(r"\$([a-zA-Z][\w-]*)")
+
+
+def used_names(content):
+ """Noms LUS par le fichier, sans les liaisons.
+
+ Un « $nom » suivi d'un deux-points est toujours une liaison — déclaration,
+ valeur par défaut, ou argument nommé d'un @include — jamais la lecture
+ d'une variable. Sans cette réserve, « @include o-position-absolute(
+ $right: 50%) » se lisait comme l'usage d'un $right inexistant.
+
+ Le test se fait ici et non par une négation dans le motif : une négation
+ en tête de motif fait rétrograder le nom pour satisfaire la condition, et
+ rapporte « $botto » au lieu d'écarter « $bottom ». Mesuré.
+ """
+ names = set()
+ for match in RE_ANY.finditer(content):
+ if content[match.end() :].lstrip(" \t").startswith(":"):
+ continue
+ names.add(match.group(1))
+ return names
+
+
+RE_DEF = re.compile(r"^\s*\$([a-zA-Z][\w-]*)\s*:", re.M)
+
+# Une variable peut aussi être LIÉE sans être définie : paramètre de mixin ou
+# de fonction, variable de boucle. Les ignorer donnait six faux positifs sur
+# le seul fichier mesuré — $bottom, $counter, $off, $on, $right, $value — et
+# un détecteur qui crie à tort finit ignoré, ce qui vaut moins que rien.
+RE_PARAM = re.compile(r"@(?:mixin|function)\s+[\w-]+\s*\(([^)]*)\)")
+RE_EACH = re.compile(r"@each\s+([^i]+?)\s+in\s", re.S)
+RE_FOR = re.compile(r"@for\s+\$([\w-]+)\s+from\s")
+
+
+def bound_names(content):
+ """Noms liés par le fichier lui-même, hors définitions « $x: … »."""
+ names = set()
+ for group in RE_PARAM.findall(content):
+ names.update(RE_ANY.findall(group))
+ for group in RE_EACH.findall(content):
+ names.update(RE_ANY.findall(group))
+ names.update(RE_FOR.findall(content))
+ return names
+
+
+def run_psql(database, sql):
+ """Interroger la base, lecture seule garantie par le serveur."""
+ env = os.environ.copy()
+ env["PGOPTIONS"] = (
+ "-c default_transaction_read_only=on -c statement_timeout=60000"
+ )
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ [
+ "psql",
+ "-X",
+ "-w",
+ "-v",
+ "ON_ERROR_STOP=1",
+ "-d",
+ database,
+ "-tAF",
+ "\x1f",
+ "-c",
+ sql,
+ ],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ raise RuntimeError(done.stderr.strip() or "psql failed")
+ return [line.split("\x1f") for line in done.stdout.splitlines() if line]
+
+
+def filestore_dir(database, override=None):
+ if override:
+ return override
+ return os.path.join(
+ os.path.expanduser("~"),
+ ".local",
+ "share",
+ "Odoo",
+ "filestore",
+ database,
+ )
+
+
+def custom_scss(database, filestore):
+ """[(id, url, contenu)] pour chaque SCSS personnalisé lisible."""
+ rows = run_psql(
+ database,
+ "SELECT id, url, COALESCE(store_fname, ''),"
+ " COALESCE(encode(db_datas, 'escape'), '') FROM ir_attachment"
+ " WHERE url LIKE '%.custom.%' AND url NOT LIKE '%.css'"
+ " ORDER BY id;",
+ )
+ lst = []
+ for row in rows:
+ if len(row) < 4:
+ continue
+ att_id, url, store_fname, db_datas = row[0], row[1], row[2], row[3]
+ content = ""
+ if store_fname:
+ path = os.path.join(filestore, store_fname)
+ if os.path.isfile(path):
+ with open(path, "r", encoding="utf-8", errors="replace") as h:
+ content = h.read()
+ elif db_datas:
+ content = db_datas
+ if content:
+ lst.append((int(att_id), url, content))
+ return lst
+
+
+def installed_modules(database):
+ return {
+ row[0]
+ for row in run_psql(
+ database,
+ "SELECT name FROM ir_module_module WHERE state = 'installed';",
+ )
+ if row and row[0]
+ }
+
+
+def defined_in_sources(version_dir, lst_module):
+ """Variables définies par les SCSS des modules installés de la cible.
+
+ Le nom du module est le répertoire qui contient « static » : c'est ce qui
+ permet de ne pas compter les variables d'un module absent de la base, dont
+ aucune ligne n'atteindra jamais un bundle.
+ """
+ defined = set()
+ pattern = os.path.join(version_dir, "**", "static", "**", "*.scss")
+ for path in glob.iglob(pattern, recursive=True):
+ parts = path.split(os.sep)
+ try:
+ module = parts[parts.index("static") - 1]
+ except (ValueError, IndexError):
+ continue
+ if module not in lst_module:
+ continue
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
+ defined.update(RE_DEF.findall(handle.read()))
+ except OSError:
+ continue
+ return defined
+
+
+def module_file(base_url, version_dir):
+ """Le fichier du module que la personnalisation masque, ou None.
+
+ Le premier segment de l'URL est le nom du module ; le reste est son
+ chemin. C'est ce fichier que reset_asset rendrait, donc c'est contre lui
+ que se lit ce que la copie a réellement changé.
+ """
+ parts = base_url.strip("/").split("/")
+ if len(parts) < 2:
+ return None
+ pattern = os.path.join(version_dir, "**", parts[0], *parts[1:])
+ for candidate in glob.iglob(pattern, recursive=True):
+ if os.path.isfile(candidate):
+ return candidate
+ return None
+
+
+def analyse(database, version_dir, filestore=None):
+ """Un dictionnaire par personnalisation à risque.
+
+ Le contenu et le fichier du module y sont joints : l'appelant qui veut
+ montrer l'écart ne doit pas relire la base ni retrouver le chemin.
+ """
+ filestore = filestore_dir(database, filestore)
+ lst_module = installed_modules(database)
+ defined = defined_in_sources(version_dir, lst_module)
+ lst_finding = []
+ for att_id, url, content in custom_scss(database, filestore):
+ own = set(RE_DEF.findall(content)) | bound_names(content)
+ missing = sorted(
+ {
+ name
+ for name in used_names(content)
+ if name not in own and name not in defined
+ }
+ )
+ if not missing:
+ continue
+ base_url, bundle = split_custom_url(url)
+ path = module_file(base_url, version_dir)
+ module_content = ""
+ if path:
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
+ module_content = handle.read()
+ lst_finding.append(
+ {
+ "id": att_id,
+ "url": url,
+ "missing": missing,
+ "custom": content,
+ "base_url": base_url,
+ "bundle": bundle,
+ "module_path": path,
+ "module_content": module_content,
+ "version_dir": version_dir,
+ "database": database,
+ }
+ )
+ return lst_finding
+
+
+def render_diff(finding):
+ """Ce que la copie a changé, comparée au fichier du module de la cible.
+
+ C'est la seule chose qu'abandonner la copie ferait perdre. Sans elle, on
+ répond « oui, réinitialise » sans savoir si l'on jette trois lignes ou
+ une page entière.
+ """
+ import difflib
+
+ lines = [
+ f"── id={finding['id']} {finding['url']} ──",
+ "",
+ f" {t('Missing:')} "
+ + ", ".join("$" + name for name in finding["missing"]),
+ "",
+ ]
+ if not finding["module_path"]:
+ lines += [
+ f" {t('No module file of that name in')}"
+ f" {finding['version_dir']} :",
+ f" {t('the target no longer ships it, so there is nothing to')}",
+ f" {t('fall back on. Read the copy before dropping it.')}",
+ ]
+ return "\n".join(lines)
+ diff = list(
+ difflib.unified_diff(
+ finding["module_content"].splitlines(),
+ finding["custom"].splitlines(),
+ fromfile=finding["module_path"],
+ tofile=f"custom id={finding['id']}",
+ lineterm="",
+ n=2,
+ )
+ )
+ if len(diff) <= 2:
+ lines.append(f" {t('The copy is identical to the module file.')}")
+ return "\n".join(lines)
+ lines += [f" {line}" for line in diff]
+ plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
+ minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
+ lines += [
+ "",
+ f" +{plus}/-{minus} {t('line(s): that is what resetting gives up.')}",
+ ]
+ return "\n".join(lines)
+
+
+def running_odoo_dir():
+ """Le répertoire de la version qui répondra au shell, d'après le checkout.
+
+ Ce n'est PAS la cible : au moment où l'on prédit ce que le palier
+ cassera, le checkout est encore sur la version d'avant. C'est elle qui
+ exécutera reset_asset — ou ne le saura pas.
+ """
+ try:
+ with open(".odoo-version", "r", encoding="utf-8") as handle:
+ return "odoo" + handle.read().strip()
+ except OSError:
+ return None
+
+
+def reset_supported(odoo_dir=None):
+ """Cette version sait-elle faire reset_asset ?
+
+ Mesuré : `web_editor.assets` et `reset_asset` n'existent qu'à partir de
+ 13.0. Lancé sous odoo12.0, l'appel lève « KeyError: 'web_editor.assets' »
+ et ne change rien — c'est arrivé sur une vraie migration, et le correctif
+ a été cru appliqué alors qu'il avait échoué.
+
+ On regarde les sources plutôt qu'un numéro : c'est ce qui répondra.
+ """
+ odoo_dir = odoo_dir or running_odoo_dir()
+ if not odoo_dir or not os.path.isdir(odoo_dir):
+ return True # Rien pour trancher : ne pas bloquer sur une supposition.
+ pattern = os.path.join(odoo_dir, "**", "web_editor", "models", "assets.py")
+ for path in glob.iglob(pattern, recursive=True):
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
+ if "def reset_asset" in handle.read():
+ return True
+ return False
+
+
+def too_early_message(odoo_dir, database):
+ """Pourquoi on ne peut pas encore corriger, et quand on pourra."""
+ return (
+ f"⛔ {t('Resetting needs Odoo 13.0 or later; this checkout is on')}"
+ f" {odoo_dir or '?'}.\n"
+ f" {t('The prediction stands, the fix does not: run it again once')}"
+ f" {t('the bump is done, on the upgraded database.')}\n"
+ f" {t('Applying it from here fails with')}"
+ " KeyError: 'web_editor.assets'"
+ f" {t('and changes nothing.')}"
+ )
+
+
+def reset_command(lst_finding, database, config_path="./config.conf"):
+ """La commande qui rend les fichiers de module. Rien n'est lancé ici."""
+ lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"]
+ for finding in lst_finding:
+ lines.append(
+ "env['web_editor.assets'].reset_asset"
+ f"('{finding['base_url']}', '{finding['bundle']}')"
+ )
+ lines += ["env.cr.commit()", "PY"]
+ return "\n".join(lines)
+
+
+def apply_reset(lst_finding, database, config_path="./config.conf"):
+ """Rendre les fichiers de module. ÉCRIT en base — le seul endroit ici.
+
+ Réversible seulement au sens où la personnalisation peut être réécrite :
+ reset_asset supprime la pièce jointe. D'où la sauvegarde préalable, qui
+ n'est pas une politesse mais la condition pour dire « oui » sans risque.
+ """
+ script = "\n".join(
+ "env['web_editor.assets'].reset_asset"
+ f"('{f['base_url']}', '{f['bundle']}')"
+ for f in lst_finding
+ )
+ done = subprocess.run(
+ ["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
+ input=script + "\nenv.cr.commit()\n",
+ capture_output=True,
+ text=True,
+ )
+ return done.returncode, done.stdout + done.stderr
+
+
+def backup_custom(lst_finding, database):
+ """Écrire les copies sur disque AVANT de les abandonner.
+
+ reset_asset supprime la pièce jointe : sans ceci, les lignes réellement
+ personnalisées ne seraient plus nulle part.
+ """
+ directory = os.path.join(
+ "private", "odoo", "migration", database, "scss_backup"
+ )
+ os.makedirs(directory, exist_ok=True)
+ lst_path = []
+ for finding in lst_finding:
+ name = f"{finding['id']}_" + finding["url"].strip("/").replace(
+ "/", "_"
+ )
+ path = os.path.join(directory, name)
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(finding["custom"])
+ lst_path.append(path)
+ return lst_path
+
+
+def render(lst_finding, database, version_dir):
+ if not lst_finding:
+ return (
+ f"✅ -> {t('No customized SCSS is at risk in')} {version_dir}.\n"
+ )
+ lines = [
+ f"⚠️ {len(lst_finding)}"
+ f" {t('customized SCSS use(s) a variable that')} {version_dir}"
+ f" {t('no longer defines: the bundle will not compile.')}"
+ ]
+ for finding in lst_finding:
+ lines.append(f" - id={finding['id']} {finding['url']}")
+ lines.append(
+ " " + ", ".join("$" + name for name in finding["missing"])
+ )
+ lines += [
+ f" {t('Each one is a copy frozen on an older version. Dropping it')}"
+ f" {t('restores the module file:')}",
+ ]
+ lines += [
+ " " + line
+ for line in reset_command(lst_finding, database).splitlines()
+ ]
+ lines.append(
+ f" {t('Read it first: what it holds beyond the stale variable is')}"
+ f" {t('a real customization, to re-apply as a small file.')}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def prompt(lst_finding, database, config_path="./config.conf", ask=None):
+ """Montrer, puis proposer de corriger. Rend True si l'on a écrit.
+
+ Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de
+ perdre on ne sait quoi. L'invite revient donc après chaque lecture :
+ regarder ne répond pas à la question.
+
+ Le défaut suit ce que le checkout SAIT faire : réinitialiser quand
+ `reset_asset` existe, rien du tout avant la 13.0. Un défaut « a » là où
+ la remise à zéro n'existe pas ferait boucler l'invite sur elle-même.
+ """
+ odoo_dir = running_odoo_dir()
+ can_reset = reset_supported(odoo_dir)
+ if not can_reset:
+ print(too_early_message(odoo_dir, database))
+ defaut = "a" if can_reset else "n"
+ if ask is None:
+ ask = (
+ auto_ask.make_ask(defaut)
+ if auto_ask
+ else (lambda prompt="": input(prompt) or defaut)
+ )
+ while True:
+ choix = (
+ f" {t('Enter = reset them onto the module file')},"
+ if can_reset
+ else ""
+ )
+ answer = (
+ ask(
+ f"💬 {t('What do you want to do with these customizations?')}"
+ f" ({choix}"
+ f" v = {t('what the copy changed')},"
+ f" w = {t('full screen')},"
+ f" n = {t('nothing')}) : "
+ )
+ .strip()
+ .lower()
+ )
+ if answer == "v":
+ for finding in lst_finding:
+ print(render_diff(finding))
+ print()
+ continue
+ if answer == "w":
+ try:
+ from check_stale_scss_tui import run_tui
+ except ImportError:
+ print(f"ℹ️ {t('Full screen view unavailable.')}")
+ continue
+ if not run_tui(lst_finding):
+ for finding in lst_finding:
+ print(render_diff(finding))
+ continue
+ if answer == "a" and can_reset:
+ lst_path = backup_custom(lst_finding, database)
+ print(f"📦 {t('Saved before resetting')} :")
+ for path in lst_path:
+ print(f" {path}")
+ status, output = apply_reset(lst_finding, database, config_path)
+ print(output.strip()[-2000:])
+ if status:
+ print(f"❌ {t('Reset failed, nothing was changed.')}")
+ return False
+ print(f"✅ -> {t('Reset done.')}")
+ return True
+ return False
+
+
+def split_custom_url(url):
+ """« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle).
+
+ C'est la convention d'Odoo : le nom du bundle est encastré dans celui du
+ fichier. La reconstruire évite de faire deviner à l'utilisateur les deux
+ arguments de reset_asset au moment où il veut juste réparer.
+ """
+ directory, name = os.path.split(url)
+ head, _sep, tail = name.partition(".custom.")
+ bundle = tail[:-5] if tail.endswith(".scss") else tail
+ return os.path.join(directory, head + ".scss"), bundle
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Predict which customized SCSS will break on the target version."
+ " Read-only unless --apply."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument(
+ "-t",
+ "--target_version",
+ required=True,
+ help="target Odoo source directory, e.g. odoo13.0",
+ )
+ parser.add_argument(
+ "--filestore",
+ default=None,
+ help="filestore directory (default: ~/.local/share/Odoo/filestore/)",
+ )
+ parser.add_argument(
+ "-c",
+ "--config",
+ default="./config.conf",
+ help="Odoo config used by the shell for --apply",
+ )
+ parser.add_argument(
+ "--report-only",
+ action="store_true",
+ help="never ask anything, even in front of a terminal",
+ )
+ parser.add_argument(
+ "--diff",
+ action="store_true",
+ help="print what each copy changed, without asking",
+ )
+ parser.add_argument(
+ "--tui",
+ action="store_true",
+ help="browse the differences full screen",
+ )
+ parser.add_argument(
+ "--apply",
+ action="store_true",
+ help="reset them onto the module file (WRITES; saves a copy first)",
+ )
+ config = parser.parse_args(argv)
+
+ if not os.path.isdir(config.target_version):
+ print(
+ f"❌ {t('Target version directory not found')} :"
+ f" '{config.target_version}'"
+ )
+ return 2
+ try:
+ lst_finding = analyse(
+ config.database, config.target_version, config.filestore
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+
+ print(render(lst_finding, config.database, config.target_version))
+ if not lst_finding:
+ return 0
+
+ if config.diff:
+ for finding in lst_finding:
+ print(render_diff(finding))
+ print()
+ if config.tui:
+ from check_stale_scss_tui import run_tui
+
+ if not run_tui(lst_finding):
+ for finding in lst_finding:
+ print(render_diff(finding))
+ if config.apply:
+ odoo_dir = running_odoo_dir()
+ if not reset_supported(odoo_dir):
+ print(too_early_message(odoo_dir, config.database))
+ return 2
+ lst_path = backup_custom(lst_finding, config.database)
+ print(f"📦 {t('Saved before resetting')} :")
+ for path in lst_path:
+ print(f" {path}")
+ status, output = apply_reset(
+ lst_finding, config.database, config.config
+ )
+ print(output.strip()[-2000:])
+ if status:
+ print(f"❌ {t('Reset failed, nothing was changed.')}")
+ return 2
+ print(f"✅ -> {t('Reset done.')}")
+ return 0
+
+ # Aucun drapeau : on demande, plutôt que d'imprimer un rapport et de
+ # laisser retrouver soi-même les deux arguments de reset_asset. Mais
+ # seulement devant un terminal — dans un tube, une invite bloquerait
+ # l'appelant sans que personne ne voie la question.
+ if not (config.diff or config.tui or config.report_only) and can_ask():
+ if prompt(lst_finding, config.database, config.config):
+ return 0
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/check_stale_scss_tui.py b/script/odoo/migration/check_stale_scss_tui.py
new file mode 100755
index 0000000..cf234ee
--- /dev/null
+++ b/script/odoo/migration/check_stale_scss_tui.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Full-screen browsing of the customized SCSS a version bump would break.
+
+Two things have to be read before answering « reset it »: what the copy
+changed — the only thing resetting gives up — and which variables the target
+no longer defines, which is why it breaks at all. Space switches between them.
+
+The text report says the same, but a 1000-line diff in a terminal scrollback
+is not something anyone reads before deciding.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+from check_stale_scss import render_diff, reset_command # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 3; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#files { width: 42; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def render_missing(finding):
+ """Pourquoi ça casse : ce que la cible ne définit plus."""
+ lines = [
+ f"── id={finding['id']} {finding['url']} ──",
+ "",
+ f" {t('This copy uses variables that')} {finding['version_dir']}"
+ f" {t('no longer defines')} :",
+ "",
+ ]
+ for name in finding["missing"]:
+ lines.append(f" ${name}")
+ lines += [
+ "",
+ f" {t('The copy was written against an older version and frozen')}",
+ f" {t('there. The module has since renamed what it relies on.')}",
+ "",
+ f" {t('Resetting restores')} :",
+ f" {finding['module_path'] or t('(nothing: the target no longer ships this file)')}",
+ ]
+ return "\n".join(lines)
+
+
+def build_app(lst_finding):
+ """Build the application. Textual is imported here, not at module level.
+
+ The module stays importable — and therefore testable — on a machine
+ without Textual, which is also what lets the caller fall back to the text
+ report rather than fail.
+ """
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ class StaleScssApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [
+ ("q,escape", "quit", "Quit"),
+ ("space,tab", "toggle", "Diff / why"),
+ ("c", "copy", "Copy reset command"),
+ ]
+
+ def __init__(self, lst_finding):
+ super().__init__()
+ self.lst_finding = lst_finding
+ self.why = False
+ self.index = 0
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="files", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = "Customized SCSS at risk"
+ table = self.query_one("#files", DataTable)
+ table.add_columns("file", "+/-", "missing")
+ for finding in self.lst_finding:
+ table.add_row(
+ os.path.basename(finding["url"])[:30],
+ self._weight(finding),
+ str(len(finding["missing"])),
+ key=str(finding["id"]),
+ )
+ self._show()
+
+ def _weight(self, finding):
+ """Combien la copie s'écarte, pour trier d'un coup d'œil."""
+ if not finding["module_path"]:
+ return "—"
+ import difflib
+
+ diff = list(
+ difflib.unified_diff(
+ finding["module_content"].splitlines(),
+ finding["custom"].splitlines(),
+ lineterm="",
+ n=0,
+ )
+ )
+ plus = sum(
+ 1 for x in diff if x[:1] == "+" and not x.startswith("+++")
+ )
+ minus = sum(
+ 1 for x in diff if x[:1] == "-" and not x.startswith("---")
+ )
+ return f"+{plus}/-{minus}"
+
+ def _show(self):
+ if not self.lst_finding:
+ return
+ finding = self.lst_finding[self.index]
+ which = "why it breaks" if self.why else "what the copy changed"
+ self.query_one("#head", Static).update(
+ f"{finding['url']} · id={finding['id']}\n"
+ f"[{which}] — space to switch, c to copy the reset command"
+ )
+ render = render_missing if self.why else render_diff
+ self.query_one("#content", Static).update(render(finding))
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "files" and self.lst_finding:
+ self.index = event.cursor_row
+ self._show()
+
+ def action_toggle(self):
+ self.why = not self.why
+ self._show()
+
+ def action_copy(self):
+ """Mettre la commande de réparation dans le presse-papier.
+
+ Sans cela il faut la recopier à la main depuis un plein écran,
+ avec deux arguments dont une faute ne se voit pas.
+ """
+ finding = self.lst_finding[self.index]
+ command = reset_command([finding], finding["database"])
+ try:
+ self.copy_to_clipboard(command)
+ self.notify(t("Reset command copied."))
+ except Exception:
+ self.notify(command, timeout=20)
+
+ return StaleScssApp(lst_finding)
+
+
+def run_tui(lst_finding, run_app=True):
+ """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
+
+ Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
+ de terminal, ou Textual absent. Se taire ferait réafficher le rapport
+ texte à la place de l'écran demandé, sans rien qui distingue les deux.
+ """
+ if not lst_finding:
+ return False
+ if not sys.stdout.isatty():
+ print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
+ return False
+ try:
+ from script.todo import textual_setup
+ except Exception:
+ textual_setup = None
+ if textual_setup and not textual_setup.available():
+ if not textual_setup.in_venv():
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ print(f" {t('Run it with')} .venv.erplibre/bin/python3")
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(lst_finding)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ return False
+ if not run_app:
+ return app
+ app.run()
+ return True
diff --git a/script/odoo/migration/cow_drift.py b/script/odoo/migration/cow_drift.py
new file mode 100755
index 0000000..e96402c
--- /dev/null
+++ b/script/odoo/migration/cow_drift.py
@@ -0,0 +1,390 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Show what a website COW copy holds, and why the next version breaks it.
+
+Read-only. Answers the two questions someone asks before neutralizing a copy,
+and neither is answerable from the warning alone:
+
+**What do I lose?** The copy is compared with the module view it shadows. That
+is the customization someone made, and often it is three lines — an id, a
+container width — for which nobody would hold up a migration.
+
+**Why does it break?** The module declaration is shown in the current version
+and in the target. The pair is the whole explanation: a template declared
+without ``inherit_id`` is a standalone document, one declared with it must
+hold inheritance specs. A copy frozen in the first shape cannot be applied in
+the second, and Odoo stops on « cannot be located in parent view ».
+
+Nothing here writes. Neutralizing is ``neutralize_cow_views.py --apply``, and
+undoing it is ``--restore``.
+"""
+
+import argparse
+import difflib
+import json
+import os
+import re
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from check_cow_views import analyse, find_module_dir, t # noqa: E402
+
+# The declaration of a template spans a few lines; showing the opening tag and
+# what follows is enough to see its shape, and short enough to compare two
+# versions side by side without scrolling.
+DECL_LINES = 4
+
+
+def run_psql(database, sql):
+ """Run a statement and return stdout, raising on failure."""
+ result = subprocess.run(
+ ["psql", "-X", "-w", "-d", database, "-tAc", sql],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode:
+ raise RuntimeError(
+ f"Query failed on '{database}': {result.stderr.strip()}"
+ )
+ return result.stdout
+
+
+def unwrap_arch(value):
+ """The arch as text, whatever the column type.
+
+ ``arch_db`` is text up to 15.0 and jsonb from 16.0. The same unwrapping as
+ the sibling scripts, kept identical on purpose: a second reading of the
+ same column that differs on an edge case would report a drift nobody made.
+ """
+ text = (value or "").strip()
+ if text.startswith("{") and '"' in text:
+ try:
+ data = json.loads(text)
+ except ValueError:
+ return value or ""
+ if isinstance(data, dict) and data:
+ for lang in ("en_US", *sorted(data)):
+ if isinstance(data.get(lang), str):
+ return data[lang]
+ return value or ""
+
+
+def fetch_arch(database, view_id):
+ """The stored arch of one view."""
+ return unwrap_arch(
+ run_psql(
+ database,
+ f"SELECT arch_db::text FROM ir_ui_view WHERE id = {int(view_id)};",
+ )
+ )
+
+
+def fetch_module_view(database, key):
+ """(id, arch) of the module view a copy shadows, or (None, '').
+
+ The module view is the one carrying that key WITHOUT a website: that is
+ exactly the pairing Odoo itself makes, and the reason renaming a key is
+ enough to unpair a copy.
+ """
+ safe = key.replace("'", "''")
+ out = run_psql(
+ database,
+ "SELECT id, arch_db::text FROM ir_ui_view"
+ f" WHERE key = '{safe}' AND website_id IS NULL"
+ " ORDER BY id LIMIT 1;",
+ ).strip()
+ if not out:
+ return None, ""
+ view_id, _, arch = out.partition("|")
+ return int(view_id), unwrap_arch(arch)
+
+
+def declaration(version_dir, key):
+ """(path, snippet) of how the sources of one version declare this key.
+
+ Returns None when the module or the node is not found — a fact worth
+ showing as such, since « the module no longer declares it » is itself a
+ reason a copy breaks.
+ """
+ module_name, _, template_id = key.partition(".")
+ if not template_id:
+ return None
+ module_dir = find_module_dir(version_dir, module_name)
+ if module_dir is None:
+ return None
+ try:
+ from lxml import etree
+ except ImportError:
+ return None
+
+ pattern = os.path.join(module_dir, "**", "*.xml")
+ import glob
+
+ for path in sorted(glob.glob(pattern, recursive=True)):
+ try:
+ tree = etree.parse(path)
+ except etree.XMLSyntaxError:
+ continue
+ for element in tree.getroot().iter():
+ if element.get("id") != template_id:
+ continue
+ if element.tag not in ("template", "record"):
+ continue
+ # The source line, not a re-serialization: what the file actually
+ # says is what a reader will grep for.
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
+ lines = handle.read().splitlines()
+ start = max((element.sourceline or 1) - 1, 0)
+ return path, "\n".join(lines[start : start + DECL_LINES])
+ return None
+
+
+def database_version_dir(database):
+ """The odoo.0 directory matching what the DATABASE says it is.
+
+ Not `.odoo-version`: the checkout is switched to the TARGET before this
+ runs, so reading it would compare the target with itself and show the same
+ declaration twice — which is what it did until this was measured on a real
+ migration. The database, at that moment, is still on the previous version
+ and says so in ir_module_module.
+ """
+ try:
+ out = run_psql(
+ database,
+ "SELECT latest_version FROM ir_module_module"
+ " WHERE name = 'base';",
+ ).strip()
+ except RuntimeError:
+ return None
+ if not out:
+ return None
+ parts = out.split(".")
+ if len(parts) < 2:
+ return None
+ return f"odoo{parts[0]}.{parts[1]}"
+
+
+def collect(database, target_version, current_version=None):
+ """Everything needed to judge each at-risk copy. No writes."""
+ current_version = current_version or database_version_dir(database)
+ lst_at_risk, _, _ = analyse(database, target_version)
+ lst_finding = []
+ for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
+ module_id, module_arch = fetch_module_view(database, key)
+ lst_finding.append(
+ {
+ "id": view_id,
+ "key": key,
+ "mode": mode,
+ "target_mode": target_mode,
+ "website_id": website_id,
+ "reason": reason,
+ "copy_arch": fetch_arch(database, view_id),
+ "module_id": module_id,
+ "module_arch": module_arch,
+ "decl_current": (
+ declaration(current_version, key)
+ if current_version
+ else None
+ ),
+ "decl_target": declaration(target_version, key),
+ "current_version": current_version,
+ "target_version": target_version,
+ }
+ )
+ return lst_finding
+
+
+def render_diff(finding):
+ """What the copy changed, compared with the module view it shadows."""
+ lines = [
+ f"── id={finding['id']} {finding['key']}"
+ f" (website={finding['website_id']}) ──",
+ "",
+ ]
+ if finding["module_id"] is None:
+ lines += [
+ f" {t('No module view carries this key, so there is nothing to')}",
+ f" {t('compare: this copy is a page made in the website editor.')}",
+ ]
+ return "\n".join(lines)
+
+ left = finding["module_arch"].splitlines()
+ right = finding["copy_arch"].splitlines()
+ diff = [
+ line
+ for line in difflib.unified_diff(
+ left,
+ right,
+ fromfile=f"module id={finding['module_id']}",
+ tofile=f"copy id={finding['id']}",
+ lineterm="",
+ n=1,
+ )
+ ]
+ if len(diff) <= 2:
+ lines.append(f" {t('The copy is identical to the module view.')}")
+ return "\n".join(lines)
+ lines += [f" {line}" for line in diff]
+ n_plus = sum(
+ 1 for x in diff if x.startswith("+") and not x.startswith("+++")
+ )
+ n_minus = sum(
+ 1 for x in diff if x.startswith("-") and not x.startswith("---")
+ )
+ lines += [
+ "",
+ f" {n_plus} {t('line(s) added')}, {n_minus} {t('removed')} —"
+ f" {t('this is what neutralizing gives up.')}",
+ ]
+ return "\n".join(lines)
+
+
+def render_shape(finding):
+ """Why it breaks: the declaration in each version, side by side."""
+ lines = [
+ f"── id={finding['id']} {finding['key']} ──",
+ "",
+ f" {t(finding['reason'])}",
+ "",
+ ]
+ for label, decl in (
+ (finding["current_version"], finding["decl_current"]),
+ (finding["target_version"], finding["decl_target"]),
+ ):
+ lines.append(f" ")
+ if decl is None:
+ lines += [
+ f" ({t('the module no longer declares this template')})",
+ "",
+ ]
+ continue
+ path, snippet = decl
+ lines.append(f" ")
+ lines += [f" {line}" for line in snippet.splitlines()]
+ shape = (
+ t("inheritance specs (needs inherit_id)")
+ if re.search(r"inherit_id\s*=", snippet)
+ else t("a standalone template")
+ )
+ lines += [f" -> {shape}", ""]
+ lines += [
+ f" {t('Odoo changing the shape of its own template is NOT the')}",
+ f" {t('problem: on a database without a copy, the module upgrade')}",
+ f" {t('rewrites the view and nothing breaks. It breaks here because')}",
+ f" {t('a COPY exists and froze the old shape — the copy follows the')}",
+ f" {t('module and becomes an extension, while still holding a')}",
+ f" {t('standalone template. Odoo then applies that template as an')}",
+ f" {t('inheritance spec and stops on « cannot be located in parent')}",
+ f" {t('view ».')}",
+ "",
+ ]
+ # Sans ceci, cette vue se lit comme « du code Odoo qui change », et l'on
+ # se demande pourquoi c'est notre affaire. Ce qui la rend nôtre est ce que
+ # la copie contient — la seule chose que neutraliser ferait perdre.
+ lines += _what_the_copy_holds(finding)
+ return "\n".join(lines)
+
+
+def _what_the_copy_holds(finding):
+ """Ce que la copie porte en propre, dit en une ou deux lignes.
+
+ Relie les deux moitiés de la question : le mécanisme explique POURQUOI ça
+ casse, ceci dit CE QU'IL EN COÛTE. Une copie identique à sa jumelle ne
+ coûte rien, et le savoir change la décision.
+ """
+ if finding["module_id"] is None:
+ return [
+ f" {t('This copy has no module view of that name: it is a page')}",
+ f" {t('made in the website editor, and nothing holds its content.')}",
+ ]
+ left = finding["module_arch"].splitlines()
+ right = finding["copy_arch"].splitlines()
+ diff = [
+ line
+ for line in difflib.unified_diff(left, right, lineterm="", n=0)
+ if line[:1] in "+-" and not line.startswith(("+++", "---"))
+ ]
+ if not diff:
+ return [
+ f" {t('This copy is IDENTICAL to the module view it shadows: it')}",
+ f" {t('holds no customization, so neutralizing it loses nothing.')}",
+ ]
+ n_plus = sum(1 for x in diff if x.startswith("+"))
+ n_minus = sum(1 for x in diff if x.startswith("-"))
+ return [
+ f" {t('This copy differs from the module view by')}"
+ f" +{n_plus}/-{n_minus} {t('line(s):')}",
+ f" {t('that is the customization, and all neutralizing gives up.')}",
+ f" {t('Run without --shape to read it.')}",
+ ]
+
+
+def render_all(lst_finding, shape=False):
+ """The whole report, one block per finding."""
+ if not lst_finding:
+ return f"✅ {t('No website COW view is at risk.')}\n"
+ render = render_shape if shape else render_diff
+ return "\n\n".join(render(f) for f in lst_finding) + "\n"
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Show what each at-risk website COW copy holds, and why the next"
+ " version breaks it (read-only)."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument(
+ "-t",
+ "--target_version",
+ required=True,
+ help="target Odoo source directory, e.g. odoo13.0",
+ )
+ parser.add_argument(
+ "--current",
+ default=None,
+ help="current Odoo source directory (default: from .odoo-version)",
+ )
+ parser.add_argument(
+ "--shape",
+ action="store_true",
+ help="show the declarations instead of the customization diff",
+ )
+ parser.add_argument(
+ "--tui",
+ action="store_true",
+ help="browse full screen, switching between the two views",
+ )
+ config = parser.parse_args(argv)
+
+ if not os.path.isdir(config.target_version):
+ print(
+ f"❌ {t('Target version directory not found')} :"
+ f" '{config.target_version}'"
+ )
+ return 2
+ try:
+ lst_finding = collect(
+ config.database, config.target_version, config.current
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+
+ if config.tui and lst_finding:
+ from cow_drift_tui import run_tui
+
+ if run_tui(lst_finding):
+ return 1
+ print(render_all(lst_finding, shape=config.shape))
+ return 1 if lst_finding else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/cow_drift_tui.py b/script/odoo/migration/cow_drift_tui.py
new file mode 100755
index 0000000..8bdec25
--- /dev/null
+++ b/script/odoo/migration/cow_drift_tui.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Full-screen browsing of the at-risk website COW copies.
+
+Read-only, without exception: this screen exists to decide, and deciding
+supposes having looked. Neutralizing stays a separate, explicit command.
+
+Two views of the same copy, one key apart
+-----------------------------------------
+« What do I lose » and « why does it break » are different questions with
+different answers, and putting them side by side would halve the width of
+each on a screen that already shows XML. They share the pane instead, and
+``space`` switches — the header always says which one is showing, because a
+diff and a declaration look alike at a glance.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 3; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#views { width: 38; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def build_app(lst_finding):
+ """Build the application. Textual is imported here, not at module level.
+
+ The module stays importable — and therefore testable — on a machine
+ without Textual, which is also what lets the caller fall back to the text
+ report rather than fail.
+ """
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ from cow_drift import render_diff, render_shape
+
+ class DriftApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [
+ ("q,escape", "quit", "Quit"),
+ ("space,tab", "toggle", "Diff / declarations"),
+ ("c", "copy", "Copy"),
+ ]
+
+ def __init__(self, lst_finding):
+ super().__init__()
+ self.lst_finding = lst_finding
+ self.shape = False
+ self.index = 0
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="views", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = "Website COW copies at risk"
+ table = self.query_one("#views", DataTable)
+ table.add_columns("copy", "+/-")
+ for finding in self.lst_finding:
+ table.add_row(
+ (finding["key"] or str(finding["id"]))[:28],
+ self._weight(finding),
+ key=str(finding["id"]),
+ )
+ self._show()
+
+ def _weight(self, finding):
+ """How much the copy diverges, so the list can be triaged."""
+ if finding["module_id"] is None:
+ return "—"
+ import difflib
+
+ diff = list(
+ difflib.unified_diff(
+ finding["module_arch"].splitlines(),
+ finding["copy_arch"].splitlines(),
+ lineterm="",
+ n=0,
+ )
+ )
+ plus = sum(
+ 1
+ for x in diff
+ if x.startswith("+") and not x.startswith("+++")
+ )
+ minus = sum(
+ 1
+ for x in diff
+ if x.startswith("-") and not x.startswith("---")
+ )
+ return f"+{plus}/-{minus}"
+
+ def _show(self):
+ if not self.lst_finding:
+ return
+ finding = self.lst_finding[self.index]
+ which = "declarations" if self.shape else "what the copy changed"
+ self.query_one("#head", Static).update(
+ f"{finding['key']} · id={finding['id']}"
+ f" · website={finding['website_id']}\n"
+ f"[{which}] — space to switch"
+ )
+ render = render_shape if self.shape else render_diff
+ self.query_one("#content", Static).update(render(finding))
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "views" and self.lst_finding:
+ self.index = event.cursor_row
+ self._show()
+
+ def action_toggle(self):
+ self.shape = not self.shape
+ self._show()
+
+ def action_copy(self):
+ if not self.lst_finding:
+ return
+ from cow_drift import render_diff, render_shape
+
+ render = render_shape if self.shape else render_diff
+ # Truncated keeping the END: that is where the added lines are,
+ # and what someone is most often after.
+ self.copy_to_clipboard(
+ render(self.lst_finding[self.index])[-100_000:]
+ )
+ self.notify("Copied.")
+
+ return DriftApp(lst_finding)
+
+
+def run_tui(lst_finding, run_app=True):
+ """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
+
+ Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
+ de terminal, ou Textual absent. Se taire faisait réafficher le rapport
+ texte à la place de l'écran demandé — la même sortie que la touche
+ précédente, sans rien qui distingue les deux.
+
+ Textual n'est installé que dans `.venv.erplibre`. Lancer ce script
+ directement, maintenant qu'il porte le bit d'exécution, prend le python
+ du système : le shebang ne connaît pas le venv. C'est le chemin qui rend
+ ce message nécessaire.
+ """
+ if not lst_finding:
+ return False
+ if not sys.stdout.isatty():
+ print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
+ return False
+ try:
+ from script.todo import textual_setup
+ except Exception:
+ textual_setup = None
+ if textual_setup and not textual_setup.available():
+ # Le conseil AVANT la proposition d'installer : Textual est déjà là,
+ # dans le venv. Proposer de l'installer une seconde fois sous
+ # « --user » répond à côté de la question.
+ if not textual_setup.in_venv():
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ print(f" {t('Run it with')} .venv.erplibre/bin/python3")
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(lst_finding)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ print(f" {t('Run it with')} .venv.erplibre/bin/python3")
+ return False
+ if not run_app:
+ return app
+ app.run()
+ return True
diff --git a/script/odoo/migration/database_cleanup.py b/script/odoo/migration/database_cleanup.py
new file mode 100755
index 0000000..7cac3bd
--- /dev/null
+++ b/script/odoo/migration/database_cleanup.py
@@ -0,0 +1,606 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Run the OCA « Database cleanup » purges, in order, until nothing moves.
+
+Why a tool rather than the eight screens
+----------------------------------------
+A migration leaves obsolete models, columns, tables, data and menus behind.
+The `database_cleanup` module offers one wizard per kind, and they are not
+independent: purging a model frees the columns that referenced it, purging a
+table frees the data rows pointing at it. One pass is never enough — the
+order matters, and so does going round again.
+
+Errors are expected, and are not a reason to stop
+-------------------------------------------------
+Some entries cannot be purged: a foreign key still holds, a record is
+protected, a module refuses. Purging the whole list at once loses everything
+to the first failure, so each entry is purged INSIDE ITS OWN SAVEPOINT: a
+refusal rolls back that entry alone and the rest of the pass continues. What
+one pass could not repair, the next may — once its neighbours are gone.
+
+The loop stops when a full pass repairs nothing new. Whatever remains is
+reported as a warning, not as a failure: a database can carry leftovers that
+nothing can remove, and refusing to move on would help no one.
+
+Exit codes: 0 nothing left, 1 leftovers remain, 2 the tool failed.
+"""
+
+import argparse
+import json
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+# L'ordre demandé, et il compte : purger un modèle libère les colonnes qui le
+# référençaient, purger une table libère les données qui la visaient. Les
+# index viennent après les purges — inutile d'indexer ce qu'on va retirer.
+#
+# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property par une
+# colonne jsonb. On le garde dans la liste et on le saute quand il manque,
+# plutôt que d'échouer sur une version où il n'a plus lieu d'être.
+ORDER = [
+ ("models", "cleanup.purge.wizard.model"),
+ ("modules", "cleanup.purge.wizard.module"),
+ ("columns", "cleanup.purge.wizard.column"),
+ ("tables", "cleanup.purge.wizard.table"),
+ ("data", "cleanup.purge.wizard.data"),
+ ("menus", "cleanup.purge.wizard.menu"),
+ ("indexes", "cleanup.create_indexes.wizard"),
+ ("properties", "cleanup.purge.wizard.property"),
+]
+
+LABEL = {
+ "models": "obsolete models",
+ "modules": "obsolete modules",
+ "columns": "obsolete columns",
+ "tables": "obsolete tables",
+ "data": "obsolete data entries",
+ "menus": "obsolete menu entries",
+ "indexes": "missing indexes",
+ "properties": "obsolete properties",
+}
+
+STEP = "ERPLIBRE_CLEANUP_STEP"
+START = "ERPLIBRE_CLEANUP_START"
+END = "ERPLIBRE_CLEANUP_END"
+
+SHELL_SCRIPT = """
+import json
+
+try:
+ from odoo.exceptions import UserError
+except Exception:
+ class UserError(Exception):
+ pass
+
+ORDER = %(order)r
+MAX_ROUND = %(max_round)d
+DRY_RUN = %(dry_run)s
+
+t_all = "%(all_label)s"
+report = {"rounds": [], "missing": [], "failed": []}
+
+
+def note(label, name, exc):
+ report["failed"].append([label, name, str(exc)[:200]])
+
+
+def step(label, name, index=0, total=0):
+ # Dire ce qu'on fait PENDANT qu'on le fait. Sans cela l'outil se taisait
+ # jusqu'au rapport final : mesuré, dix-sept minutes de silence complet
+ # sur une base de 5984 modules, impossible à distinguer d'un blocage.
+ detail = f" {index}/{total}" if total else ""
+ print(f"%(step)s {label}{detail} {name}", flush=True)
+
+
+def recover():
+ # Rendre la transaction utilisable, quoi qu'il vienne de se passer.
+ #
+ # PAS de point de reprise ici, et c'est mesure : purge_modules.find()
+ # purge lui-meme (purge_modules.py:91), et purge_columns appelle
+ # cr.commit() (purge_columns.py:57). Or un COMMIT DETRUIT tous les
+ # points de reprise : le notre disparaissait sous nos pieds, d'ou
+ # « savepoint ... does not exist », puis tout ce qui suivait mourait
+ # sur une transaction avortee. Un rollback franc remet les compteurs
+ # a zero ; ce qu'on perd est borne, puisqu'on valide apres chaque
+ # entree reussie.
+ try:
+ env.cr.rollback()
+ except Exception:
+ pass
+
+
+try:
+ for index in range(MAX_ROUND):
+ this_round = []
+ purged_this_round = 0
+ step("pass", str(index + 1), index + 1, MAX_ROUND)
+ for label, model in ORDER:
+ if model not in env:
+ if label not in report["missing"]:
+ report["missing"].append(label)
+ continue
+ ok = 0
+ errors = []
+ would = []
+ try:
+ wizard = env[model].create({})
+ # Les noms sont matérialisés TOUT DE SUITE : les relire plus
+ # tard relancerait une requête, et c'est là que le script
+ # mourait quand la transaction avait été avortée entre-temps.
+ todo = [
+ (line, line.name or str(line.id))
+ for line in wizard.purge_line_ids
+ ]
+ except UserError:
+ # « No orphaned models found » : le module signale le VIDE
+ # en levant. Le compter comme un échec faisait passer une
+ # base saine pour cassée.
+ recover()
+ this_round.append({"kind": label, "purged": 0,
+ "errors": [], "would": []})
+ continue
+ except Exception as exc:
+ recover()
+ note(label, "-", exc)
+ continue
+ # find() peut avoir purgé de lui-même : on garde ce qu'il a fait.
+ if not DRY_RUN:
+ try:
+ env.cr.commit()
+ except Exception:
+ recover()
+ if DRY_RUN:
+ would = [name for _line, name in todo]
+ elif todo:
+ # LE LOT D'ABORD, et ce n'est pas une optimisation de
+ # confort. `purge()` d'un module appelle
+ # button_immediate_uninstall(), qui RECHARGE LE REGISTRE
+ # ENTIER — 5984 modules à relire. En purgeant ligne par
+ # ligne j'en faisais un rechargement PAR MODULE : mesuré,
+ # dix secondes chacun, dix-sept minutes pour neuf modules.
+ # Le module OCA purge le lot en un seul appel, donc un seul
+ # rechargement.
+ #
+ # L'isolement ligne à ligne garde tout son sens — un refus
+ # ne doit pas emporter la catégorie — mais il ne coûte que
+ # lorsqu'il sert vraiment, c'est-à-dire après un échec.
+ step(label, t_all, 0, len(todo))
+ try:
+ wizard.purge_line_ids.purge()
+ env.cr.commit()
+ ok = len(todo)
+ except Exception:
+ recover()
+ for index, (line, name) in enumerate(todo, start=1):
+ step(label, name, index, len(todo))
+ try:
+ line.purge()
+ # Valider ENTRÉE PAR ENTRÉE : un refus plus loin
+ # ne doit pas emporter ce qui vient d'être
+ # réparé.
+ env.cr.commit()
+ ok += 1
+ except Exception as exc:
+ recover()
+ errors.append([name, str(exc)[:160]])
+ purged_this_round += ok
+ this_round.append({"kind": label, "purged": ok,
+ "errors": errors, "would": would})
+ report["rounds"].append(this_round)
+ # On s'arrête quand une passe ENTIÈRE n'a plus rien réparé : ce qui
+ # résistait au tour d'avant résistera encore. En simulation, une
+ # seule passe suffit — rien ne change, donc rien ne se libère.
+ if purged_this_round == 0 or DRY_RUN:
+ break
+except Exception as exc:
+ # Le rapport de CE QUI A ÉTÉ FAIT vaut plus que la trace de ce qui a
+ # cassé : sans lui, on ne sait même pas si la base a été touchée.
+ note("*", "fatal", exc)
+
+if DRY_RUN:
+ # Une simulation qui écrit n'est pas une simulation. Et celle-ci
+ # écrivait : purge_modules.find() purge de lui-meme, avant meme
+ # qu'on ait rien decide. On defait tout ce que la lecture a
+ # provoque — c'est possible ici, justement parce qu'on n'a valide
+ # aucune entree.
+ recover()
+
+print("%(start)s")
+print(json.dumps(report))
+print("%(end)s")
+"""
+
+
+def build_script(max_round, dry_run):
+ return SHELL_SCRIPT % {
+ "order": ORDER,
+ "max_round": max_round,
+ "dry_run": "True" if dry_run else "False",
+ "start": START,
+ "end": END,
+ "step": STEP,
+ "all_label": "the whole batch",
+ }
+
+
+def checkout_version():
+ """La version d'Odoo que le checkout servira, d'après .odoo-version."""
+ try:
+ with open(".odoo-version", "r", encoding="utf-8") as handle:
+ return handle.read().strip()
+ except OSError:
+ return None
+
+
+def database_version(database):
+ """La version que la BASE dit être la sienne, via le module `base`."""
+ env = os.environ.copy()
+ env["PGOPTIONS"] = "-c default_transaction_read_only=on"
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ [
+ "psql",
+ "-X",
+ "-w",
+ "-d",
+ database,
+ "-tAc",
+ "SELECT latest_version FROM ir_module_module WHERE name='base';",
+ ],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ return None
+ parts = done.stdout.strip().split(".")
+ return ".".join(parts[:2]) if len(parts) >= 2 else None
+
+
+def require_matching_version(database):
+ """Refuser d'ouvrir une base avec un Odoo d'une autre version.
+
+ Un Odoo plus ancien qui charge une base plus récente ne se contente pas
+ d'échouer : il ÉCRIT en chemin — mesuré, un shell en 14.0 lancé sur une
+ base 17.0 est parti réécrire ir_model avant de mourir sur un jsonb qu'il
+ ne connaissait pas. Le checkout suit la migration, et rien ne garantit
+ qu'il soit resté sur la version de la base qu'on veut nettoyer.
+ """
+ checkout = checkout_version()
+ database_side = database_version(database)
+ if not checkout or not database_side:
+ return None # Rien pour trancher : ne pas bloquer sur une supposition.
+ if checkout != database_side:
+ return (
+ f"{t('The checkout is on Odoo')} {checkout}"
+ f" {t('and the database on')} {database_side}."
+ f"\n {t('Opening it with the wrong version writes to it before')}"
+ f" {t('failing. Switch the checkout first.')}"
+ )
+ return None
+
+
+def module_state(database, module="database_cleanup"):
+ """L'état du module dans cette base, ou None si on ne peut pas lire."""
+ env = os.environ.copy()
+ env["PGOPTIONS"] = "-c default_transaction_read_only=on"
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ [
+ "psql",
+ "-X",
+ "-w",
+ "-d",
+ database,
+ "-tAc",
+ f"SELECT state FROM ir_module_module WHERE name='{module}';",
+ ],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ return None
+ return done.stdout.strip() or None
+
+
+def install_module(database, module="database_cleanup", timeout=1800):
+ """Poser le module avant de s'en servir.
+
+ Sans lui, aucun assistant n'existe et l'outil rend « rien à faire » sur
+ une base qui en aurait eu besoin — un silence qu'on prend pour un
+ succès. La migration l'installait plus tard, à l'étape 3 ; l'attendre
+ revenait à nettoyer trop tard.
+ """
+ done = subprocess.run(
+ ["./script/addons/install_addons.sh", database, module],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ return done.returncode, done.stdout + done.stderr
+
+
+def kill_group(process):
+ """Tuer le GROUPE : « ./odoo_bin.sh » est un script bash.
+
+ Un terminate() sur lui tue le script et laisse odoo-bin vivant, sur la
+ base, avec ses verrous. La leçon a déjà été payée une fois par six
+ serveurs orphelins.
+ """
+ try:
+ group = os.getpgid(process.pid)
+ except OSError:
+ group = None
+ for signal_number in (signal.SIGTERM, signal.SIGKILL):
+ if group is None:
+ break
+ try:
+ os.killpg(group, signal_number)
+ except OSError:
+ break
+ try:
+ process.wait(timeout=20)
+ return
+ except subprocess.TimeoutExpired:
+ continue
+
+
+def run_shell(database, config_path, script, timeout=3600, echo=None):
+ """Pousser le script dans « odoo-bin shell » et rendre son rapport.
+
+ On LIT au fil de l'eau plutôt que de tout capturer : ce nettoyage dure
+ des minutes — dix-sept, mesurées, sur une base de 5984 modules — et la
+ version précédente n'affichait rien avant la fin. Un travail qui avance
+ et un blocage se ressemblent alors trait pour trait, et c'est ainsi
+ qu'on interrompt une réparation à moitié faite.
+
+ Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne
+ lit comme rapport que ce qui est entre elles. Leur absence est une
+ erreur franche, pas un rapport vide qu'on prendrait pour « rien à
+ faire ».
+ """
+ process = subprocess.Popen(
+ [
+ "./odoo_bin.sh",
+ "shell",
+ "-c",
+ config_path,
+ "-d",
+ database,
+ "--log-level=warn",
+ ],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ start_new_session=True,
+ )
+ # Le délai ne peut pas vivre dans la boucle de lecture : celle-ci BLOQUE
+ # tant qu'aucune ligne n'arrive, et c'est exactement le cas qu'il faut
+ # couvrir. Un minuteur à part, donc.
+ expire = {"fired": False}
+
+ def couper():
+ expire["fired"] = True
+ kill_group(process)
+
+ minuteur = threading.Timer(timeout, couper)
+ minuteur.daemon = True
+ minuteur.start()
+ lst_line = []
+ try:
+ process.stdin.write(script)
+ process.stdin.close()
+ for line in process.stdout:
+ lst_line.append(line)
+ if echo and line.startswith(STEP):
+ echo(line[len(STEP) :].strip())
+ process.wait()
+ finally:
+ minuteur.cancel()
+ if process.poll() is None:
+ kill_group(process)
+ output = "".join(lst_line)
+ if expire["fired"]:
+ raise RuntimeError(
+ f"{t('The cleanup was still running after')} {timeout}"
+ f" {t('seconds and was stopped.')}"
+ )
+ if START not in output or END not in output:
+ raise RuntimeError(
+ f"{t('The cleanup produced no report.')}\n{output.strip()[-1500:]}"
+ )
+ body = output.split(START, 1)[1].split(END, 1)[0].strip()
+ try:
+ return json.loads(body)
+ except ValueError as exc:
+ raise RuntimeError(f"{t('Unreadable report')} : {exc}")
+
+
+def make_echo(depart):
+ """Relayer la progression du script poussé, horodatée.
+
+ Le temps écoulé n'est pas décoratif : c'est lui qui distingue « ça
+ avance lentement » de « ça ne bouge plus », et c'est précisément la
+ question qu'on se pose devant un écran muet.
+ """
+
+ def echo(ligne):
+ morceaux = ligne.split(" ", 1)
+ etiquette = morceaux[0]
+ reste = morceaux[1] if len(morceaux) > 1 else ""
+ secondes = int(time.monotonic() - depart)
+ if etiquette == "pass":
+ print(f" [{secondes:>4}s] {t('pass')} {reste}")
+ else:
+ print(
+ f" [{secondes:>4}s] {t(LABEL.get(etiquette, etiquette))}"
+ f" — {t(reste) if reste == 'the whole batch' else reste}"
+ )
+
+ return echo
+
+
+def leftovers(report):
+ """[(kind, name, message)] de ce que la DERNIÈRE passe n'a pas pu purger."""
+ if not report.get("rounds"):
+ return []
+ lst = []
+ for entry in report["rounds"][-1]:
+ for name, message in entry.get("errors", []):
+ lst.append((entry["kind"], name, message))
+ for kind, name, message in report.get("failed", []):
+ lst.append((kind, name, message))
+ return lst
+
+
+def render(report, database):
+ lines = [f"🧹 {t('Database cleanup on')} '{database}'"]
+ # La simulation ne répare rien : la présenter comme un échec ferait
+ # croire à 585 refus là où il n'y a que 585 candidats.
+ lst_would = [
+ (entry["kind"], name)
+ for this_round in report.get("rounds", [])
+ for entry in this_round
+ for name in entry.get("would", [])
+ ]
+ if lst_would:
+ lines.append(
+ f"ℹ {len(lst_would)} {t('entries would be purged')}"
+ f" ({t('nothing was changed')}) :"
+ )
+ by_kind = {}
+ for kind, _name in lst_would:
+ by_kind[kind] = by_kind.get(kind, 0) + 1
+ for kind, count in by_kind.items():
+ lines.append(f" - {t(LABEL.get(kind, kind))} : {count}")
+ # Les échecs comptent AUSSI en simulation : une catégorie qui ne
+ # s'ouvre même pas est une information, pas un silence.
+ for kind, name, message in report.get("failed", []):
+ lines.append(f" ⚠ [{kind}] {name} : {message[:90]}")
+ for kind in report.get("missing", []):
+ lines.append(
+ f" ℹ {t(LABEL.get(kind, kind))} :"
+ f" {t('no such wizard in this version, skipped.')}"
+ )
+ return "\n".join(lines) + "\n"
+ total = 0
+ for index, this_round in enumerate(report.get("rounds", []), start=1):
+ purged = sum(entry["purged"] for entry in this_round)
+ total += purged
+ detail = ", ".join(
+ f"{t(LABEL[entry['kind']])} {entry['purged']}"
+ for entry in this_round
+ if entry["purged"]
+ )
+ lines.append(
+ f" {t('pass')} {index} : {purged} {t('purged')}"
+ + (f" — {detail}" if detail else "")
+ )
+ for kind in report.get("missing", []):
+ lines.append(
+ f" ℹ {t(LABEL.get(kind, kind))} :"
+ f" {t('no such wizard in this version, skipped.')}"
+ )
+ lst_left = leftovers(report)
+ if not lst_left:
+ lines.append(f"✅ -> {total} {t('entries purged, nothing left.')}")
+ return "\n".join(lines) + "\n"
+ lines.append(
+ f"⚠️ {total} {t('purged;')} {len(lst_left)}"
+ f" {t('could not be, and are left as they are')} :"
+ )
+ for kind, name, message in lst_left[:20]:
+ lines.append(f" - [{kind}] {name} : {message[:90]}")
+ if len(lst_left) > 20:
+ lines.append(f" … {len(lst_left) - 20} {t('more')}")
+ lines.append(
+ f" {t('A database can carry leftovers nothing can remove.')}"
+ f" {t('This is a warning, not a failure.')}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Run the OCA database_cleanup purges in order, repeating until"
+ " a full pass repairs nothing new."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", default="./config.conf")
+ parser.add_argument(
+ "--max-round",
+ type=int,
+ default=10,
+ help="how many full passes at most (default 10)",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="list what would be purged, purge nothing",
+ )
+ parser.add_argument("--timeout", type=int, default=3600)
+ config = parser.parse_args(argv)
+
+ mismatch = require_matching_version(config.database)
+ if mismatch:
+ print(f"⛔ {mismatch}")
+ return 2
+
+ state = module_state(config.database)
+ if state != "installed":
+ print(
+ f"⧖ {t('database_cleanup is not installed on this base;')}"
+ f" {t('installing it first.')}"
+ )
+ code, output = install_module(config.database)
+ if code:
+ print(output.strip()[-1500:])
+ print(f"❌ {t('Could not install database_cleanup.')}")
+ return 2
+
+ print(f"⧖ {t('Cleaning')} '{config.database}'…")
+ print(
+ f" {t('Purging modules reloads the whole registry: on a big')}"
+ f" {t('addons path this takes minutes, not seconds.')}"
+ )
+ depart = time.monotonic()
+ try:
+ report = run_shell(
+ config.database,
+ config.config,
+ build_script(config.max_round, config.dry_run),
+ timeout=config.timeout,
+ echo=make_echo(depart),
+ )
+ except (RuntimeError, subprocess.SubprocessError, OSError) as exc:
+ print(f"❌ {exc}")
+ return 2
+ print(render(report, config.database))
+ print(f"⌛ {t('Took')} {int(time.monotonic() - depart)} {t('seconds.')}")
+ return 1 if leftovers(report) else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/dms_access_repair.py b/script/odoo/migration/dms_access_repair.py
new file mode 100755
index 0000000..1407328
--- /dev/null
+++ b/script/odoo/migration/dms_access_repair.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Rendre visibles les documents DMS qu'une migration a rendus muets.
+
+Le symptôme : après le palier 13, les documents DMS « ont disparu ». Ils
+n'ont pas disparu. Mesuré sur une base migrée : 69 fichiers, 16 dossiers,
+23 Mo de `content_binary`, présents à l'identique de la 12 à la 18. Rien
+n'a été perdu.
+
+Ce qui a changé, c'est le MODÈLE DE SÉCURITÉ. En 12, MuK DMS ne filtrait
+que sur la société :
+
+ ['|', ('company','=',False), ('company','child_of',[...])]
+
+OCA DMS, à partir de la 13, ajoute des règles GLOBALES sur une permission
+calculée :
+
+ [('permission_read', '=', user.id)]
+
+`_search_permission_read` n'accorde l'accès que par deux portes :
+
+1. Une `dms.access.group` dont l'utilisateur est membre, reliée au
+ dossier — la migration n'en a créé AUCUNE, parce que MuK n'en avait
+ aucune à convertir.
+2. L'héritage depuis l'enregistrement lié, qui exige
+ `storage.save_type = 'attachment'` — or les stockages migrés sont en
+ `database`.
+
+Les deux portes fermées, la règle globale filtre TOUT. Pas un dossier,
+pas un fichier, pour personne — l'administrateur compris, car `admin`
+n'est pas le super-utilisateur au sens d'Odoo.
+
+Ce que fait la réparation
+-------------------------
+Une seule `dms.access.group`, adossée au groupe Odoo `dms.group_dms_user`
+plutôt qu'à une liste d'utilisateurs — ainsi l'appartenance continue de
+suivre le groupe, comme avant la migration —, reliée aux dossiers
+RACINES. Les enfants héritent : ils portent tous
+`inherit_group_ids = true`.
+
+Ce qu'elle ne fait pas : inventer une granularité que MuK n'avait pas.
+L'ancien modèle ne connaissait pas d'ACL par dossier ; en fabriquer une
+ici serait décider à la place de l'utilisateur, sur des données qu'il
+n'a jamais saisies.
+
+Codes de sortie : 0 rien à faire, 1 il y a à faire (ou c'est fait), 2 échec.
+"""
+
+import argparse
+import os
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+from script.odoo.migration import database_cleanup # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et
+# elles ne servent qu'à isoler le rapport des journaux d'Odoo. En
+# inventer une seconde paire aurait créé un contrat parallèle à tenir.
+DEBUT = database_cleanup.START
+FIN = database_cleanup.END
+NOM_GROUPE = "Migration MuK → DMS"
+
+# Le script poussé dans `odoo-bin shell`. Il compte AVANT et APRÈS avec un
+# vrai utilisateur : c'est la seule preuve qui vaille — la règle globale
+# ne s'applique pas au super-utilisateur, donc un compte fait en sudo
+# dirait « tout va bien » alors que personne ne voit rien.
+SCRIPT = """
+import json
+DRY = {dry}
+NOM = {nom!r}
+rapport = {{"dry_run": DRY}}
+try:
+ # Sans DMS, il n'y a rien à réparer et ce n'est pas une erreur : cet
+ # outil tourne à chaque migration vers la 13, et la plupart des bases
+ # n'ont jamais eu de DMS. Lever ici les ferait toutes échouer.
+ if "dms.file" not in env:
+ rapport["absent"] = True
+ print({debut!r})
+ print(json.dumps(rapport))
+ print({fin!r})
+ raise SystemExit(0)
+ Dossier = env["dms.directory"].sudo()
+ Fichier = env["dms.file"].sudo()
+ Groupe = env["dms.access.group"].sudo()
+ rapport["files"] = Fichier.search_count([])
+ rapport["directories"] = Dossier.search_count([])
+ rapport["access_groups_before"] = Groupe.search_count([])
+
+ # Un vrai utilisateur, pas le super-utilisateur : la règle globale ne
+ # s'applique qu'à lui, et c'est justement ce qu'on mesure.
+ membres = env.ref("dms.group_dms_user").users.filtered(
+ lambda u: u.id != 1 and u.active
+ )
+ temoin = membres[:1]
+ rapport["witness"] = temoin.login if temoin else None
+
+ def visible():
+ if not temoin:
+ return None
+ return {{
+ "directories": Dossier.with_user(temoin).search_count([]),
+ "files": Fichier.with_user(temoin).search_count([]),
+ }}
+
+ rapport["before"] = visible()
+ racines = Dossier.search([("is_root_directory", "=", True)])
+ rapport["roots"] = racines.mapped("name")
+ deja = Groupe.search([("name", "=", NOM)])
+ rapport["already_repaired"] = bool(deja)
+
+ if not DRY and not deja and racines:
+ Groupe.create({{
+ "name": NOM,
+ "perm_create": True,
+ "perm_write": True,
+ "perm_unlink": True,
+ "group_ids": [(6, 0, [env.ref("dms.group_dms_user").id])],
+ "directory_ids": [(6, 0, racines.ids)],
+ }})
+ env.cr.commit()
+ env.registry.clear_cache()
+ rapport["created"] = True
+ rapport["after"] = visible()
+ rapport["access_groups_after"] = Groupe.search_count([])
+except Exception as exc:
+ rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
+print({debut!r})
+print(json.dumps(rapport))
+print({fin!r})
+"""
+
+
+def build_script(dry_run):
+ return SCRIPT.format(
+ dry="True" if dry_run else "False",
+ nom=NOM_GROUPE,
+ debut=DEBUT,
+ fin=FIN,
+ )
+
+
+def render(rapport, dry_run):
+ if rapport.get("absent"):
+ return [f"ℹ️ {t('No DMS in this database, nothing to repair.')}"]
+ lignes = [
+ f"📁 {t('DMS documents in the database')} :"
+ f" {rapport.get('files', '?')} {t('file(s)')},"
+ f" {rapport.get('directories', '?')} {t('folder(s)')}"
+ ]
+ avant = rapport.get("before")
+ temoin = rapport.get("witness")
+ if avant is None:
+ lignes.append(f" ⚠ {t('No DMS user to test visibility with.')}")
+ else:
+ lignes.append(
+ f" {t('Visible to')} {temoin} :"
+ f" {avant['directories']} {t('folder(s)')},"
+ f" {avant['files']} {t('file(s)')}"
+ )
+ lignes.append(
+ f" {t('DMS access groups:')} {rapport.get('access_groups_before', '?')}"
+ )
+ if rapport.get("already_repaired"):
+ lignes.append(f" ℹ️ {t('Already repaired: the group exists.')}")
+ return lignes
+ if dry_run:
+ lignes.append(
+ f" → {t('Would create one access group over')}"
+ f" {len(rapport.get('roots') or [])} {t('root folder(s)')} :"
+ f" {', '.join(rapport.get('roots') or [])}"
+ )
+ lignes.append(f" {t('Nothing written. Re-run with --apply.')}")
+ return lignes
+ apres = rapport.get("after")
+ if apres:
+ lignes.append(
+ f" ✅ {t('Now visible to')} {temoin} :"
+ f" {apres['directories']} {t('folder(s)')},"
+ f" {apres['files']} {t('file(s)')}"
+ )
+ return lignes
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Restore visibility of DMS documents after a MuK DMS to OCA DMS"
+ " migration. Reports without writing unless --apply is given."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", default="config.conf")
+ parser.add_argument(
+ "--apply",
+ action="store_true",
+ help="actually create the access group (default: report only)",
+ )
+ config = parser.parse_args(argv)
+
+ # Le même garde-fou que partout ailleurs : un Odoo d'une autre version
+ # ÉCRIT dans la base avant d'échouer.
+ souci = database_cleanup.require_matching_version(config.database)
+ if souci:
+ print(f"❌ {souci}")
+ return 2
+
+ try:
+ rapport = database_cleanup.run_shell(
+ config.database,
+ config.config,
+ build_script(not config.apply),
+ echo=lambda texte: print(f"⧖ {texte}", flush=True),
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ if rapport.get("error"):
+ print(f"❌ {rapport['error']}")
+ return 2
+ print("\n".join(render(rapport, not config.apply)))
+ if rapport.get("absent"):
+ return 0
+ avant = rapport.get("before") or {}
+ if config.apply:
+ # Une réparation RÉUSSIE doit rendre 0. Rendre « il y a des
+ # trouvailles » après avoir tout remis en place ferait échouer
+ # n'importe quelle chaîne make qui appelle l'outil.
+ apres = rapport.get("after") or {}
+ if rapport.get("already_repaired"):
+ return 0
+ return 0 if apres.get("files") else 2
+ return 1 if not avant.get("files") else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/fix_migration_odoo140_to_odoo150.py b/script/odoo/migration/fix_migration_odoo140_to_odoo150.py
old mode 100644
new mode 100755
diff --git a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql
new file mode 100644
index 0000000..65993f4
--- /dev/null
+++ b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql
@@ -0,0 +1,36 @@
+-- © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+--
+-- Correctifs à appliquer AVANT qu'OpenUpgrade ne migre vers Odoo 18.
+--
+-- En SQL et non en Python : ce fichier tourne sur une base encore en
+-- 17, que le code de la 18 ne saurait pas charger.
+
+-- forum_tag_rel : la colonne qui pointe vers forum.post s'appelait
+-- `forum_id` — un nom trompeur, elle ne désignait pas un forum. La 18 la
+-- nomme `forum_post_id`.
+--
+-- OpenUpgrade le DÉCLARE dans son analyse :
+-- website_forum / forum.post / tag_ids (many2many)
+-- : column1 is now 'forum_post_id' ('forum_id') [forum_tag_rel]
+-- mais `website_forum/18.0.1.2/` ne contient aucun script : rien ne
+-- l'applique. Le chargement casse alors sur
+-- column "forum_post_id" referenced in foreign key constraint does not exist
+-- et la base reste à moitié migrée.
+--
+-- Les deux conditions rendent l'ordre rejouable : rien à faire si la
+-- table n'existe pas (website_forum non installé) ni si le renommage a
+-- déjà eu lieu.
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'forum_tag_rel' AND column_name = 'forum_id'
+ ) AND NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'forum_tag_rel' AND column_name = 'forum_post_id'
+ ) THEN
+ ALTER TABLE forum_tag_rel RENAME COLUMN forum_id TO forum_post_id;
+ RAISE NOTICE 'forum_tag_rel.forum_id renommee en forum_post_id';
+ END IF;
+END $$;
diff --git a/script/odoo/migration/fix_view_type.py b/script/odoo/migration/fix_view_type.py
new file mode 100755
index 0000000..adb24ec
--- /dev/null
+++ b/script/odoo/migration/fix_view_type.py
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Deux données de vue qu'Odoo refuse, et qu'aucune mise à jour ne répare.
+
+A — le `type` stocké contredit l'héritage
+=========================================
+
+Le symptôme est brutal : Odoo refuse de charger la base.
+
+ Le nœud racine d'une vue tree devrait être , et non
+ view: ir.ui.view(637,) view.parent: ir.ui.view(636,)
+
+La 637 hérite d'une vue `search` et son `type` en base dit `tree`. Odoo
+valide alors un `` avec les règles d'un ``, et échoue.
+
+Pourquoi aucune mise à jour ne le répare
+----------------------------------------
+`type` n'est PAS un champ calculé. Dans `ir_ui_view.py`, il est rempli
+dans `create` — et seulement s'il est absent :
+
+ if not values.get('type'):
+ if values.get('inherit_id'):
+ values['type'] = self.browse(values['inherit_id']).type
+
+`write` ne le recalcule jamais. Une valeur fausse reste donc fausse pour
+toujours, et `-u module` ne la corrigera pas — il échoue avant, sur la
+validation qu'elle provoque.
+
+Pourquoi en SQL et pas par l'ORM
+--------------------------------
+Parce que le registre ne charge plus. Un outil qui aurait besoin d'Odoo
+pour réparer ce qui empêche Odoo de démarrer ne servirait à rien.
+
+Ce que la règle garantit
+------------------------
+Une vue héritée prend le type de son ancêtre — c'est la règle d'Odoo
+lui-même, citée plus haut. Vérifié : zéro écart sur une installation 18
+neuve et sur quatre bases migrées, un seul sur celle qui refusait de
+charger. Le détecteur ne crie pas au loup.
+
+B — un `` survit dans une base Odoo 18
+============================================
+La 18 a renommé le type `tree` en `list`, balise comprise. OpenUpgrade
+convertit la COLONNE :
+
+ def _fix_list_view_type(cr):
+ '''Former tree views have view type list now.'''
+ cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'")
+
+…et jamais l'ARCH. Le chargement meurt alors sur
+
+ Le nœud racine d'une vue list devrait être , et non
+
+Une vue de module s'en remet à la première mise à jour du module, qui
+réécrit son arch depuis le XML. Une vue SANS xmlid — une liste faite à
+la main, une copie de site — n'est réécrite par rien : elle reste
+cassée pour toujours.
+
+Le remplacement porte sur TOUTES les occurrences, pas seulement la
+racine : un `` imbriqué dans un formulaire (une liste one2many)
+est tout aussi refusé. Vérifié : les addons d'Odoo 18 n'en contiennent
+plus une seule, donc tout `` d'une base 18 est un reste.
+
+Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
+"""
+
+import argparse
+import os
+import subprocess
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+SEP = "\x1f"
+
+# La profondeur borne la récursion : un `inherit_id` circulaire ferait
+# tourner PostgreSQL sans fin, et une chaîne de vues n'a jamais vingt
+# maillons.
+PROFONDEUR_MAX = 20
+
+DETECTION = f"""
+WITH RECURSIVE racine AS (
+ SELECT id, type, 0 AS prof FROM ir_ui_view WHERE inherit_id IS NULL
+ UNION ALL
+ SELECT v.id, r.type, r.prof + 1
+ FROM ir_ui_view v JOIN racine r ON v.inherit_id = r.id
+ WHERE r.prof < {PROFONDEUR_MAX}
+)
+SELECT v.id::text, v.type, r.type,
+ coalesce(v.mode, '-'),
+ coalesce(d.module || '.' || d.name, '-'),
+ coalesce(v.model, '-')
+FROM ir_ui_view v
+JOIN racine r ON r.id = v.id
+LEFT JOIN ir_model_data d
+ ON d.model = 'ir.ui.view' AND d.res_id = v.id
+WHERE v.inherit_id IS NOT NULL AND v.type <> r.type
+ORDER BY v.id
+"""
+
+
+# La 18 renomme `tree` en `list`. Avant, la balise est légitime.
+PREMIERE_VERSION_LIST = 18
+
+
+def run_psql(database, sql, read_only=True):
+ """Interroger la base. En lecture seule sauf demande explicite."""
+ env = os.environ.copy()
+ if read_only:
+ env["PGOPTIONS"] = "-c default_transaction_read_only=on"
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ return None
+ return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
+
+
+def find(database):
+ """Les vues fautives. None si la base ne répond pas."""
+ lignes = run_psql(database, DETECTION)
+ if lignes is None:
+ return None
+ return [
+ {
+ "id": int(ligne[0]),
+ "type": ligne[1],
+ "expected": ligne[2],
+ "mode": ligne[3],
+ "xmlid": ligne[4],
+ "model": ligne[5],
+ }
+ for ligne in lignes
+ if len(ligne) >= 6
+ ]
+
+
+def db_major(database):
+ """La version majeure d'Odoo de cette base, ou None.
+
+ Celle qu'Odoo inscrit, pas celle du checkout : on répare une base,
+ pas un répertoire.
+ """
+ lignes = run_psql(
+ database,
+ "SELECT latest_version FROM ir_module_module WHERE name = 'base'",
+ )
+ if not lignes or not lignes[0][0]:
+ return None
+ try:
+ return int(lignes[0][0].split(".")[0])
+ except ValueError:
+ return None
+
+
+def arch_is_jsonb(database):
+ """`arch_db` est un jsonb depuis la 16, un texte avant."""
+ lignes = run_psql(
+ database,
+ "SELECT data_type FROM information_schema.columns"
+ " WHERE table_name = 'ir_ui_view' AND column_name = 'arch_db'",
+ )
+ return bool(lignes) and lignes[0][0] == "jsonb"
+
+
+def arch_as_text(jsonb):
+ """L'expression SQL qui rend l'arch en texte, quelle que soit sa forme."""
+ return "arch_db::text" if jsonb else "coalesce(arch_db, '')"
+
+
+def find_tree(database, jsonb):
+ """Les vues qui portent encore un ``. [] hors des bases 18+."""
+ texte = arch_as_text(jsonb)
+ lignes = run_psql(
+ database,
+ f"SELECT v.id::text, v.type,"
+ f" coalesce(d.module || '.' || d.name, '-'),"
+ f" coalesce(v.model, '-'),"
+ f" CASE WHEN d.id IS NULL THEN 'custom' ELSE 'module' END"
+ f" FROM ir_ui_view v"
+ f" LEFT JOIN ir_model_data d"
+ f" ON d.model = 'ir.ui.view' AND d.res_id = v.id"
+ f" WHERE {texte} LIKE '%= 5
+ ]
+
+
+def repair_tree_sql(vues, jsonb):
+ """Le SQL qui remplace `` par ``, ou "".
+
+ TOUTES les occurrences : un `` imbriqué dans un formulaire est
+ refusé autant que celui de la racine. Les addons d'Odoo 18 n'en
+ contiennent plus une seule, donc il n'y a rien de légitime à épargner.
+ """
+ if not vues:
+ return ""
+ liste = ", ".join(str(vue["id"]) for vue in vues)
+ if jsonb:
+ # jsonb : reconstruire l'objet, langue par langue. Une traduction
+ # oubliée laisserait la vue cassée dans cette langue seulement —
+ # une panne qui ne se montre qu'à certains.
+ remplace = (
+ "(SELECT jsonb_object_agg(cle,"
+ " replace(replace(valeur, '', '
'))"
+ " FROM jsonb_each_text(arch_db) AS paires(cle, valeur))"
+ )
+ else:
+ remplace = (
+ "replace(replace(arch_db, '', '
')"
+ )
+ return f"UPDATE ir_ui_view SET arch_db = {remplace} WHERE id IN ({liste})"
+
+
+def repair_sql(vues):
+ """Le SQL de correction, ou "" s'il n'y a rien à corriger.
+
+ Un `CASE` plutôt qu'un ordre par vue : une seule instruction, donc
+ une seule transaction, donc pas de base à moitié réparée si l'on
+ coupe au milieu.
+ """
+ if not vues:
+ return ""
+ cas = " ".join(
+ f"WHEN {vue['id']} THEN '{vue['expected']}'" for vue in vues
+ )
+ liste = ", ".join(str(vue["id"]) for vue in vues)
+ return (
+ f"UPDATE ir_ui_view SET type = CASE id {cas} END"
+ f" WHERE id IN ({liste})"
+ )
+
+
+def render(vues, applique=False):
+ if not vues:
+ return []
+ lignes = [
+ f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}"
+ f" {t('their inheritance')} :"
+ ]
+ for vue in vues:
+ lignes.append(
+ f" {vue['id']:<7} {vue['xmlid'][:46]:<48}"
+ f" {vue['type']} → {vue['expected']} {vue['model']}"
+ )
+ if applique:
+ lignes.append(f" ✅ {t('Corrected.')}")
+ else:
+ lignes.append(
+ f" {t('No module update will fix this: the type is set once,')}"
+ f" {t('at creation. Re-run with --apply.')}"
+ )
+ return lignes
+
+
+def render_tree(vues, applique=False):
+ if not vues:
+ return []
+ lignes = [
+ f"🩹 {len(vues)} {t('view(s) still carry a tag Odoo 18')}"
+ f" {t('renamed to ')} :"
+ ]
+ for vue in vues:
+ # L'ORIGINE est le renseignement utile : une vue de module se
+ # répare d'une mise à jour, une vue sans xmlid n'est réécrite
+ # par rien et reste cassée pour toujours.
+ marque = (
+ t("custom — nothing will ever rewrite it")
+ if vue["origin"] == "custom"
+ else t("from a module — a module update also fixes it")
+ )
+ lignes.append(
+ f" {vue['id']:<7} {vue['xmlid'][:40]:<42}"
+ f" {vue['type']:<8} {vue['model'][:22]:<24} {marque}"
+ )
+ if applique:
+ lignes.append(f" ✅ {t('Corrected.')}")
+ return lignes
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Report views whose stored type contradicts the type of the"
+ " view they inherit from, and optionally fix them."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument(
+ "--apply",
+ action="store_true",
+ help="actually correct the type (default: report only)",
+ )
+ config = parser.parse_args(argv)
+
+ vues = find(config.database)
+ if vues is None:
+ print(f"❌ {t('Cannot read the database: ')}{config.database}")
+ return 2
+ # Le renommage `tree` → `list` n'a de sens qu'à partir de la 18 :
+ # avant, la balise est parfaitement légitime et la « corriger »
+ # casserait des vues saines.
+ jsonb = arch_is_jsonb(config.database)
+ majeure = db_major(config.database)
+ arbres = []
+ if majeure and majeure >= PREMIERE_VERSION_LIST:
+ arbres = find_tree(config.database, jsonb) or []
+
+ if not vues and not arbres:
+ print(f"✅ {t('Every view agrees with what Odoo expects.')}")
+ return 0
+ if not config.apply:
+ print("\n".join(render(vues) + render_tree(arbres)))
+ return 1
+
+ for sql in (repair_sql(vues), repair_tree_sql(arbres, jsonb)):
+ if sql and run_psql(config.database, sql, read_only=False) is None:
+ print(f"❌ {t('The correction failed.')}")
+ return 2
+ # Relire APRÈS : annoncer « corrigé » sans vérifier ferait relancer
+ # la migration sur le même mur.
+ if find(config.database) or (arbres and find_tree(config.database, jsonb)):
+ print(f"❌ {t('The correction failed.')}")
+ return 2
+ print(
+ "\n".join(
+ render(vues, applique=True) + render_tree(arbres, applique=True)
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/neutralize_cow_views.py b/script/odoo/migration/neutralize_cow_views.py
index 7e32425..5b83077 100755
--- a/script/odoo/migration/neutralize_cow_views.py
+++ b/script/odoo/migration/neutralize_cow_views.py
@@ -32,7 +32,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-from check_cow_views import analyse # noqa: E402
+from check_cow_views import analyse, t # noqa: E402
DEFAULT_PREFIX = "zz_cow_archive"
@@ -40,7 +40,7 @@ DEFAULT_PREFIX = "zz_cow_archive"
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
- ["psql", "-d", database, "-tAc", sql],
+ ["psql", "-X", "-w", "-d", database, "-tAF", "|", "-c", sql],
capture_output=True,
text=True,
)
@@ -66,6 +66,39 @@ def neutralize(database, lst_view_id, prefix):
return int(output or 0)
+def list_archived(database, prefix):
+ """The copies a previous neutralization put aside.
+
+ Knowing what was archived is the other half of --restore: a key renamed
+ months ago is invisible in the interface — the copy is inactive and no
+ longer pairs with anything — so without this listing the only trace is
+ someone's memory of having run --apply.
+ """
+ output = run_psql(
+ database,
+ "SELECT id, substring(key from " + str(len(prefix) + 2) + "),"
+ " COALESCE(website_id::text, ''), active,"
+ " octet_length(arch_db::text)"
+ f" FROM ir_ui_view WHERE key LIKE '{prefix}.%' ORDER BY id;",
+ )
+ lst_row = []
+ for line in output.splitlines():
+ if not line.strip():
+ continue
+ parts = line.split("|")
+ if len(parts) >= 5:
+ lst_row.append(
+ {
+ "id": int(parts[0]),
+ "key": parts[1],
+ "website_id": parts[2] or None,
+ "active": parts[3] == "t",
+ "arch_bytes": int(parts[4] or 0),
+ }
+ )
+ return lst_row
+
+
def restore(database, prefix):
"""Undo a neutralization: strip the prefix and reactivate."""
output = run_psql(
@@ -105,51 +138,97 @@ def main():
action="store_true",
help="undo a previous neutralization and reactivate the copies",
)
+ parser.add_argument(
+ "--list",
+ dest="list_archived",
+ action="store_true",
+ help="list the copies a previous neutralization put aside",
+ )
config = parser.parse_args()
+ try:
+ return _run(config, parser)
+ except RuntimeError as exc:
+ # Une base absente ou un PostgreSQL arrêté est une erreur d'usage, pas
+ # un défaut de l'outil : une trace d'appel ferait chercher le bogue au
+ # mauvais endroit.
+ print(f"❌ {exc}")
+ return 2
+
+
+def _run(config, parser):
+ if config.list_archived:
+ lst_row = list_archived(config.database, config.prefix)
+ if not lst_row:
+ print(
+ f"✅ -> {t('No view with this prefix on')}"
+ f" '{config.database}' : '{config.prefix}.'"
+ )
+ return 0
+ print(
+ f"ℹ {len(lst_row)} {t('archived COW view(s) on')}"
+ f" '{config.database}' :"
+ )
+ for row in lst_row:
+ state = "active" if row["active"] else "inactive"
+ print(
+ f" - id={row['id']} website={row['website_id'] or '-'}"
+ f" {row['key']} ({state}, {row['arch_bytes']} B)"
+ )
+ print(
+ f" {t('Their arch is intact. Restore them all with --restore,')}"
+ f" {t('once the module view they shadow has the right shape.')}"
+ )
+ return 0
+
if config.restore:
count = restore(config.database, config.prefix)
- print(f"✅ -> {count} COW view(s) restored on '{config.database}'.")
+ print(
+ f"✅ -> {count} {t('COW view(s) restored on')} '{config.database}'."
+ )
return 0
if not config.target_version:
parser.error("--target_version is required unless --restore is used")
if not os.path.isdir(config.target_version):
print(
- f"❌ Target version directory '{config.target_version}' not found."
+ f"❌ {t('Target version directory not found')} :"
+ f" '{config.target_version}'"
)
- return 1
+ return 2
lst_at_risk, _, _ = analyse(config.database, config.target_version)
if not lst_at_risk:
- print("✅ -> No website COW view to neutralize.")
+ print(f"✅ -> {t('No website COW view to neutralize.')}")
return 0
print(
- f"⚠️ {len(lst_at_risk)} website COW view(s) would break the bump to"
- f" {config.target_version}:"
+ f"⚠️ {len(lst_at_risk)} {t('website COW view(s) would break the bump')}"
+ f" {t('to')} {config.target_version} :"
)
lst_view_id = []
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
lst_view_id.append(view_id)
print(
f" - id={view_id} website={website_id} {key}"
- f" : {mode} -> {target_mode} ({reason})"
+ f" : {mode} -> {target_mode} ({t(reason)})"
)
if not config.apply:
print(
- "ℹ Dry-run. Add --apply to rename their key to"
- f" '{config.prefix}.' and deactivate them. Reversible with"
- " --restore; the arch stays in database."
+ f"ℹ {t('Dry-run. Add --apply to rename their key to')}"
+ f" '{config.prefix}.' {t('and deactivate them. Reversible')}"
+ f" {t('with --restore; the arch stays in database.')}"
)
- return 0
+ # 1 = des copies sont à neutraliser. Le pilote lisait une phrase
+ # anglaise pour le savoir ; il devenait aveugle une fois traduite.
+ return 1
count = neutralize(config.database, lst_view_id, config.prefix)
print(
- f"✅ -> {count} COW view(s) neutralized (key prefixed with"
- f" '{config.prefix}.', deactivated). The arch is kept as an archive;"
- " use --restore to undo."
+ f"✅ -> {count} {t('COW view(s) neutralized (key prefixed with')}"
+ f" '{config.prefix}.', {t('deactivated). The arch is kept as an')}"
+ f" {t('archive; use --restore to undo.')}"
)
return 0
diff --git a/script/odoo/migration/reset_stale_cow_tui.py b/script/odoo/migration/reset_stale_cow_tui.py
new file mode 100755
index 0000000..766f90f
--- /dev/null
+++ b/script/odoo/migration/reset_stale_cow_tui.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Full-screen browsing of the COW copies that drifted from their module view.
+
+Two things decide whether to reset a copy, and the text report puts them a
+thousand lines apart: what the copy holds that the module view does not — the
+only thing a reset gives up — and which child no longer finds its anchor,
+which is why anything breaks at all. Space switches between them.
+
+The reset command is on « c »: it takes a key, and copying a key by hand out
+of a scrolled diff is where a character goes missing. A key matching no copy
+is not an error for the tool — it runs and does nothing.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+from reset_stale_cow_views import render_broken, render_diff # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 3; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#copies { width: 44; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def weight(cow_view, module_view):
+ """Combien la copie s'écarte, pour trier d'un coup d'œil."""
+ if not module_view:
+ return "—"
+ import difflib
+
+ diff = list(
+ difflib.unified_diff(
+ module_view["arch"].splitlines(),
+ cow_view["arch"].splitlines(),
+ lineterm="",
+ n=0,
+ )
+ )
+ plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
+ minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
+ return f"+{plus}/-{minus}"
+
+
+def build_app(lst_finding, database):
+ """Build the application. Textual is imported here, not at module level.
+
+ The module stays importable — and therefore testable — on a machine
+ without Textual, which is also what lets the caller fall back to the text
+ report rather than fail.
+ """
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ class StaleCowApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [
+ ("q,escape", "quit", "Quit"),
+ ("space,tab", "toggle", "Diff / why"),
+ ("c", "copy", "Copy reset command"),
+ ]
+
+ def __init__(self, lst_finding, database):
+ super().__init__()
+ self.lst_finding = lst_finding
+ self.database = database
+ self.why = False
+ self.index = 0
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="copies", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = "COW copies drifted from their module view"
+ table = self.query_one("#copies", DataTable)
+ table.add_columns("copy", "+/-", "broken")
+ for cow_view, module_view, broken in self.lst_finding:
+ table.add_row(
+ (cow_view["key"] or str(cow_view["id"]))[:30],
+ weight(cow_view, module_view),
+ str(len(broken)),
+ key=str(cow_view["id"]),
+ )
+ self._show()
+
+ def _show(self):
+ if not self.lst_finding:
+ return
+ cow_view, module_view, broken = self.lst_finding[self.index]
+ which = "why it breaks" if self.why else "what the copy holds"
+ self.query_one("#head", Static).update(
+ f"{cow_view['key']} · id={cow_view['id']}"
+ f" · website={cow_view.get('website_id')}\n"
+ f"[{which}] — space to switch, c to copy the reset command"
+ )
+ if self.why:
+ text = render_broken(cow_view, module_view, broken)
+ elif module_view:
+ text = render_diff(module_view, cow_view, indent=" ")
+ else:
+ text = (
+ f" {t('No module view carries this key: nothing to reset')}"
+ f" {t('onto.')}"
+ )
+ self.query_one("#content", Static).update(text)
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "copies" and self.lst_finding:
+ self.index = event.cursor_row
+ self._show()
+
+ def action_toggle(self):
+ self.why = not self.why
+ self._show()
+
+ def action_copy(self):
+ """Mettre la commande de réinitialisation dans le presse-papier.
+
+ Elle prend une CLÉ, et recopier une clé à la main depuis un diff
+ défilé est l'endroit où un caractère se perd — l'outil ne dirait
+ rien, il ne trouverait simplement aucune copie.
+ """
+ cow_view = self.lst_finding[self.index][0]
+ command = (
+ "./script/odoo/migration/reset_stale_cow_views.py"
+ f" -d {self.database} --reset {cow_view['key']} --apply"
+ )
+ try:
+ self.copy_to_clipboard(command)
+ self.notify(t("Reset command copied."))
+ except Exception:
+ self.notify(command, timeout=20)
+
+ return StaleCowApp(lst_finding, database)
+
+
+def run_tui(lst_finding, database, run_app=True):
+ """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
+
+ Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
+ de terminal, ou Textual absent. Se taire ferait réafficher le rapport
+ texte à la place de l'écran demandé, sans rien qui distingue les deux.
+ """
+ if not lst_finding:
+ return False
+ if not sys.stdout.isatty():
+ print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
+ return False
+ try:
+ from script.todo import textual_setup
+ except Exception:
+ textual_setup = None
+ if textual_setup and not textual_setup.available():
+ if not textual_setup.in_venv():
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ print(f" {t('Run it with')} .venv.erplibre/bin/python3")
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(lst_finding, database)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ return False
+ if not run_app:
+ return app
+ app.run()
+ return True
diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py
index 13cd44e..ab11e79 100755
--- a/script/odoo/migration/reset_stale_cow_views.py
+++ b/script/odoo/migration/reset_stale_cow_views.py
@@ -61,6 +61,17 @@ import re
import subprocess
import sys
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
@@ -142,7 +153,7 @@ def require_lxml():
return etree
except ImportError:
sys.exit(
- "❌ lxml is required to resolve the xpath expressions.\n"
+ f"❌ {t('lxml is required to resolve the xpath expressions.')}\n"
" Run this with an interpreter that has it, e.g.\n"
" ./.venv.erplibre/bin/python3 " + os.path.relpath(__file__)
)
@@ -216,8 +227,34 @@ def analyse(database):
return findings
-def show_diff(module_view, cow_view):
- """Module arch vs copy: what the copy would gain and lose on a reset."""
+def find_copy_by_key(views, key):
+ """(copie COW, jumelle module) pour cette clé, ou (None, None).
+
+ La détection différentielle ne voit qu'une copie dont un ENFANT casse.
+ Une copie périmée sans enfant lui échappe — mesuré : la copie de
+ `website_crm.contactus_form` était plus petite d'un tiers que sa
+ jumelle, et c'est elle qui rendait /contactus en 500. Demander sa
+ réinitialisation par clé doit donc marcher même hors détection.
+ """
+ copy = twin = None
+ for row in views.values():
+ if row.get("key") != key:
+ continue
+ if row.get("website_id"):
+ if copy is None or row["id"] < copy["id"]:
+ copy = row
+ elif twin is None or row["id"] < twin["id"]:
+ twin = row
+ return copy, twin
+
+
+def render_diff(module_view, cow_view, indent=" "):
+ """Module arch vs copy : ce qu'une réinitialisation rend et abandonne.
+
+ Rendu en texte plutôt qu'imprimé : la TUI montre exactement le même
+ diff que la ligne de commande. Deux rendus séparés dériveraient sans
+ que rien ne le signale.
+ """
diff = difflib.unified_diff(
module_view["arch"].splitlines(),
cow_view["arch"].splitlines(),
@@ -225,8 +262,44 @@ def show_diff(module_view, cow_view):
tofile=f"cow id={cow_view['id']}",
lineterm="",
)
- for line in diff:
- print(f" {line}")
+ return "\n".join(f"{indent}{line}" for line in diff)
+
+
+def render_broken(cow_view, module_view, broken):
+ """Pourquoi ça casse : les enfants dont l'xpath ne trouve plus son point."""
+ lines = [
+ f"── id={cow_view['id']} {cow_view['key']}"
+ f" (website={cow_view.get('website_id')}) ──",
+ "",
+ ]
+ if not broken:
+ lines.append(
+ f" {t('No child fails on this copy: it drifted without')}"
+ f" {t('breaking anything yet.')}"
+ )
+ else:
+ lines.append(
+ f" {t('These children no longer find their anchor in the copy')}"
+ " :"
+ )
+ for child_id, expr in broken:
+ lines.append(f" {t('child')} {child_id} : {expr}")
+ lines += [
+ "",
+ f" {t('The anchor exists in the module view')}"
+ f" (id={module_view['id'] if module_view else '?'})"
+ f" {t('but not in this copy, frozen on an older version.')}",
+ "",
+ f" {t('Resetting restores the module arch; the customization the')}",
+ f" {t('copy carried is saved first, and is yours to re-apply as an')}",
+ f" {t('INHERITING view with its own key.')}",
+ ]
+ return "\n".join(lines)
+
+
+def show_diff(module_view, cow_view):
+ """Imprimer le diff, pour la ligne de commande."""
+ print(render_diff(module_view, cow_view))
def backup(database, cow_view, directory):
@@ -272,6 +345,16 @@ def main():
)
)
parser.add_argument("-d", "--database", required=True)
+ parser.add_argument(
+ "--tui",
+ action="store_true",
+ help="browse the differences full screen",
+ )
+ parser.add_argument(
+ "--list-keys",
+ action="store_true",
+ help="print one drifted key per line, and nothing else",
+ )
parser.add_argument(
"--reset",
metavar="KEY",
@@ -293,72 +376,141 @@ def main():
config = parser.parse_args()
findings = analyse(config.database)
- if not findings:
- print("✅ No COW copy has drifted from its module view.")
- return 0
+ # Une sortie sans décor, pour que l'appelant construise un menu : on ne
+ # connaît pas les clés de tête, et les recopier depuis un diff de mille
+ # lignes est le genre de recopie où l'on se trompe.
+ if config.list_keys:
+ for cow_view, _module_view, _broken in findings:
+ print(cow_view["key"])
+ return 1 if findings else 0
- print(
- f"⚠️ {len(findings)} COW copy(ies) drifted from their module view"
- f" in {config.database}"
- )
- print(
- " Odoo surfaces this when the module view is rewritten (a version"
- " bump) or when the page is rendered.\n"
- )
+ # « Rien détecté » ne veut pas dire « rien à faire » quand une clé est
+ # demandée : la détection différentielle ne voit qu'une copie dont un
+ # ENFANT casse, et une copie périmée sans enfant lui échappe. Sortir ici
+ # faisait taire la demande — on croyait la copie réinitialisée.
+ if not findings and not (set(config.reset) - {"all"}):
+ print(f"✅ {t('No COW copy has drifted from its module view.')}")
+ return 0
+ if not findings:
+ # Le même texte que ci-dessus se lisait comme une contradiction :
+ # « aucune copie n'a dérivé », puis « ✅ réinitialisé id=2656 ».
+ # Les deux sont vrais — la détection différentielle ne voit qu'une
+ # copie dont un ENFANT casse — mais mis côte à côte sans un mot,
+ # on croit l'outil incohérent et l'on cesse de le lire.
+ print(
+ f"ℹ {t('The differential detection found nothing; resetting')}"
+ f" {t('the requested key(s) anyway.')}"
+ )
+
+ if findings:
+ print(
+ f"⚠️ {len(findings)}"
+ f" {t('COW copy(ies) drifted from their module')}"
+ f" {t('view in')} {config.database}"
+ )
+ print(
+ f" {t('Odoo surfaces this when the module view is rewritten')}"
+ f" {t('(a version bump) or when the page is rendered.')}\n"
+ )
for cow_view, module_view, broken in findings:
twin = (
f"module id={module_view['id']}"
if module_view
- else "NO module view with this key"
+ else t("NO module view with this key")
)
print(
f" id={cow_view['id']} key={cow_view['key']}"
f" website_id={cow_view['website_id']} [{twin}]"
)
for child_id, expr in broken:
- print(f" child {child_id} cannot apply: {expr}")
+ print(f" {t('child')} {child_id} {t('cannot apply')}: {expr}")
if module_view:
show_diff(module_view, cow_view)
print()
if not config.reset:
print(
- "Nothing changed. Re-run with --reset --apply to reset a"
- " copy onto its module view."
+ f"{t('Nothing changed. Re-run with --reset --apply to')}"
+ f" {t('reset a copy onto its module view.')}"
)
return 1
+ if config.tui and findings:
+ from reset_stale_cow_tui import run_tui
+
+ # False n'est pas un échec : l'écran a dit pourquoi, et le rapport
+ # texte ci-dessus porte déjà la même information.
+ run_tui(findings, config.database)
+
wanted = set(config.reset)
directory = config.backup_dir or os.path.join(
"private", "odoo", "migration", config.database, "cow_reset"
)
done = 0
+ honoured = set()
+ missed = []
+ # Les vues brutes servent aux clés que la détection ne voit pas ; on ne
+ # les lit que si l'on va s'en servir.
+ views = fetch_views(config.database) if wanted - {"all"} else {}
for cow_view, module_view, _broken in findings:
if "all" not in wanted and cow_view["key"] not in wanted:
continue
+ honoured.add(cow_view["key"])
if not module_view:
print(
- f"⏭ {cow_view['key']}: no module view to reset onto,"
- " skipped."
+ f"⏭ {cow_view['key']} :"
+ f" {t('no module view to reset onto, skipped.')}"
)
continue
if not config.apply:
print(
- f"[dry-run] would reset id={cow_view['id']}"
- f" ({cow_view['key']}) onto id={module_view['id']}"
+ f"[{t('dry-run')}] {t('would reset')} id={cow_view['id']}"
+ f" ({cow_view['key']}) {t('onto')} id={module_view['id']}"
)
continue
path = backup(config.database, cow_view, directory)
reset(config.database, cow_view, module_view)
done += 1
- print(f"✅ reset id={cow_view['id']} ({cow_view['key']})")
- print(f" previous arch saved to {path}")
+ print(f"✅ {t('reset')} id={cow_view['id']} ({cow_view['key']})")
+ print(f" {t('previous arch saved to')} {path}")
+ # Une clé demandée qui ne correspond à rien N'EST PAS un succès. Elle
+ # l'était : la commande tournait, ne faisait rien, et se taisait. On a
+ # donc cru une copie réinitialisée alors que /contactus rendait encore
+ # 500 — mesuré sur une vraie migration.
+ for key in sorted(wanted - honoured - {"all"}):
+ copy, twin = find_copy_by_key(views, key)
+ if copy is None:
+ print(f"⚠️ {t('No COW copy carries this key')} : {key}")
+ missed.append(key)
+ continue
+ if twin is None:
+ print(f"⚠️ {key} : {t('no module view to reset onto, skipped.')}")
+ missed.append(key)
+ continue
+ if copy["arch"] == twin["arch"]:
+ print(f"ℹ {key} : {t('already identical to the module view.')}")
+ continue
+ if not config.apply:
+ print(
+ f"[{t('dry-run')}] {t('would reset')} id={copy['id']}"
+ f" ({key}) {t('onto')} id={twin['id']}"
+ )
+ continue
+ path = backup(config.database, copy, directory)
+ reset(config.database, copy, twin)
+ done += 1
+ print(f"✅ {t('reset')} id={copy['id']} ({key})")
+ print(f" {t('previous arch saved to')} {path}")
+
if config.apply and done:
print(
- f"\n{done} copy(ies) reset. Re-apply any real customisation as an"
- " INHERITING view, not a copy, so it cannot go stale again."
+ f"\n{done} {t('copy(ies) reset. Re-apply any real customisation')}"
+ f" {t('as an INHERITING view, not a copy, so it cannot go stale')}"
+ f" {t('again.')}"
)
- return 0
+ # Une demande non honorée doit se voir jusque dans le code de sortie :
+ # l'appelant qui enchaîne ne lit pas le texte.
+ return 2 if missed else 0
if __name__ == "__main__":
diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py
new file mode 100755
index 0000000..25c3b0c
--- /dev/null
+++ b/script/odoo/migration/smoke_internal_ui.py
@@ -0,0 +1,1016 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Log in as `test` and open the first page of every Odoo app.
+
+Why this exists
+---------------
+The public smoke test opens what a visitor can reach. It says nothing about
+the BACK OFFICE, which is where a migration does most of its damage: a field
+dropped from a model but still named in a form view, a widget renamed between
+two versions, a stored compute that no longer resolves. None of it stops the
+module loading — it stops the day someone opens the app.
+
+Why the `test` user
+-------------------
+Neutralizing a database installs `user_test`, which copies the SYSTEM user's
+groups onto a `test` / `test` login (see its `post_init_hook`). The module is
+uninstalled right after, but the user survives — its uninstall hook only
+warns. So a neutralized database, and only a neutralized one, can be browsed
+without knowing anyone's real password. The tool checks for that user rather
+than trusting a flag: a resumed migration may have skipped neutralization,
+and a promise in a progression file is not a login.
+
+What is actually exercised
+--------------------------
+For each app — a root menu — its first page, meaning the first menu below it
+that carries an action, which is what the web client opens on a click. Two
+calls per app, both server-side:
+
+- ``get_views`` (12→15: ``load_views``) renders EVERY view arch of the
+ action. This is where a migration's damage surfaces, and it surfaces as an
+ exception rather than an empty screen.
+- ``web_search_read`` (12: ``search_read``) loads the first page of records.
+ A view can render on an empty model and still explode on real data.
+
+Exit codes: 0 every app answered, 1 some failed, 2 the tool failed.
+"""
+
+import argparse
+import ast
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+DEFAULT_LOGIN = "test"
+DEFAULT_PASSWORD = "test"
+DEFAULT_RECORD_LIMIT = 20
+
+# Deux façons d'écrire le même champ caché selon la version du gabarit :
+# l'ordre des attributs n'est pas garanti, et un seul motif en manquait la
+# moitié — la connexion échouait alors sans rien dire d'utile.
+RE_CSRF = re.compile(
+ r"""name=["']csrf_token["']\s+value=["']([^"']+)["']"""
+ r"""|value=["']([^"']+)["']\s+name=["']csrf_token["']"""
+)
+RE_DB_FIELD = re.compile(r"""name=["']db["']""")
+
+
+def user_state(database, login=DEFAULT_LOGIN, run_psql=None):
+ """« present », « absent » ou « unknown ». La nuance n'est pas cosmétique.
+
+ On INTERROGE plutôt que de croire un drapeau : une migration reprise a
+ pu sauter la neutralisation, et `state_1_neutralize_database` dirait
+ quand même « fait ».
+
+ Mais `run_psql` rend une liste vide DANS LES DEUX CAS — aucune ligne, ou
+ requête refusée. Mesuré ici même : un « id » ambigu passait pour « pas
+ d'utilisateur test », et l'outil annonçait tranquillement que la base
+ n'avait pas été neutralisée. Un COUNT distingue les deux : il rend
+ toujours une ligne quand la requête aboutit.
+ """
+ if run_psql is None:
+ from smoke_public_url import run_psql
+ sql = (
+ "SELECT count(*) FROM res_users" f" WHERE login = '{login}' AND active"
+ )
+ try:
+ rows = run_psql(database, sql)
+ except Exception:
+ return "unknown"
+ if not rows:
+ return "unknown"
+ try:
+ return "present" if int(rows[0][0]) else "absent"
+ except (TypeError, ValueError, IndexError):
+ return "unknown"
+
+
+def user_exists(database, login=DEFAULT_LOGIN, run_psql=None):
+ """Raccourci : seul « present » vaut oui."""
+ return user_state(database, login, run_psql=run_psql) == "present"
+
+
+class Session:
+ """Un client HTTP qui garde son cookie de session.
+
+ `urllib` sans gestionnaire de cookies renvoie chaque appel comme un
+ inconnu : on se connecte, puis on interroge en anonyme sans qu'aucune
+ erreur ne le signale — juste des résultats vides.
+ """
+
+ def __init__(self, base_url, timeout=60):
+ import http.cookiejar
+
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self.jar = http.cookiejar.CookieJar()
+ self.opener = urllib.request.build_opener(
+ urllib.request.HTTPCookieProcessor(self.jar)
+ )
+ self.uid = None
+ self.last_url = None
+ # Le nom des méthodes a changé en cours de route (get_views en 16,
+ # load_views avant). On retient celui qui a répondu : chercher à
+ # chaque appel doublerait le nombre de requêtes.
+ self._views_method = None
+ self._read_method = None
+
+ def open(self, path, data=None, headers=None):
+ url = self.base_url + path
+ request = urllib.request.Request(url, data=data, headers=headers or {})
+ # Où l'on a ATTERRI, pas où l'on voulait aller : urllib suit les
+ # redirections en silence, et une session perdue renvoie /my vers
+ # /web/login avec un 200 parfaitement rassurant.
+ self.last_url = url
+ try:
+ with self.opener.open(request, timeout=self.timeout) as answer:
+ self.last_url = answer.geturl()
+ return answer.getcode(), answer.read().decode(
+ "utf-8", errors="replace"
+ )
+ except urllib.error.HTTPError as exc:
+ self.last_url = exc.geturl()
+ return exc.code, exc.read().decode("utf-8", errors="replace")
+ except Exception as exc:
+ return 0, str(exc)
+
+ def rpc(self, path, params):
+ """Un appel JSON-RPC. Rend (résultat, erreur) — jamais une exception.
+
+ Une erreur Odoo arrive avec le statut 200 et un objet `error` dans
+ le corps : la traiter comme un succès ferait passer une vue cassée
+ pour une vue vide.
+ """
+ payload = json.dumps(
+ {
+ "jsonrpc": "2.0",
+ "method": "call",
+ "params": params,
+ "id": 1,
+ }
+ ).encode("utf-8")
+ status, body = self.open(
+ path, data=payload, headers={"Content-Type": "application/json"}
+ )
+ if not status:
+ return None, {"name": "transport", "message": body[:200]}
+ try:
+ answer = json.loads(body)
+ except ValueError:
+ return None, {
+ "name": f"HTTP {status}",
+ "message": body.strip()[:200],
+ }
+ if "error" in answer:
+ error = answer["error"] or {}
+ data = error.get("data") or {}
+ return None, {
+ "name": data.get("name") or error.get("message") or "error",
+ "message": (
+ data.get("message") or error.get("message") or ""
+ ).strip(),
+ "debug": data.get("debug") or "",
+ }
+ return answer.get("result"), None
+
+ def call_kw(self, model, method, args, kwargs=None):
+ return self.rpc(
+ "/web/dataset/call_kw",
+ {
+ "model": model,
+ "method": method,
+ "args": args,
+ "kwargs": kwargs or {},
+ },
+ )
+
+ def log_in(self, database, login, password):
+ """Se connecter par le formulaire, comme le ferait un navigateur.
+
+ Le jeton CSRF n'est pas une formalité : sans lui Odoo rend la page
+ de connexion avec un statut 200, et l'on croirait être entré.
+ """
+ status, body = self.open("/web/login")
+ if not status:
+ return False, t("The server did not serve the login page.")
+ if status >= 400:
+ # DIRE le statut. Sans cela on rapportait « Session expired »,
+ # qui décrit la conséquence et cache la cause : la page de
+ # connexion elle-même rendait 500. Mesuré — une copie COW de
+ # `website.submenu` casse /web/login comme elle casse le site,
+ # et l'on cherchait du côté du mot de passe.
+ return False, (
+ f"{t('The login page itself failed')} : HTTP {status} —"
+ f" {error_from_page(body)}"
+ )
+ match = RE_CSRF.search(body)
+ if not match:
+ # Sans jeton, Odoo refuse le POST et rend la page de connexion
+ # avec un statut 200 : on croirait à un mot de passe refusé.
+ return False, t("No CSRF token on the login page")
+ fields = {
+ "login": login,
+ "password": password,
+ "redirect": "",
+ }
+ if match:
+ fields["csrf_token"] = match.group(1) or match.group(2)
+ # Le champ `db` n'existe que si le serveur en propose plusieurs.
+ # L'envoyer toujours n'est pas anodin : certaines versions le
+ # refusent quand la liste des bases est masquée.
+ if RE_DB_FIELD.search(body):
+ fields["db"] = database
+ self.open(
+ "/web/login",
+ data=urllib.parse.urlencode(fields).encode("utf-8"),
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ # On ne LIT pas la redirection pour juger : on demande au serveur
+ # qui il croit que nous sommes.
+ info, error = self.rpc("/web/session/get_session_info", {})
+ if error:
+ return False, f"{error['name']} : {error['message']}"[:200]
+ uid = (info or {}).get("uid")
+ if not uid:
+ return False, t("Wrong login or password for user")
+ self.uid = uid
+ return True, ""
+
+ def views_of(self, model, lst_view, context=None):
+ """Faire rendre les arch côté serveur. C'est LE test.
+
+ `get_views` depuis la 16, `load_views` avant, et la 18 n'a plus que
+ le premier. On essaie le moderne, on retombe sur l'ancien.
+ """
+ kwargs = {"views": lst_view, "options": {}, "context": context or {}}
+ lst_try = (
+ [self._views_method]
+ if self._views_method
+ else ["get_views", "load_views"]
+ )
+ last = None
+ for method in lst_try:
+ result, error = self.call_kw(model, method, [], dict(kwargs))
+ if error is None:
+ self._views_method = method
+ return result, None
+ if not _is_missing_method(error):
+ return None, error
+ last = error
+ return None, last
+
+ def first_page(self, model, domain, context, limit, lst_field=None):
+ """Charger la première page d'enregistrements.
+
+ `search_read` et non `web_search_read` : ce dernier a changé de
+ signature en cours de route — `fields` (une liste) est devenu
+ `specification` (un dictionnaire) en 17. Mesuré sur une base 18 :
+ seize applications sur vingt-cinq échouaient sur
+ « unexpected keyword argument 'fields' », c'est-à-dire sur MON
+ appel, pas sur la base. `search_read` n'a pas bougé depuis la 12.
+
+ Une vue peut se rendre sur un modèle vide et exploser sur de
+ vraies données : un champ calculé qui ne résout plus ne se voit
+ qu'une fois qu'il y a quelque chose à calculer.
+ """
+ return self.call_kw(
+ model,
+ "search_read",
+ [domain, lst_field or ["display_name"]],
+ {"limit": limit, "context": context or {}},
+ )
+
+ def known_fields(self, model, context=None):
+ """Les champs que le modèle porte VRAIMENT, d'après le serveur."""
+ result, error = self.call_kw(
+ model, "fields_get", [[], ["type"]], {"context": context or {}}
+ )
+ if error:
+ return None, error
+ return sorted(result or {}), None
+
+
+def arch_fields(views):
+ """Les champs que la première page affiche, et RIEN de plus.
+
+ Analyse XML et non expression régulière, pour une raison mesurée : une
+ vue formulaire embarque les sous-vues de ses one2many, et un
+ `` qui s'y trouve appartient à la LIGNE de
+ facture, pas à la facture. Le motif à plat les ramassait aussi et
+ rapportait neuf applications « nommant un champ que le modèle n'a
+ plus » — toutes fausses. On ne descend donc jamais dans un ``.
+
+ Lire `display_name` seul ne prouverait presque rien : ce sont les
+ colonnes de la liste qui font travailler l'ORM, et c'est là qu'un champ
+ calculé cassé par une migration se manifeste.
+ """
+ import xml.etree.ElementTree as ET
+
+ lst_arch = []
+
+ def descendre(noeud):
+ if isinstance(noeud, dict):
+ arch = noeud.get("arch")
+ if isinstance(arch, str):
+ lst_arch.append(arch)
+ for valeur in noeud.values():
+ descendre(valeur)
+ elif isinstance(noeud, list):
+ for valeur in noeud:
+ descendre(valeur)
+
+ descendre(views)
+
+ lst_name = []
+
+ def parcourir(element):
+ for enfant in element:
+ if enfant.tag == "field":
+ nom = enfant.get("name")
+ # Un champ pointé vise un sous-modèle : le demander au
+ # modèle principal serait une erreur de NOTRE fait.
+ if nom and "." not in nom and nom not in lst_name:
+ lst_name.append(nom)
+ # On s'arrête là : ce qui est SOUS un champ appartient au
+ # modèle de ce champ, pas au nôtre.
+ continue
+ parcourir(enfant)
+
+ for arch in lst_arch:
+ try:
+ racine = ET.fromstring(arch)
+ except ET.ParseError:
+ # Un arch illisible n'est pas une liste de champs vide : c'est
+ # une inconnue. La taire vaut mieux que d'inventer.
+ continue
+ if racine.tag == "field":
+ nom = racine.get("name")
+ if nom and "." not in nom and nom not in lst_name:
+ lst_name.append(nom)
+ else:
+ parcourir(racine)
+ return lst_name
+
+
+def model_is_unregistered(error):
+ """L'erreur dit-elle « ce modèle n'existe pas dans ce registre » ?
+
+ Odoo rend un 404 nu quand `call_kw` vise un modèle absent du registre :
+ « 404 Not Found: The requested URL was not found ». C'est illisible, et
+ pourtant c'est la trouvaille la plus nette d'une migration — le module
+ est installé dans la base, mais son code n'est plus dans l'addons path
+ de la version cible. Mesuré : cinq applications sur vingt-cinq.
+ """
+ return "notfound" in (error.get("name") or "").lower().replace(".", "")
+
+
+def _is_missing_method(error):
+ """L'erreur dit-elle « cette méthode n'existe pas » ?
+
+ Il faut la distinguer d'une vraie panne : retomber sur l'ancien nom
+ après une AccessError masquerait l'AccessError derrière un
+ « méthode inconnue » qui n'a rien à voir.
+ """
+ texte = f"{error.get('name', '')} {error.get('message', '')}".lower()
+ return (
+ "not exist" in texte
+ or "unknown method" in texte
+ or "attributeerror" in texte
+ or "has no attribute" in texte
+ )
+
+
+def menu_rows(session):
+ """Les menus que CET utilisateur voit, dans l'ordre où il les voit.
+
+ Par RPC et non par SQL : `ir.ui.menu` filtre selon les droits et rend
+ les noms traduits. Lire la table donnerait des menus inaccessibles et,
+ depuis la 16, un nom en jsonb qu'il faudrait décoder à la main.
+ """
+ return session.call_kw(
+ "ir.ui.menu",
+ "search_read",
+ [[], ["id", "name", "parent_id", "sequence", "action"]],
+ {"context": {"lang": "en_US"}},
+ )
+
+
+def apps(lst_menu):
+ """(application, menu de sa première page) pour chaque application.
+
+ La première page d'une application est ce que le client web ouvre au
+ clic : le premier menu, en descendant, qui porte une action. Une
+ application qui n'en a aucune n'est pas une erreur — elle n'a rien à
+ ouvrir, et le dire évite de la chercher.
+ """
+ par_parent = {}
+ for menu in lst_menu:
+ parent = menu.get("parent_id")
+ cle = parent[0] if isinstance(parent, (list, tuple)) else None
+ par_parent.setdefault(cle, []).append(menu)
+ for lst in par_parent.values():
+ lst.sort(key=lambda m: (m.get("sequence") or 0, m.get("id") or 0))
+
+ def descendre(menu, vus):
+ if menu["id"] in vus:
+ return None
+ vus.add(menu["id"])
+ if menu.get("action"):
+ return menu
+ for enfant in par_parent.get(menu["id"], []):
+ trouve = descendre(enfant, vus)
+ if trouve:
+ return trouve
+ return None
+
+ resultat = []
+ for racine in par_parent.get(None, []):
+ resultat.append((racine, descendre(racine, set())))
+ return resultat
+
+
+def actionable(lst_menu):
+ """TOUS les menus portant une action, pour le balayage complet."""
+ return [menu for menu in lst_menu if menu.get("action")]
+
+
+def split_action(reference):
+ """« ir.actions.act_window,42 » -> (« ir.actions.act_window », 42)."""
+ if not reference or "," not in str(reference):
+ return None, None
+ model, _sep, ident = str(reference).partition(",")
+ try:
+ return model.strip(), int(ident)
+ except ValueError:
+ return model.strip(), None
+
+
+def literal(value, fallback):
+ """Un domaine ou un contexte, s'il est LITTÉRAL. Sinon le repli.
+
+ Ils contiennent parfois des expressions Python — `uid`, `context_today`
+ — que seul le client sait évaluer. Les évaluer ici serait exécuter du
+ code venu de la base ; les refuser tout court retirerait la moitié des
+ applications du balayage. On teste donc avec un domaine vide, et on le
+ DIT dans le rapport.
+ """
+ if not value or value in ("[]", "{}"):
+ return fallback, False
+ try:
+ return ast.literal_eval(value), False
+ except (ValueError, SyntaxError):
+ return fallback, True
+
+
+def view_pairs(view_mode):
+ """« list,form » -> [[False, « list »], [False, « form »]].
+
+ `qweb` est écarté : son rendu demande un enregistrement précis et un
+ contexte de rapport, pas une ouverture de menu.
+ """
+ lst = []
+ for mode in (view_mode or "list,form").split(","):
+ mode = mode.strip()
+ if mode and mode != "qweb":
+ lst.append([False, mode])
+ return lst or [[False, "form"]]
+
+
+def blank_entry(app, menu, kind=None, action=None):
+ """La forme commune d'un résultat. Une seule, pour un seul rapport.
+
+ Le portail et les applications ne se testent pas de la même façon —
+ l'un par HTTP, l'autre par RPC — mais un lecteur n'a pas à connaître
+ cette différence pour lire ce qui a cassé.
+ """
+ return {
+ "app": app,
+ "menu": menu,
+ "action": action,
+ "model": None,
+ "kind": kind,
+ "error": None,
+ "stage": None,
+ "domain_ignored": False,
+ "unknown_fields": [],
+ "fields_read": 0,
+ "status": None,
+ }
+
+
+def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT):
+ """Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé."""
+ resultat = blank_entry(
+ app["name"], menu["name"], action=menu.get("action")
+ )
+ model_action, ident = split_action(menu.get("action"))
+ resultat["kind"] = model_action
+ if model_action != "ir.actions.act_window" or not ident:
+ # Une action client n'a pas d'arch à rendre, une action serveur
+ # ÉCRIT. Ni l'une ni l'autre ne se teste en ouvrant une page.
+ return resultat
+ fields = ["res_model", "view_mode", "domain", "context", "limit"]
+ lst_action, error = session.call_kw(
+ "ir.actions.act_window", "read", [[ident], fields]
+ )
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = "action"
+ return resultat
+ if not lst_action:
+ return resultat
+ action = lst_action[0]
+ model = action.get("res_model")
+ resultat["model"] = model
+ if not model:
+ return resultat
+ domain, brut_domaine = literal(action.get("domain"), [])
+ context, brut_contexte = literal(action.get("context"), {})
+ if not isinstance(domain, list):
+ domain, brut_domaine = [], True
+ if not isinstance(context, dict):
+ context, brut_contexte = {}, True
+ resultat["domain_ignored"] = brut_domaine or brut_contexte
+
+ views, error = session.views_of(
+ model, view_pairs(action.get("view_mode")), context
+ )
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = (
+ "registry" if model_is_unregistered(error) else "views"
+ )
+ return resultat
+
+ # Les champs de la page, et ceux que le modèle porte vraiment. L'écart
+ # entre les deux EST le dégât d'une migration : une vue qui nomme un
+ # champ disparu. Certaines versions le laissent passer au rendu et
+ # n'échouent qu'à la lecture — autant le nommer tout de suite.
+ lst_arch = arch_fields(views)
+ lst_known, error = session.known_fields(model, context)
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = "fields"
+ return resultat
+ resultat["unknown_fields"] = [
+ name for name in lst_arch if name not in lst_known
+ ]
+ lst_read = [name for name in lst_arch if name in lst_known]
+ resultat["fields_read"] = len(lst_read)
+
+ _rows, error = session.first_page(
+ model,
+ domain,
+ context,
+ min(limit, action.get("limit") or limit),
+ lst_field=lst_read or ["display_name"],
+ )
+ if error:
+ resultat["error"] = error
+ resultat["stage"] = "records"
+ return resultat
+
+
+PORTAL_KIND = "portal"
+DEFAULT_PORTAL_PATHS = ("/my",)
+
+# La page de connexion se reconnaît à SES DEUX champs. Chercher « login »
+# seul attraperait la moitié des pages du portail, où le mot est partout.
+RE_LOGIN_FORM = re.compile(
+ r"""name=["']login["'][\s\S]{0,2000}?name=["']password["']""", re.I
+)
+
+# La trace d'Odoo dans une page d'erreur : la ligne qui NOMME l'exception.
+RE_TRACE = re.compile(r"^\s*([\w.]+(?:Error|Exception)):\s*(.+)$", re.M)
+
+
+def error_from_page(body):
+ """Ce qu'une page d'erreur dit d'elle-même, ou son début à défaut.
+
+ Un « HTTP 500 » sans plus arrête l'enquête là où elle commence : Odoo
+ imprime pourtant l'exception dans la page quand il est en mode debug,
+ et c'est exactement ce qu'on veut lire.
+ """
+ match = RE_TRACE.search(body or "")
+ if match:
+ return f"{match.group(1)}: {match.group(2).strip()}"[:200]
+ # Retirer style et script AVANT les balises : sinon on rend « html {
+ # font-size: 14px; } Home Back 500 » — du CSS, mesuré tel quel.
+ texte = re.sub(
+ r"<(script|style)[^>]*>[\s\S]*?\1>", " ", body or "", flags=re.I
+ )
+ texte = re.sub(r"<[^>]+>", " ", texte)
+ texte = " ".join(texte.split())
+ return texte[:200]
+
+
+# La dernière ligne d'une trace Python : celle qui NOMME la panne.
+RE_LOG_EXCEPTION = re.compile(
+ r"^(\w[\w.]*(?:Error|Exception|NotFound)):\s*(.*)$", re.M
+)
+
+
+def log_exceptions(lst_log):
+ """Les exceptions du journal du serveur, dans l'ordre.
+
+ La page d'erreur d'Odoo en production ne montre PAS la trace : on
+ obtient « 500: Internal Server Error » et rien d'autre. Le journal, lui,
+ la contient — et c'est la seule chose qui permette de réparer plutôt que
+ de constater.
+ """
+ texte = "\n".join(lst_log or [])
+ return [
+ f"{nom}: {reste.strip()}"[:200]
+ for nom, reste in RE_LOG_EXCEPTION.findall(texte)
+ ]
+
+
+# La ligne d'accès de werkzeug : c'est elle qui porte le CHEMIN, donc la
+# seule façon de savoir à qui appartient une trace.
+RE_ACCESS = re.compile(r'"(?:GET|POST|PUT|DELETE) (\S+) HTTP/[\d.]+" (\d{3})')
+
+
+def reason_for_path(lst_log, path):
+ """La dernière exception loggée AVANT la réponse en échec de ce chemin.
+
+ Prendre simplement la dernière trace du journal était faux, et l'essai
+ réel l'a montré tout de suite : le portail est interrogé en PREMIER,
+ donc sa trace est la première du fichier, et on lui attribuait celle
+ d'une application testée bien après. Une cause fausse coûte plus cher
+ qu'une cause absente — on cherche alors du mauvais côté, avec
+ confiance.
+
+ Une réponse SAINE pour ce chemin efface l'ardoise : ce qui précédait
+ appartenait à quelqu'un d'autre.
+ """
+ attente = None
+ prefixe = (path or "").rstrip("/")
+ for ligne in lst_log or []:
+ trouve = RE_LOG_EXCEPTION.search(ligne)
+ if trouve:
+ attente = f"{trouve.group(1)}: {trouve.group(2).strip()}"[:200]
+ continue
+ acces = RE_ACCESS.search(ligne)
+ if not acces:
+ continue
+ chemin = acces.group(1).split("?")[0]
+ vise = chemin == path or chemin.startswith(prefixe + "/")
+ if vise and int(acces.group(2)) >= 400:
+ return attente
+ attente = None
+ return None
+
+
+def attach_log_reason(lst_failure, lst_log):
+ """Donner à une page en échec la trace qui la concerne, ELLE.
+
+ On ne touche jamais à un message venu d'un appel RPC : celui-ci nomme
+ précisément sa propre erreur, alors que le journal est un flux partagé.
+ Seules les pages ouvertes en HTTP ont besoin de cette recherche — leur
+ page d'erreur, en production, ne montre rien d'utile.
+ """
+ for item in lst_failure:
+ if item.get("kind") != PORTAL_KIND:
+ continue
+ raison = reason_for_path(lst_log, item.get("menu") or "")
+ if raison:
+ (item.get("error") or {})["log"] = raison
+ return lst_failure
+
+
+def check_portal(session, path):
+ """Ouvrir une page du portail AVEC la session connectée.
+
+ Le portail n'est ni le site public ni le back-office : c'est un
+ troisième rendu, en QWeb frontend, avec ses compteurs qui interrogent
+ chacun leur modèle. Ni le sitemap — qui ne liste pas /my, la page
+ demandant une session — ni les appels RPC des applications ne passent
+ par là. Une migration peut donc le casser sans que rien ne le dise.
+ """
+ resultat = blank_entry(t("Portal"), path, kind=PORTAL_KIND)
+ status, body = session.open(path)
+ resultat["status"] = status
+ if not status:
+ resultat["error"] = {
+ "name": "transport",
+ "message": (body or "")[:200],
+ }
+ resultat["stage"] = PORTAL_KIND
+ return resultat
+ if status >= 400:
+ resultat["error"] = {
+ "name": f"HTTP {status}",
+ "message": error_from_page(body),
+ }
+ resultat["stage"] = PORTAL_KIND
+ return resultat
+ # 200 ET la page de connexion : on n'a pas vu /my, on a vu le portier.
+ # Le compter comme une réussite serait le mensonge le plus coûteux ici.
+ redirige = "/web/login" in (session.last_url or "")
+ if redirige or RE_LOGIN_FORM.search(body or ""):
+ resultat["error"] = {
+ "name": t("not signed in"),
+ "message": t("the page served was the login form"),
+ }
+ resultat["stage"] = PORTAL_KIND
+ return resultat
+
+
+def crawl(
+ session,
+ limit=DEFAULT_RECORD_LIMIT,
+ every_menu=False,
+ lst_portal=DEFAULT_PORTAL_PATHS,
+):
+ """Parcourir le portail et les applications. Rend (visitées, échecs)."""
+ lst_menu, error = menu_rows(session)
+ if error:
+ raise RuntimeError(
+ f"{t('Could not read the menus')} : {error['name']}"
+ f" {error['message']}"[:300]
+ )
+ if every_menu:
+ par_id = {menu["id"]: menu for menu in lst_menu}
+ lst_entry = [
+ (_root_of(menu, par_id), menu) for menu in actionable(lst_menu)
+ ]
+ else:
+ lst_entry = [
+ (app, first) for app, first in apps(lst_menu) if first is not None
+ ]
+ lst_result = [check_portal(session, path) for path in lst_portal or ()]
+ lst_result += [
+ check_entry(session, app, menu, limit=limit) for app, menu in lst_entry
+ ]
+ return lst_result, [item for item in lst_result if item["error"]]
+
+
+def _root_of(menu, par_id):
+ """Remonter jusqu'à l'application, pour dire d'où vient le menu."""
+ vus = set()
+ courant = menu
+ while True:
+ parent = courant.get("parent_id")
+ cle = parent[0] if isinstance(parent, (list, tuple)) else None
+ if cle is None or cle in vus or cle not in par_id:
+ return courant
+ vus.add(cle)
+ courant = par_id[cle]
+
+
+def render(lst_result, lst_failure):
+ """Le rapport. Ce qui a ouvert, et ce qui a refusé de s'ouvrir."""
+ lignes = []
+ # Le portail est compté À PART : noyé dans le total des applications,
+ # « 22/23 » ne dirait pas laquelle des deux choses a cassé, alors que
+ # ce sont deux rendus sans rapport — QWeb frontend d'un côté, vues
+ # back-office de l'autre.
+ portail = [item for item in lst_result if item["kind"] == PORTAL_KIND]
+ lst_result = [item for item in lst_result if item["kind"] != PORTAL_KIND]
+ lst_failure = [item for item in lst_failure if item["kind"] != PORTAL_KIND]
+ if portail:
+ casse = [item for item in portail if item["error"]]
+ lignes.append(
+ f"\n✨ {t('Portal pages opened as the test user')} :"
+ f" {len(portail) - len(casse)}/{len(portail)}"
+ )
+ for item in portail:
+ marque = "❌" if item["error"] else "✅"
+ detail = (
+ f" — {item['error']['name']} :"
+ f" {item['error']['message'][:120]}"
+ if item["error"]
+ else f" — HTTP {item['status']}"
+ )
+ lignes.append(f" {marque} {item['menu']}{detail}")
+ raison = (item["error"] or {}).get("log")
+ if raison:
+ lignes.append(f" {t('server log')} : {raison}")
+ lignes.append(
+ f"\n✨ {t('Apps opened as the test user')} :"
+ f" {len(lst_result) - len(lst_failure)}/{len(lst_result)}"
+ )
+ saute = [
+ item
+ for item in lst_result
+ if not item["error"] and item["kind"] != "ir.actions.act_window"
+ ]
+ if saute:
+ lignes.append(
+ f" {len(saute)} {t('not openable this way (client or server')}"
+ f" {t('action): nothing to render.')}"
+ )
+ approx = [item for item in lst_result if item["domain_ignored"]]
+ if approx:
+ lignes.append(
+ f" {len(approx)} {t('opened with an empty domain: theirs')}"
+ f" {t('needs the browser to evaluate it.')}"
+ )
+ fantomes = [item for item in lst_result if item.get("unknown_fields")]
+ if fantomes:
+ lignes.append(
+ f"\n⚠️ {t('Views naming a field the model no longer has')} :"
+ )
+ for item in fantomes:
+ lignes.append(
+ f" · {item['app']} [{item['model']}] :"
+ f" {', '.join(item['unknown_fields'][:8])}"
+ )
+ if not lst_failure:
+ if not any(item["error"] for item in portail):
+ lignes.append(f"✅ {t('Every app opened its first page.')}")
+ return "\n".join(lignes)
+ lignes.append(f"\n❌ {t('Apps that failed to open')} :")
+ ETAPE = {
+ "action": t("reading the action"),
+ "views": t("rendering the views"),
+ "fields": t("listing the model fields"),
+ "records": t("loading the first records"),
+ "registry": t("model absent from the running registry"),
+ }
+ for item in lst_failure:
+ error = item["error"]
+ lignes.append(
+ f" · {item['app']} → {item['menu']}"
+ f" [{item['model'] or '-'}]"
+ )
+ lignes.append(
+ f" {ETAPE.get(item['stage'], item['stage'])} :"
+ f" {error['name']}"
+ )
+ if item["stage"] == "registry":
+ # Le 404 nu d'Odoo ne dit rien d'utile ; ce qu'il faut savoir,
+ # c'est que le code du module manque à CETTE version.
+ lignes.append(
+ f" {t('installed in the database, but its module code')}"
+ f" {t('is not in this addons path.')}"
+ )
+ else:
+ message = (error.get("message") or "").strip().splitlines()
+ if message:
+ lignes.append(f" {message[0][:160]}")
+ lignes.append(
+ f"\nℹ {t('These are back-office failures: the public smoke test')}"
+ f" {t('cannot see them.')}"
+ )
+ return "\n".join(lignes)
+
+
+def run(
+ base_url,
+ database,
+ login,
+ password,
+ limit,
+ every_menu=False,
+ lst_portal=DEFAULT_PORTAL_PATHS,
+):
+ """Se connecter puis parcourir. Lève RuntimeError si l'on ne peut pas."""
+ session = Session(base_url)
+ ok, raison = session.log_in(database, login, password)
+ if not ok:
+ raise RuntimeError(f"{t('Could not log in as')} '{login}' : {raison}")
+ return crawl(
+ session, limit=limit, every_menu=every_menu, lst_portal=lst_portal
+ )
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Log in as the neutralization test user and open the first page"
+ " of every Odoo app, reporting what fails."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", default="./config.conf")
+ parser.add_argument("-p", "--port", type=int, default=None)
+ parser.add_argument("--login", default=DEFAULT_LOGIN)
+ parser.add_argument("--password", default=DEFAULT_PASSWORD)
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=DEFAULT_RECORD_LIMIT,
+ help="how many records to load per app",
+ )
+ parser.add_argument(
+ "--all-menus",
+ action="store_true",
+ help="open every menu with an action, not just each app's first page",
+ )
+ parser.add_argument(
+ "--portal",
+ default=",".join(DEFAULT_PORTAL_PATHS),
+ help="portal paths to open while signed in (comma separated, empty"
+ " to skip)",
+ )
+ parser.add_argument(
+ "--boot-timeout",
+ type=int,
+ default=180,
+ help="how long to wait for the server to answer",
+ )
+ config = parser.parse_args(argv)
+
+ from smoke_public_url import (
+ DEFAULT_PORT,
+ port_is_taken,
+ read_log,
+ start_server,
+ stop_server,
+ wait_ready,
+ )
+
+ port = config.port or DEFAULT_PORT
+ etat = user_state(config.database, config.login)
+ if etat == "unknown":
+ # Ne PAS dire « pas neutralisée » : on ne le sait pas. Le dire
+ # ferait croire que le back-office va bien alors qu'on n'a rien vu.
+ print(
+ f"❌ {t('Could not tell whether the')} '{config.login}'"
+ f" {t('user exists in')} '{config.database}'."
+ )
+ return 2
+ if etat == "absent":
+ print(
+ f"ℹ️ {t('No')} '{config.login}' {t('user in')}"
+ f" '{config.database}' :"
+ f" {t('the database was not neutralized, nothing to browse.')}"
+ )
+ return 0
+ from smoke_public_url import require_matching_version
+
+ mismatch = require_matching_version(config.database)
+ if mismatch:
+ print(f"⛔ {mismatch}")
+ return 2
+ if port_is_taken(port):
+ print(
+ f"❌ {t('Something already listens on port')} {port} :"
+ f" {t('it would be tested instead of this database.')}"
+ )
+ return 2
+ base_url = f"http://127.0.0.1:{port}"
+ print(
+ f"⧖ {t('Starting Odoo on')} '{config.database}'"
+ f" ({t('port')} {port})…"
+ )
+ import tempfile
+
+ log_path = os.path.join(
+ tempfile.gettempdir(),
+ f"erplibre_internal_{config.database}_{port}.log",
+ )
+ server = start_server(
+ config.database, port, config.config, log_path=log_path
+ )
+ try:
+ if not wait_ready(base_url, timeout=config.boot_timeout):
+ print(f"❌ {t('The server never answered on')} {base_url}")
+ return 2
+ lst_result, lst_failure = run(
+ base_url,
+ config.database,
+ config.login,
+ config.password,
+ config.limit,
+ every_menu=config.all_menus,
+ lst_portal=[
+ path.strip()
+ for path in (config.portal or "").split(",")
+ if path.strip()
+ ],
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ finally:
+ # Le journal se lit APRÈS l'arrêt : un serveur qui écrit dans un
+ # fichier bufferise et ne vide qu'en s'arrêtant. Lire avant donnait
+ # les lignes de démarrage et rien de la panne.
+ stop_server(server)
+ attach_log_reason(lst_failure, read_log(log_path))
+ print(render(lst_result, lst_failure))
+ return 1 if lst_failure else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py
new file mode 100755
index 0000000..501fe76
--- /dev/null
+++ b/script/odoo/migration/smoke_public_url.py
@@ -0,0 +1,914 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Request every public URL of a migrated database, and report what breaks.
+
+Why this exists
+---------------
+A migration can finish, load every module, and still serve a 500 on a page
+nobody thought to open. Measured on a real one: ``/blog//post/``
+answered 500 because a COW copy frozen on the previous version no longer held
+the section a child view xpaths into. Nothing in the migration log said so —
+the module loading had succeeded.
+
+Where the list comes from
+-------------------------
+``/sitemap.xml``: the list Odoo itself publishes for search engines, built by
+``website.enumerate_pages()``. It covers the controller routes declared with
+``sitemap=True`` and the records behind them — pages, blog posts, products.
+
+It cannot be read from ``odoo-bin shell``: ``enumerate_pages()`` asks for
+``http.root.get_db_router(request.db)`` and raises « object unbound » without
+a real request. So the server is started, asked, and stopped.
+
+What counts as a failure
+------------------------
+Any status >= 400. The sitemap is the PUBLIC list: a page listed there and
+answering 403 or 404 is as wrong as one answering 500, just less loud.
+
+Exit codes: 0 every URL answered, 1 some failed, 2 the tool failed.
+"""
+
+import argparse
+import os
+import re
+import signal
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+try:
+ from script.todo import auto_ask
+except Exception: # pragma: no cover - repli si le pilote est absent
+ auto_ask = None
+
+
+def can_ask():
+ """Peut-on poser une question ICI ?
+
+ Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
+ terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
+ stdin laisse poser une invite qui part dans un tube : elle reste en
+ tampon, invisible, pendant que le processus attend — on croit à un
+ blocage et l'on tape Entrée à l'aveugle.
+ """
+ return sys.stdin.isatty() and sys.stdout.isatty()
+
+
+RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I)
+
+# « [view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281] ». Seule la
+# phrase qui précède est traduite ; cette ligne-ci ne l'est pas, donc la lire
+# marche dans les deux langues. Le PARENT est le coupable : c'est la copie
+# figée dans laquelle l'enfant ne trouve plus son xpath.
+RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]")
+
+# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le
+# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie
+# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans
+# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue
+# fautive.
+RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M)
+
+# Un port à part : la migration tourne souvent à côté d'une instance vivante,
+# et lui voler 8069 ferait échouer le test pour une raison sans rapport.
+DEFAULT_PORT = 8169
+
+
+def run_psql(database, sql):
+ """Lecture seule, garantie par le serveur PostgreSQL lui-même."""
+ env = os.environ.copy()
+ env["PGOPTIONS"] = "-c default_transaction_read_only=on"
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ ["psql", "-X", "-w", "-d", database, "-tAF", "\x1f", "-c", sql],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ return []
+ return [line.split("\x1f") for line in done.stdout.splitlines() if line]
+
+
+def require_matching_version(database):
+ """Refuser de démarrer sur une base d'une autre version qu'un Odoo.
+
+ Sans cela le rapport ment de la pire façon : mesuré ici même, un
+ checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
+ trente-sept URL et sur /web/login, et l'on conclut à un site
+ entièrement cassé alors que rien ne l'est. Le checkout suit la
+ migration ; rien ne garantit qu'il soit resté sur la version de la
+ base qu'on veut interroger.
+
+ On délègue à `database_cleanup`, qui porte déjà cette garde : deux
+ implémentations divergeraient, et une garde qui diverge ne garde rien.
+ """
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ try:
+ from database_cleanup import require_matching_version as verifier
+ except ImportError:
+ return None
+ return verifier(database)
+
+
+def start_server(database, port, config_path="./config.conf", log_path=None):
+ """Démarrer Odoo, son journal dans un FICHIER.
+
+ Pas un tube : Python bufferise par blocs quand sa sortie n'est pas un
+ terminal, et un fil de lecture court alors après des lignes qui n'ont
+ pas encore été écrites. Mesuré — 24 lignes vues sur 198, et la trace qui
+ nomme la vue fautive faisait partie des absentes. Un fichier se relit
+ entièrement, quand on veut.
+ """
+ handle = open(log_path, "w", encoding="utf-8") if log_path else None
+ server = subprocess.Popen(
+ [
+ "./run.sh",
+ "-c",
+ config_path,
+ "-d",
+ database,
+ "--http-port",
+ str(port),
+ "--log-level=warn",
+ # Deux lignes par requête, et elles portent le CHEMIN. Sans
+ # elles une trace du journal ne peut être rattachée à rien :
+ # on l'attribuait à la dernière requête, qui n'était pas la
+ # sienne. Mesuré — /my accusé d'une panne appartenant à une
+ # application testée après lui.
+ "--log-handler=werkzeug:INFO",
+ ],
+ stdout=handle or subprocess.DEVNULL,
+ stderr=subprocess.STDOUT,
+ text=True,
+ # Son propre groupe de processus : « ./run.sh » est un script bash
+ # qui ne transmet rien à son enfant. Un terminate() sur lui tuait le
+ # script et laissait odoo-bin vivant, tenant le port. L'essai suivant
+ # démarrait alors un serveur qui ne pouvait pas se lier, et
+ # interrogeait sans le savoir CELUI D'AVANT — mesuré, deux fois.
+ start_new_session=True,
+ )
+ server.erplibre_log = handle
+ return server
+
+
+def read_log(log_path):
+ """Le journal du serveur, tel qu'écrit jusqu'ici."""
+ if not log_path or not os.path.isfile(log_path):
+ return []
+ with open(log_path, "r", encoding="utf-8", errors="replace") as handle:
+ return handle.read().splitlines()
+
+
+def fetch(url, timeout=30):
+ """(statut, corps). Statut 0 quand la connexion elle-même échoue."""
+ try:
+ with urllib.request.urlopen(url, timeout=timeout) as answer:
+ return answer.getcode(), answer.read().decode(
+ "utf-8", errors="replace"
+ )
+ except urllib.error.HTTPError as exc:
+ return exc.code, exc.read().decode("utf-8", errors="replace")
+ except Exception:
+ return 0, ""
+
+
+def wait_ready(base_url, timeout=180, sleep=2):
+ """Attendre que le serveur réponde. False s'il n'est jamais venu."""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ status, _body = fetch(base_url + "/web/login", timeout=5)
+ if status:
+ return True
+ time.sleep(sleep)
+ return False
+
+
+def sitemap_urls(base_url):
+ """Les URL du sitemap, index compris, ramenées sur l'hôte local.
+
+ Le sitemap porte le domaine du site (technolibre.ca) ; on teste une base
+ servie en local. Garder le domaine ferait interroger la production —
+ c'est le genre d'erreur qui ne se voit qu'après.
+ """
+ status, body = fetch(base_url + "/sitemap.xml")
+ if not status or status >= 400:
+ return [], status
+ lst_loc = RE_LOC.findall(body)
+ # Un index de sitemaps ne contient que des sitemaps : on descend d'un cran.
+ if "= 400:
+ lst_failure.append((url, status, []))
+ return lst_failure
+
+
+def template_keys(lst_log, database):
+ """Les gabarits nommés par une QWebException, s'ils ont une copie COW.
+
+ Nommer une clé sans copie enverrait réinitialiser une vue module — donc
+ ne rien faire, en silence. On ne propose que ce qui peut l'être.
+ """
+ lst_key = []
+ for line in lst_log:
+ for key in RE_TEMPLATE.findall(line):
+ if key not in lst_key:
+ lst_key.append(key)
+ if not lst_key:
+ return []
+ quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key)
+ rows = run_psql(
+ database,
+ "SELECT DISTINCT key FROM ir_ui_view"
+ f" WHERE website_id IS NOT NULL AND key IN ({quoted});",
+ )
+ with_copy = {row[0] for row in rows if row and row[0]}
+ return [key for key in lst_key if key in with_copy]
+
+
+def culprit_keys(database, lst_failure):
+ """Les clés des vues parentes mises en cause, sans doublon.
+
+ C'est ce qu'on passe à reset_stale_cow_views : il travaille par clé, et
+ relever un identifiant dans une trace pour le traduire à la main est
+ exactement la recopie où l'on se trompe.
+ """
+ lst_id = []
+ for _url, _status, lst_parent in lst_failure:
+ for parent_id in lst_parent:
+ if parent_id not in lst_id:
+ lst_id.append(parent_id)
+ if not lst_id:
+ return []
+ ids = ",".join(str(int(x)) for x in lst_id)
+ rows = run_psql(
+ database,
+ f"SELECT id, key, website_id FROM ir_ui_view WHERE id IN ({ids})"
+ " AND key IS NOT NULL ORDER BY id;",
+ )
+ lst_key = []
+ for row in rows:
+ if len(row) >= 2 and row[1] and row[1] not in lst_key:
+ lst_key.append(row[1])
+ return lst_key
+
+
+def render(lst_url, lst_failure, lst_key=None):
+ if not lst_url:
+ return f"⚠️ {t('The sitemap listed no URL: nothing was tested.')}\n"
+ if not lst_failure:
+ return (
+ f"✅ -> {len(lst_url)} {t('public URL(s) answered without error.')}"
+ "\n"
+ )
+ lines = [
+ f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}"
+ f" {t('public URL(s) failed')} :"
+ ]
+ for url, status, lst_parent in lst_failure:
+ label = status or t("no answer")
+ lines.append(f" [{label}] {url}")
+ if lst_parent:
+ lines.append(
+ f" {t('parent view(s) in cause')} :"
+ f" {', '.join(lst_parent)}"
+ )
+ if lst_key:
+ lines.append(
+ f" {t('Those parents are copies frozen on an older version;')}"
+ f" {t('resetting them onto the module view is the fix')} :"
+ )
+ for key in lst_key:
+ lines.append(f" {key}")
+ lines.append(
+ f" {t('A page listed for search engines that does not answer is')}"
+ f" {t('a page your visitors do not reach either.')}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def apply_reset(database, lst_key):
+ """Réinitialiser ces copies sur leur vue module. ÉCRIT en base.
+
+ On délègue à reset_stale_cow_views : il sauvegarde l'arch précédente
+ avant d'écrire, et c'est déjà lui qu'on documente partout ailleurs. En
+ refaire une seconde version ici, c'est se donner deux comportements à
+ tenir d'accord.
+ """
+ cmd = [
+ sys.executable,
+ os.path.join(
+ "script", "odoo", "migration", "reset_stale_cow_views.py"
+ ),
+ "-d",
+ database,
+ ]
+ for key in lst_key:
+ cmd += ["--reset", key]
+ cmd.append("--apply")
+ done = subprocess.run(cmd, capture_output=True, text=True)
+ return done.returncode, done.stdout + done.stderr
+
+
+DEFAULT_ANSWER = "a"
+
+
+def prompt(database, lst_failure, lst_key, ask=None):
+ """Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées.
+
+ Détecter sans offrir le geste, c'est laisser relever des identifiants
+ dans une trace pour les traduire en clés à la main — au moment précis où
+ l'on veut juste que la page réponde.
+ """
+ if not lst_key:
+ print(f"ℹ -> {t('No parent view named: nothing to offer.')}")
+ return []
+ if ask is None:
+ # Lancé à part par la migration : sans lecteur temporisé, cette
+ # question arrêtait net une exécution automatique.
+ ask = (
+ auto_ask.make_ask(DEFAULT_ANSWER)
+ if auto_ask
+ else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
+ )
+ print(f"\n✨ {t('Copies to reset onto their module view')} :")
+ for index, key in enumerate(lst_key, start=1):
+ print(f" [{index}] {key}")
+ print(f" [a] {t('All of the list above')}")
+ answer = (
+ ask(
+ f"💬 {t('Which one(s) to reset?')}"
+ f" ({t('numbers separated by commas, Enter = all, n =')}"
+ f" {t('nothing')}) : "
+ )
+ .strip()
+ .lower()
+ )
+ # « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une
+ # sortie sans mot pour dire non serait une sortie sans issue.
+ if not answer or answer == "n":
+ print(f"ℹ -> {t('Kept. Nothing was reset.')}")
+ return []
+ if answer == "a":
+ lst_chosen = list(lst_key)
+ else:
+ lst_chosen = []
+ for part in answer.replace(" ", "").split(","):
+ if part.isdigit() and 1 <= int(part) <= len(lst_key):
+ lst_chosen.append(lst_key[int(part) - 1])
+ if not lst_chosen:
+ print(f"⚠️ {t('Unknown choice, nothing was reset.')}")
+ return []
+ status, output = apply_reset(database, lst_chosen)
+ print(output.strip()[-2000:])
+ if status == 2:
+ print(f"❌ {t('Reset failed, nothing was changed.')}")
+ return []
+ return lst_chosen
+
+
+def render_internal(internal):
+ """Afficher le rapport du back-office. Rend True s'il a échoué.
+
+ Ne rien afficher quand la base n'a pas été neutralisée serait laisser
+ croire que le back-office a été testé et qu'il va bien.
+ """
+ if internal is None:
+ return False
+ reprise = f" ({t('after the reset')})" if internal.get("retried") else ""
+ if "skipped" in internal:
+ if internal.get("loud"):
+ # Un saut ATTENDU se dit à voix basse ; un saut qui trahit une
+ # panne doit compter comme un échec, sinon le code de sortie
+ # annonce que tout va bien.
+ print(
+ f"\n⚠️ {t('Back office NOT browsed')}{reprise} :"
+ f" {internal['skipped']}"
+ )
+ return True
+ print(
+ f"\nℹ️ {t('Back office not browsed')}{reprise} :"
+ f" {internal['skipped']}"
+ )
+ return False
+ import smoke_internal_ui
+
+ print(smoke_internal_ui.render(internal["results"], internal["failures"]))
+ return bool(internal["failures"])
+
+
+def attach_internal_log(internal_report, log_path):
+ """Donner à la passe back-office la trace que sa page ne montrait pas.
+
+ APRÈS l'arrêt du serveur, et pas avant : un serveur qui écrit dans un
+ fichier bufferise et ne vide qu'en s'arrêtant. La page d'erreur d'Odoo
+ en production, elle, se contente de « 500: Internal Server Error » —
+ de quoi constater, pas de quoi réparer.
+ """
+ if not internal_report or not internal_report.get("failures"):
+ return
+ try:
+ import smoke_internal_ui
+ except ImportError:
+ return
+ smoke_internal_ui.attach_log_reason(
+ internal_report["failures"], read_log(log_path)
+ )
+
+
+def internal_needs_retry(internal_report):
+ """Le back-office mérite-t-il un second essai après la réparation ?
+
+ Oui s'il a échoué, et oui aussi s'il a été SAUTÉ : « connexion
+ impossible » est justement le symptôme d'un site cassé, et c'est ce
+ que la réinitialisation vient de réparer.
+ """
+ if not internal_report:
+ return False
+ return bool(
+ internal_report.get("failures") or internal_report.get("skipped")
+ )
+
+
+def internal_phase(
+ base_url,
+ database,
+ enabled=True,
+ login="test",
+ password="test",
+ limit=20,
+ every_menu=False,
+ lst_portal=None,
+ required=False,
+):
+ """Le back-office, si la base a été neutralisée. Rend None sinon.
+
+ La condition n'est pas un drapeau mais l'utilisateur lui-même : la
+ neutralisation pose un compte `test` avec les groupes du
+ superutilisateur, et c'est le seul moyen d'entrer sans connaître le mot
+ de passe de quelqu'un. Une migration reprise a pu sauter l'étape, et le
+ fichier de progression dirait quand même « fait ».
+
+ Un échec ICI ne doit pas emporter le test public : il est mesuré, il
+ est rapporté, mais le rapport des URL publiques a sa propre valeur.
+ """
+ if not enabled:
+ return None
+ try:
+ import smoke_internal_ui
+ except ImportError:
+ # Se taire ici ferait disparaître la passe ENTIÈRE sans un mot, et
+ # l'on croirait le back-office testé. Un outil absent est une
+ # panne d'installation, pas une base saine.
+ return {"skipped": t("smoke_internal_ui.py is missing"), "loud": True}
+ etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
+ if etat == "absent":
+ # `required` dit que la migration a NEUTRALISÉ cette base : le
+ # compte devrait donc y être. Mesuré — il survit jusqu'au palier
+ # 15 puis disparaît, et la passe s'arrêtait sans bruit exactement
+ # là où une migration fait le plus de dégâts.
+ if required:
+ return {
+ "skipped": t(
+ "the test user is gone from a neutralized"
+ " database: the back office was NOT checked"
+ ),
+ "loud": True,
+ }
+ return {"skipped": t("no test user: the database was not neutralized")}
+ if etat != "present":
+ # « je ne sais pas » n'est pas « tout va bien » : le dire autrement
+ # ferait passer un back-office jamais ouvert pour un back-office sain.
+ return {
+ "skipped": t("could not tell whether the test user exists"),
+ "loud": True,
+ }
+ try:
+ lst_result, lst_failure = smoke_internal_ui.run(
+ base_url,
+ database,
+ login,
+ password,
+ limit,
+ every_menu=every_menu,
+ lst_portal=(
+ smoke_internal_ui.DEFAULT_PORTAL_PATHS
+ if lst_portal is None
+ else lst_portal
+ ),
+ )
+ except RuntimeError as exc:
+ return {"skipped": str(exc)}
+ return {"results": lst_result, "failures": lst_failure}
+
+
+def run(
+ database,
+ port,
+ config_path,
+ limit=None,
+ timeout=30,
+ boot=180,
+ interactive=False,
+ auto_apply=False,
+ ask=None,
+ internal=True,
+ internal_login="test",
+ internal_password="test",
+ internal_limit=20,
+ every_menu=False,
+ portal=None,
+ internal_required=False,
+):
+ """Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
+
+ L'ordre porte tout le correctif : un serveur qui écrit dans un fichier
+ bufferise et ne vide qu'en s'arrêtant. Lire avant l'arrêt donnait
+ vingt-quatre lignes de démarrage et zéro trace — donc « aucune vue en
+ cause » sur des pages qui en nommaient une. Mesuré deux fois avant d'être
+ compris.
+ """
+ import tempfile
+
+ base_url = f"http://127.0.0.1:{port}"
+ log_path = os.path.join(
+ tempfile.gettempdir(), f"erplibre_smoke_{database}_{port}.log"
+ )
+ mismatch = require_matching_version(database)
+ if mismatch:
+ raise RuntimeError(mismatch)
+ if port_is_taken(port):
+ raise RuntimeError(
+ f"{t('Something already listens on port')} {port} :"
+ f" {t('it would be tested instead of this database.')}"
+ )
+ server = start_server(database, port, config_path, log_path=log_path)
+ try:
+ if not wait_ready(base_url, timeout=boot):
+ raise RuntimeError(
+ f"{t('The server never answered on')} {base_url}"
+ )
+ lst_url, status = sitemap_urls(base_url)
+ if not status:
+ raise RuntimeError(f"{t('Could not read')} {base_url}/sitemap.xml")
+ if limit:
+ lst_url = lst_url[:limit]
+ lst_failure = check_urls(lst_url, timeout=timeout)
+ # ICI, pendant que le serveur tourne : le démarrage d'Odoo est ce
+ # qui coûte des minutes, pas les requêtes. Un deuxième outil avec
+ # son propre serveur doublerait l'attente pour rien.
+ internal_report = internal_phase(
+ base_url,
+ database,
+ enabled=internal,
+ login=internal_login,
+ password=internal_password,
+ limit=internal_limit,
+ every_menu=every_menu,
+ lst_portal=portal,
+ required=internal_required,
+ )
+ finally:
+ stop_server(server)
+
+ attach_internal_log(internal_report, log_path)
+ lst_key = []
+ if lst_failure:
+ lst_log = read_log(log_path)
+ lst_failure = attach_missing_parents(lst_failure, lst_log)
+ lst_key = culprit_keys(database, lst_failure)
+ # Les deux sources : le contexte d'héritage quand il existe, le nom
+ # du gabarit quand l'échec vient du rendu.
+ for key in template_keys(lst_log, database):
+ if key not in lst_key:
+ lst_key.append(key)
+ if not lst_failure or not (interactive or auto_apply):
+ return lst_url, lst_failure, lst_key, None, internal_report
+
+ print(render(lst_url, lst_failure, lst_key))
+ if auto_apply:
+ lst_done = lst_key
+ if lst_done:
+ code, output = apply_reset(database, lst_done)
+ print(output.strip()[-2000:])
+ if code == 2:
+ lst_done = []
+ else:
+ # `ask=None` et non `input` : c'est `prompt` qui sait quel défaut
+ # lui appartient, et qui pose alors la question par le lecteur
+ # temporisé. Forcer `input` ici court-circuitait le mode auto —
+ # mesuré, l'invite attendait indéfiniment une frappe pendant une
+ # migration lancée en auto-exécution.
+ lst_done = prompt(database, lst_failure, lst_key, ask=ask)
+ if not lst_done:
+ return lst_url, lst_failure, lst_key, None, internal_report
+
+ lst_again, internal_report = recheck_after_reset(
+ database,
+ port,
+ config_path,
+ base_url,
+ log_path,
+ lst_failure,
+ internal_report,
+ timeout=timeout,
+ boot=boot,
+ internal=internal,
+ internal_login=internal_login,
+ internal_password=internal_password,
+ internal_limit=internal_limit,
+ every_menu=every_menu,
+ portal=portal,
+ internal_required=internal_required,
+ )
+ return lst_url, lst_failure, lst_key, lst_again, internal_report
+
+
+def recheck_after_reset(
+ database,
+ port,
+ config_path,
+ base_url,
+ log_path,
+ lst_failure,
+ internal_report,
+ timeout=30,
+ boot=180,
+ internal=True,
+ internal_login="test",
+ internal_password="test",
+ internal_limit=20,
+ every_menu=False,
+ portal=None,
+ internal_required=False,
+):
+ """Redémarrer, et revoir ce qui avait échoué. Rend (URL, back-office).
+
+ Le back-office est REJUGÉ lui aussi, et ce n'est pas une symétrie
+ gratuite : la copie COW qui casse le site casse AUSSI /web/login — les
+ deux passent par le même gabarit. La passe interne tournait donc avant
+ la réparation et rapportait « connexion impossible » sur une base que
+ la réinitialisation remettait d'aplomb quelques secondes plus tard.
+ """
+ server = start_server(database, port, config_path, log_path=log_path)
+ try:
+ if not wait_ready(base_url, timeout=boot):
+ raise RuntimeError(
+ f"{t('The server never answered on')} {base_url}"
+ )
+ lst_again = check_urls(
+ [url for url, _s, _p in lst_failure], timeout=timeout
+ )
+ if internal_needs_retry(internal_report):
+ reprise = internal_phase(
+ base_url,
+ database,
+ enabled=internal,
+ login=internal_login,
+ password=internal_password,
+ limit=internal_limit,
+ every_menu=every_menu,
+ lst_portal=portal,
+ required=internal_required,
+ )
+ if reprise is not None:
+ internal_report = reprise
+ internal_report["retried"] = True
+ finally:
+ stop_server(server)
+ return lst_again, internal_report
+
+
+def stop_server(server):
+ """Arrêter tout le GROUPE : sinon odoo-bin survit à son script."""
+ try:
+ group = os.getpgid(server.pid)
+ except OSError:
+ group = None
+ if group is not None:
+ try:
+ os.killpg(group, signal.SIGTERM)
+ except OSError:
+ pass
+ try:
+ server.wait(timeout=30)
+ except subprocess.TimeoutExpired:
+ if group is not None:
+ try:
+ os.killpg(group, signal.SIGKILL)
+ except OSError:
+ pass
+ server.kill()
+ handle = getattr(server, "erplibre_log", None)
+ if handle:
+ handle.close()
+
+
+def port_is_taken(port, host="127.0.0.1"):
+ """Quelqu'un écoute-t-il déjà là ?
+
+ Sans cette question, un serveur resté d'un essai précédent répond à
+ « le serveur est-il prêt ? », et l'on teste sa base à lui en croyant
+ tester la sienne. C'est exactement ce qui est arrivé ici.
+ """
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(1)
+ return sock.connect_ex((host, port)) == 0
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=(
+ "Start Odoo on a database, request every URL of its sitemap, and"
+ " report those that fail."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", default="./config.conf")
+ parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT)
+ parser.add_argument(
+ "--limit", type=int, default=None, help="test only the first N URLs"
+ )
+ parser.add_argument("--timeout", type=int, default=30)
+ parser.add_argument(
+ "--apply",
+ action="store_true",
+ help="reset the views in cause without asking (WRITES)",
+ )
+ parser.add_argument(
+ "--report-only",
+ action="store_true",
+ help="never ask anything, even in front of a terminal",
+ )
+ parser.add_argument(
+ "--boot-timeout",
+ type=int,
+ default=180,
+ help="how long to wait for the server to answer",
+ )
+ parser.add_argument(
+ "--no-internal",
+ action="store_true",
+ help="skip the back-office pass done as the neutralization test user",
+ )
+ parser.add_argument("--login", default="test")
+ parser.add_argument("--password", default="test")
+ parser.add_argument(
+ "--record-limit",
+ type=int,
+ default=20,
+ help="how many records each app loads on its first page",
+ )
+ parser.add_argument(
+ "--all-menus",
+ action="store_true",
+ help="open every menu with an action, not just each app's first page",
+ )
+ parser.add_argument(
+ "--internal-required",
+ action="store_true",
+ help="the database WAS neutralized: a missing test user is a failure",
+ )
+ parser.add_argument(
+ "--portal",
+ default="/my",
+ help="portal paths opened while signed in (comma separated, empty"
+ " to skip)",
+ )
+ config = parser.parse_args(argv)
+
+ print(
+ f"⧖ {t('Starting Odoo on')} '{config.database}'"
+ f" ({t('port')} {config.port})…"
+ )
+ interactive = not config.report_only and can_ask()
+ try:
+ lst_url, lst_failure, lst_key, lst_again, internal = run(
+ config.database,
+ config.port,
+ config.config,
+ limit=config.limit,
+ timeout=config.timeout,
+ boot=config.boot_timeout,
+ interactive=interactive,
+ auto_apply=config.apply,
+ internal=not config.no_internal,
+ internal_login=config.login,
+ internal_password=config.password,
+ internal_limit=config.record_limit,
+ every_menu=config.all_menus,
+ internal_required=config.internal_required,
+ portal=[
+ path.strip()
+ for path in (config.portal or "").split(",")
+ if path.strip()
+ ],
+ )
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ internal_failed = render_internal(internal)
+ if lst_again is None:
+ print(render(lst_url, lst_failure, lst_key))
+ return 1 if (lst_failure or internal_failed) else 0
+ # Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE.
+ print(
+ f"\n↻ {t('Re-checked the')} {len(lst_failure)}"
+ f" {t('failing URL(s) after the reset')} :"
+ )
+ print(render([url for url, _s, _p in lst_failure], lst_again, None))
+ return 1 if (lst_again or internal_failed) else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/snapshot_cow_views.py b/script/odoo/migration/snapshot_cow_views.py
index a8f839b..efa57bc 100755
--- a/script/odoo/migration/snapshot_cow_views.py
+++ b/script/odoo/migration/snapshot_cow_views.py
@@ -32,6 +32,18 @@ import os
import subprocess
import sys
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
# Columns worth recording. ir_ui_view does not expose the same set across 12.0
# to 18.0, so the query keeps only those that actually exist.
WANTED_COLUMN = [
@@ -115,7 +127,7 @@ def save(database, label, output_dir):
file_path = os.path.join(directory, f"{label}.json")
with open(file_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
- print(f"✅ -> {len(lst_view)} COW view(s) recorded in {file_path}")
+ print(f"✅ -> {len(lst_view)} {t('COW view(s) recorded in')} {file_path}")
return file_path
@@ -136,18 +148,18 @@ def diff(path_before, path_after):
common = sorted(set(map_before) & set(map_after))
print(
- f"📊 {before.get('label')} ({before.get('count')} views)"
- f" -> {after.get('label')} ({after.get('count')} views)"
+ f"📊 {before.get('label')} ({before.get('count')} {t('views')})"
+ f" -> {after.get('label')} ({after.get('count')} {t('views')})"
)
if removed:
- print(f"❌ {len(removed)} COW view(s) disappeared:")
+ print(f"❌ {len(removed)} {t('COW view(s) disappeared')} :")
for view_id in removed:
view = map_before[view_id]
print(f" - id={view_id} {view.get('key')}")
if added:
- print(f"➕ {len(added)} COW view(s) appeared:")
+ print(f"➕ {len(added)} {t('COW view(s) appeared')} :")
for view_id in added:
view = map_after[view_id]
print(f" - id={view_id} {view.get('key')} ({view.get('mode')})")
@@ -160,8 +172,8 @@ def diff(path_before, path_after):
if old.get(field) != new.get(field):
if field == "arch_md5":
lst_field.append(
- f"arch rewritten ({old.get('arch_len')} ->"
- f" {new.get('arch_len')} chars)"
+ f"{t('arch rewritten')} ({old.get('arch_len')} ->"
+ f" {new.get('arch_len')} {t('chars')})"
)
else:
lst_field.append(
@@ -171,14 +183,14 @@ def diff(path_before, path_after):
lst_changed.append((view_id, new.get("key"), lst_field))
if lst_changed:
- print(f"✏️ {len(lst_changed)} COW view(s) changed:")
+ print(f"✏️ {len(lst_changed)} {t('COW view(s) changed')} :")
for view_id, key, lst_field in lst_changed:
print(f" - id={view_id} {key}")
for change in lst_field:
print(f" {change}")
if not (removed or added or lst_changed):
- print("✅ -> No change on the website COW views.")
+ print(f"✅ -> {t('No change on the website COW views.')}")
return 0
diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py
new file mode 100644
index 0000000..19c02ce
--- /dev/null
+++ b/script/todo/auto_ask.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Lire une réponse, ou prendre le défaut au bout du délai.
+
+Le mode auto de la migration ne vaut que si TOUTES les invites le
+connaissent. Or six d'entre elles vivent dans des outils lancés en
+sous-processus — le désinstalleur de thème, le détecteur de SCSS figé, le
+test de fumée. Elles ne partagent ni l'objet du pilote ni sa mémoire ; sans
+rien de commun, elles attendraient indéfiniment une frappe qui ne vient
+pas, et l'automatisation s'arrêterait là sans rien dire.
+
+Ce qui traverse un `fork`, c'est l'environnement. Le pilote y pose
+`ERPLIBRE_AUTO_EXECUTE`, chaque outil le lit, et il n'existe qu'une seule
+implémentation du compte à rebours.
+
+`select` plutôt qu'un fil ou une alarme : la question est « quelque chose
+est-il LISIBLE maintenant », et il faut pouvoir renoncer. Un fil laisserait
+derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante.
+"""
+
+import os
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE"
+ENV_DELAY = "ERPLIBRE_AUTO_DELAY"
+DEFAULT_DELAY = 5
+
+
+def enabled():
+ """Le mode auto est-il actif pour CE processus ?"""
+ return os.environ.get(ENV_ENABLED, "") == "1"
+
+
+def delay():
+ """Combien de secondes attendre. Une valeur illisible ne bloque rien."""
+ try:
+ value = float(os.environ.get(ENV_DELAY, DEFAULT_DELAY))
+ except (TypeError, ValueError):
+ return DEFAULT_DELAY
+ # Un délai nul ou négatif rendrait la reprise en main impossible : on
+ # ne peut pas interrompre ce qui ne laisse aucune fenêtre.
+ return value if value > 0 else DEFAULT_DELAY
+
+
+def export(on, seconds=None):
+ """Poser le mode auto dans l'environnement, pour tout ce qu'on lancera.
+
+ C'est le seul canal qu'un sous-processus partage avec nous. Le retirer
+ quand on l'éteint, plutôt que d'y écrire « 0 », évite qu'un reste de
+ session précédente décide à notre place.
+ """
+ if on:
+ os.environ[ENV_ENABLED] = "1"
+ os.environ[ENV_DELAY] = str(
+ seconds if seconds is not None else DEFAULT_DELAY
+ )
+ else:
+ os.environ.pop(ENV_ENABLED, None)
+ os.environ.pop(ENV_DELAY, None)
+
+
+def ask(prompt, default="", seconds=None):
+ """Poser la question. Rendre `default` si rien n'arrive à temps.
+
+ Hors mode auto, c'est un `input()` ordinaire — sauf qu'une réponse vide
+ vaut le défaut, comme partout ailleurs en ligne de commande. C'est ce
+ qui permet d'écrire l'attendu dans la question : « (Y/n) » doit dire la
+ vérité pour la personne qui appuie sur Entrée, pas seulement pour le
+ compte à rebours.
+ """
+ if not enabled():
+ return input(prompt) or default
+ import select
+
+ attente = delay() if seconds is None else seconds
+ # DIRE qu'on répondra tout seul, et dans combien de temps. Une invite
+ # qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et
+ # c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt
+ # qu'après : elle se lit avant qu'on décide de partir.
+ sys.stdout.write(f"⏱{attente:g}s {prompt}")
+ sys.stdout.flush()
+ try:
+ ready, _, _ = select.select([sys.stdin], [], [], attente)
+ except (OSError, ValueError):
+ # stdin n'est pas sélectionnable : on ne devine pas, on demande.
+ return input("") or default
+ if ready:
+ answer = sys.stdin.readline().rstrip("\n")
+ # Une réponse VIDE vaut « prends le défaut » : c'est tout le propos
+ # du mode auto. Un stdin fermé — exécution non interactive — est
+ # lisible tout de suite et rend justement une ligne vide ; sans
+ # ceci, le défaut ne servirait jamais là.
+ return answer or default
+ # « Entrée », parce que c'est ce que l'invite a proposé. Elle annonce
+ # « Entrée = effacer, k = garder » et ne mentionne nulle part « d » :
+ # afficher la valeur brute nommait donc une réponse qui n'était pas au
+ # menu, et l'on se demandait ce que l'outil venait de décider. La
+ # valeur reste, entre parenthèses, pour qui veut la précision.
+ detail = f" ({default})" if default else ""
+ print(f" ⏱ → {t('Enter')}{detail}")
+ return default
+
+
+def make_ask(default, seconds=None):
+ """Un `ask` à un seul argument, pour les outils qui en injectent un.
+
+ Ils appellent `ask(question)` sans savoir ce qu'est un défaut : celui-ci
+ appartient à l'outil, pas à l'appelant, et se fige donc ici.
+ """
+
+ def ask_one(prompt=""):
+ return ask(prompt, default=default, seconds=seconds)
+
+ return ask_one
diff --git a/script/todo/migration_form.py b/script/todo/migration_form.py
index 98a150c..d320208 100644
--- a/script/todo/migration_form.py
+++ b/script/todo/migration_form.py
@@ -68,6 +68,7 @@ def run_resume_tui(ctx, run_app: bool = True):
"""
BINDINGS = [
("c", "cont", t("Continue where it stopped")),
+ ("b", "back_step", t("Go back to a step")),
("n", "new", t("New migration, erase everything")),
("r", "keep_zip", t("Keep the zip only")),
("q", "quit_nothing", t("Quit without doing anything")),
@@ -99,10 +100,12 @@ def run_resume_tui(ctx, run_app: bool = True):
id="a_cont",
)
yield Button(t("New migration"), id="a_new")
+ yield Button(t("Go back to a step"), id="a_back")
yield Button(t("Keep the zip only"), id="a_keep")
yield Button(t("Quit"), id="a_quit")
yield Static(
- f" {t('Enter on a step or a version = replay from there')}",
+ f" {t('Enter on a step or a version = replay from there')}"
+ f" · b = {t('Go back to a step')}",
id="hint",
)
yield Footer()
@@ -156,6 +159,9 @@ def run_resume_tui(ctx, run_app: bool = True):
self._answer(f"4.{event.option.id}")
def on_button_pressed(self, event) -> None:
+ if event.button.id == "a_back":
+ self.action_back_step()
+ return
mapping = {
"a_cont": "c",
"a_new": "n",
@@ -166,6 +172,18 @@ def run_resume_tui(ctx, run_app: bool = True):
if value:
self._answer(value)
+ def action_back_step(self) -> None:
+ """Amener au tableau des étapes, où le choix se fait déjà.
+
+ Le mécanisme existait — Entrée sur une ligne — mais vivait dans une
+ ligne d'astuce sous quatre boutons nommés. Une capacité qu'il faut
+ deviner n'est pas offerte ; celle-ci porte donc une touche, un
+ bouton et un message, comme les autres.
+ """
+ table = self.query_one("#steps", DataTable)
+ table.focus()
+ self.notify(t("Choose a step, Enter replays from there."))
+
def action_cont(self) -> None:
self._answer("c")
diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py
new file mode 100755
index 0000000..d8f2061
--- /dev/null
+++ b/script/todo/migration_status.py
@@ -0,0 +1,533 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Où en est cette migration, et qu'est-ce qui a mal tourné.
+
+Une migration traverse six paliers, lance des centaines de commandes et
+dure des heures. Le journal existant dit ce qui a été LANCÉ ; il ne dit
+jamais ce que cela a donné. Après trois heures on relit deux cents lignes
+de commandes sans savoir laquelle a échoué, ni ce que le test de fumée a
+conclu, ni à quelle étape on se trouve.
+
+Ce module assemble la réponse à partir du fichier de progression — le
+même que la migration écrit après chaque geste — et la rend en texte. Le
+plein écran, lui, n'est qu'une autre vue de CES données : deux rendus
+séparés dériveraient l'un de l'autre sans que rien ne le signale.
+
+Rien n'est lu ailleurs que dans ce fichier : l'écran d'état ne doit jamais
+toucher une base ni lancer un serveur. On l'ouvre en pleine migration.
+"""
+
+import json
+import os
+import re
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+DEFAULT_PATH = ".venv.erplibre/odoo_database_migration_log.json"
+PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration")
+STEP_LOG_DIR = "step_log"
+EVENT_FILE = "events.jsonl"
+
+# Les codes de sortie que TOUS les outils de migration partagent. Les
+# traduire ici plutôt qu'à l'affichage évite qu'un « 1 » passe pour une
+# panne alors qu'il annonce des trouvailles.
+VERDICT = {
+ 0: ("✅", "nothing to report"),
+ 1: ("⚠️", "findings to look at"),
+ 2: ("❌", "the tool itself failed"),
+}
+
+
+# Les couleurs ANSI, et le droit de s'en passer. `NO_COLOR` est une
+# convention respectée par la plupart des outils : la contredire oblige à
+# nettoyer une sortie à la main, ce qui est exactement ce qu'on cherchait à
+# éviter en la coloriant.
+ANSI = {
+ "cmd": "\033[36m", # cyan : ce qui a été LANCÉ
+ "step": "\033[1;34m", # bleu gras : les étapes
+ "ok": "\033[32m",
+ "warn": "\033[33m",
+ "fail": "\033[31m",
+ "dim": "\033[2m",
+}
+RESET = "\033[0m"
+
+
+def supports_colour(stream=None):
+ """Peut-on colorier CETTE sortie ?
+
+ Trois refus, et chacun a coûté à quelqu'un : un fichier de journal
+ truffé de codes d'échappement, un `grep` qui ne trouve plus rien, un
+ terminal qui les affiche en clair. Un tube n'est pas un écran.
+ """
+ if os.environ.get("NO_COLOR"):
+ return False
+ if os.environ.get("TERM", "") in ("", "dumb"):
+ return False
+ stream = stream or sys.stdout
+ try:
+ return bool(stream.isatty())
+ except Exception:
+ return False
+
+
+def paint(texte, couleur, actif=True):
+ """Colorier, ou rendre le texte tel quel. Jamais d'à-peu-près."""
+ if not actif or couleur not in ANSI:
+ return texte
+ return f"{ANSI[couleur]}{texte}{RESET}"
+
+
+VERDICT_COLOUR = {0: "ok", 1: "warn", 2: "fail"}
+
+
+def read(path=DEFAULT_PATH):
+ """La progression, complétée par ce qui a été écrit SUR DISQUE.
+
+ Le fichier de progression est archivé puis remis à zéro quand on
+ recommence une migration : tout ce qu'il contenait disparaissait alors
+ de cet écran. Le journal permanent, lui, ne fait que s'allonger — et
+ c'est justement en revenant après une interruption qu'on a besoin de
+ ce qui s'est passé avant.
+ """
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ dct = json.load(handle)
+ except (OSError, ValueError):
+ return {}
+ dct["lst_event"] = merge_events(dct)
+ return dct
+
+
+def log_dir(dct):
+ """Le répertoire des journaux de cette migration, ou None."""
+ database = (dct or {}).get("config_database_name")
+ if not database:
+ return None
+ chemin = os.path.join(PATH_MIGRATION_PRIVATE, database, STEP_LOG_DIR)
+ return chemin if os.path.isdir(chemin) else None
+
+
+def read_event_file(dct):
+ """Les événements du journal permanent, en ignorant les lignes cassées.
+
+ Une écriture interrompue laisse une ligne tronquée ; la refuser en bloc
+ perdrait tout le reste du fichier pour une seule ligne.
+ """
+ chemin = log_dir(dct)
+ if not chemin:
+ return []
+ lst = []
+ try:
+ with open(
+ os.path.join(chemin, EVENT_FILE), "r", encoding="utf-8"
+ ) as handle:
+ for ligne in handle:
+ ligne = ligne.strip()
+ if not ligne:
+ continue
+ try:
+ lst.append(json.loads(ligne))
+ except ValueError:
+ continue
+ except OSError:
+ return []
+ return lst
+
+
+def merge_events(dct):
+ """Le disque d'abord, la mémoire ensuite, sans doublon.
+
+ Les deux sources se recouvrent : ce qui vient d'être enregistré est aux
+ deux endroits. On dédoublonne sur (horodatage, nom, type) — trois
+ champs qu'un même événement porte à l'identique dans les deux.
+ """
+ vus = set()
+ fusion = []
+ for item in read_event_file(dct) + list(dct.get("lst_event") or []):
+ cle = (item.get("at"), item.get("name"), item.get("kind"))
+ if cle in vus:
+ continue
+ vus.add(cle)
+ fusion.append(item)
+ return fusion
+
+
+def step_log_path(dct, step):
+ """Le fichier de journal d'une étape, s'il existe."""
+ chemin = log_dir(dct)
+ if not chemin or not step:
+ return None
+ fichier = os.path.join(chemin, f"{step_slug(step)}.log")
+ return fichier if os.path.isfile(fichier) else None
+
+
+def step_slug(msg):
+ """Le même nom que celui écrit par la migration. Un seul calcul.
+
+ Deux formules séparées dériveraient, et l'écran chercherait alors un
+ fichier que personne n'écrit — sans rien signaler, puisqu'un fichier
+ absent se lit comme une étape sans journal.
+ """
+ import re
+
+ prefix, sep, label = (msg or "").partition(" - ")
+ propre = re.sub(r"[^A-Za-z0-9._-]+", "-", (label or prefix)).strip("-")
+ tete = re.sub(r"[^A-Za-z0-9._-]+", "-", prefix).strip("-") if sep else ""
+ nom = f"{tete}_{propre}" if tete else propre
+ return (nom or "step")[:80].lower()
+
+
+# Le format d'une ligne de journal Odoo :
+# 2026-08-19 09:21:07,074 132948 ERROR odoo.tools.translate: message
+# Le nom de la base en fait partie, et c'est ce qui permet de séparer six
+# paliers écrits dans un même fichier — ce qui était le cas avant qu'une
+# étape n'ouvre son propre journal.
+RE_LOG_LINE = re.compile(
+ r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d+ \d+ (\w+) (\S+) ([\w.]+): (.*)$"
+)
+GRAVE = ("ERROR", "CRITICAL")
+
+_CACHE = {}
+
+
+def scan_log(chemin):
+ """Compter les sévérités et relever les erreurs DISTINCTES d'un journal.
+
+ Mis en cache sur (taille, date) : l'écran se redessine à chaque touche,
+ et un journal d'étape atteint treize mégaoctets — mesuré. Le relire à
+ chaque frappe rendrait l'écran inutilisable.
+
+ Les erreurs sont dédoublonnées avec leur nombre : quarante-huit fois
+ « Model X has no table » est UN problème vu quarante-huit fois, pas
+ quarante-huit problèmes, et la liste brute noie tout le reste.
+ """
+ try:
+ stat = os.stat(chemin)
+ except OSError:
+ return {"count": {}, "errors": []}
+ cle = (chemin, stat.st_size, int(stat.st_mtime))
+ if cle in _CACHE:
+ return _CACHE[cle]
+ compte = {}
+ distinct = {}
+ try:
+ with open(chemin, "r", encoding="utf-8", errors="replace") as handle:
+ for ligne in handle:
+ trouve = RE_LOG_LINE.match(ligne)
+ if not trouve:
+ if ligne.startswith("Traceback"):
+ compte["TRACEBACK"] = compte.get("TRACEBACK", 0) + 1
+ continue
+ niveau, base, logger, message = trouve.groups()
+ compte[niveau] = compte.get(niveau, 0) + 1
+ if niveau in GRAVE:
+ signature = (base, logger, message.strip()[:120])
+ distinct[signature] = distinct.get(signature, 0) + 1
+ except OSError:
+ return {"count": {}, "errors": []}
+ resultat = {
+ "count": compte,
+ "errors": [
+ {
+ "database": base,
+ "logger": logger,
+ "message": message,
+ "times": nombre,
+ }
+ for (base, logger, message), nombre in sorted(
+ distinct.items(), key=lambda item: -item[1]
+ )
+ ],
+ }
+ # Un seul journal en cache : ils pèsent des mégaoctets, et l'écran ne
+ # regarde qu'une étape à la fois.
+ _CACHE.clear()
+ _CACHE[cle] = resultat
+ return resultat
+
+
+def step_log_scan(dct, step):
+ """Ce que le journal de cette étape contient de grave."""
+ chemin = step_log_path(dct, step)
+ return scan_log(chemin) if chemin else {"count": {}, "errors": []}
+
+
+def severe_count(scan):
+ """Combien d'ERROR et de CRITICAL, en un seul nombre."""
+ return sum(scan["count"].get(niveau, 0) for niveau in GRAVE)
+
+
+def step_log_tail(dct, step, lines=400):
+ """Les dernières lignes du journal de cette étape, et le compte total.
+
+ Rend (lignes, total). Le total n'est pas décoratif : une mise à jour de
+ modules écrit des dizaines de milliers de lignes, et montrer les
+ dernières SANS dire qu'on en cache se lit comme « il manque des logs ».
+ """
+ chemin = step_log_path(dct, step)
+ if not chemin:
+ return [], 0
+ try:
+ with open(chemin, "r", encoding="utf-8", errors="replace") as handle:
+ lst = handle.read().splitlines()
+ except OSError:
+ return [], 0
+ return lst[-lines:], len(lst)
+
+
+def journal_by_step(dct):
+ """Le journal découpé par étape, dans l'ordre.
+
+ Les entrées commençant par « # » sont les en-têtes d'étape que la
+ migration y dépose elle-même ; tout ce qui suit appartient à l'étape
+ ouverte. On réutilise donc un marquage qui existe déjà plutôt que d'en
+ inventer un second, qui divergerait.
+ """
+ lst_section = []
+ courante = None
+ for entry in dct.get("command_executed") or []:
+ texte = str(entry)
+ if texte.startswith("#"):
+ courante = {"step": texte.lstrip("# ").strip(), "lst_cmd": []}
+ lst_section.append(courante)
+ continue
+ if courante is None:
+ courante = {"step": t("before the first step"), "lst_cmd": []}
+ lst_section.append(courante)
+ courante["lst_cmd"].append(texte)
+ return lst_section
+
+
+def events(dct, kind=None):
+ """Ce qui a mal tourné, et ce que les outils ont conclu."""
+ lst = [
+ item
+ for item in (dct.get("lst_event") or [])
+ if kind is None or item.get("kind") == kind
+ ]
+ return lst
+
+
+def verdict(status):
+ """(icône, phrase) pour un code de sortie d'outil."""
+ icone, phrase = VERDICT.get(status, ("❔", "unknown result"))
+ return icone, t(phrase)
+
+
+def tests_summary(dct):
+ """Le dernier verdict de chaque outil, PAR ÉTAPE, et son nombre de passages.
+
+ Par étape, car c'est la question qu'on pose. Une migration lance le
+ test de fumée à CHAQUE palier ; regrouper sur le seul nom d'outil n'en
+ laissait qu'une ligne, et l'on lisait « smoke_public_url ✅ » sans voir
+ que le palier 14 était passé et le 17 tombé.
+
+ Dans une étape, le DERNIER verdict l'emporte : un outil relancé après
+ correction a deux verdicts contradictoires, et c'est le second qui
+ décrit la base telle qu'elle est. Les afficher tous deux sans les
+ distinguer ferait lire une réparation comme un échec persistant.
+
+ L'ordre est celui du journal, donc celui de la migration. Trier les
+ étapes par leur nom mettrait « 4.10 » avant « 4.2 ».
+ """
+ dernier = {}
+ for item in events(dct, kind="test"):
+ cle = (item.get("step") or "", item.get("name") or "?")
+ entree = dernier.setdefault(
+ cle, {"name": cle[1], "step": cle[0], "runs": 0}
+ )
+ entree["runs"] += 1
+ entree["status"] = item.get("status")
+ entree["at"] = item.get("at")
+ return list(dernier.values())
+
+
+def tests_by_step(dct):
+ """Les verdicts groupés sous leur étape, dans l'ordre de la migration."""
+ par_etape = {}
+ for item in tests_summary(dct):
+ par_etape.setdefault(item["step"], []).append(item)
+ return list(par_etape.items())
+
+
+def failures(dct):
+ """Les commandes qui ont échoué, la plus récente d'abord."""
+ return list(reversed(events(dct, kind="command")))
+
+
+def elapsed(dct):
+ """Combien de temps la migration a duré, ou dure encore.
+
+ Du premier écrit à la DERNIÈRE écriture du journal : la progression est
+ réécrite après chaque geste, donc sa date de mise à jour EST la fin —
+ ou l'instant présent si la migration tourne toujours.
+
+ On délègue à `migration_stats.fmt_delay`, qui porte déjà ce calcul pour
+ l'écran de statistiques. Deux formules donneraient deux durées pour la
+ même migration selon l'écran qu'on ouvre.
+ """
+ try:
+ from script.todo.migration_stats import fmt_delay
+ except Exception:
+ return "?"
+ return fmt_delay(dct.get("date_create"), dct.get("date_update"))
+
+
+def overview(dct):
+ """L'en-tête : de quelle migration parle-t-on."""
+ return {
+ "elapsed": elapsed(dct),
+ "file": os.path.basename(dct.get("migration_file") or "?"),
+ "database": dct.get("config_database_name") or "?",
+ "target": dct.get("target_odoo_version") or "?",
+ "started": dct.get("date_create") or "?",
+ "updated": dct.get("date_update") or "?",
+ "step": (
+ (dct.get("lst_event") or [{}])[-1].get("step")
+ or _last_step(dct)
+ or "?"
+ ),
+ }
+
+
+def _last_step(dct):
+ lst = journal_by_step(dct)
+ return lst[-1]["step"] if lst else None
+
+
+def bumps(dct):
+ """Les paliers, et lesquels sont faits."""
+ done = dct.get("state_4_upgrade_odoo_lst") or []
+ lst = dct.get("lst_version_bump") or []
+ if not lst:
+ # Le nom des bases de palier porte la version : c'est la seule
+ # source disponible quand la liste n'a pas été écrite.
+ lst = list(range(len(done)))
+ return [
+ {"version": version, "done": bool(i < len(done) and done[i])}
+ for i, version in enumerate(lst)
+ ]
+
+
+def render_text(dct, limit_cmd=12, colour=None):
+ """Le rapport complet, en texte. C'est aussi le repli du plein écran.
+
+ `colour` à None laisse la sortie décider : un écran est colorié, un
+ tube ne l'est pas. Le forcer sert aux tests, qui doivent pouvoir
+ vérifier les deux sans dépendre de l'endroit où ils tournent.
+ """
+ if colour is None:
+ colour = supports_colour()
+ if not dct:
+ return f"ℹ️ {t('No migration in progress.')}"
+ info = overview(dct)
+ lignes = [
+ f"📍 {t('Migration state')}",
+ f" {t('database')} : {info['database']}",
+ f" {t('image')} : {info['file']}",
+ f" {t('started')} : {info['started']}",
+ f" {t('finished')} : {info['updated']}",
+ f" {t('duration')} : {info['elapsed']}",
+ f" {t('current step')} : {info['step']}",
+ ]
+
+ lst_test = tests_summary(dct)
+ lignes.append(f"\n🧪 {t('Test results')}")
+ if not lst_test:
+ lignes.append(f" {t('No tool has run yet.')}")
+ for etape, lst_item in tests_by_step(dct):
+ lignes.append(
+ f" {paint(etape or t('before the first step'), 'step', colour)}"
+ )
+ for item in lst_item:
+ icone, phrase = verdict(item.get("status"))
+ rejeu = (
+ f" ({item['runs']} {t('runs')})"
+ if item.get("runs", 1) > 1
+ else ""
+ )
+ teinte = VERDICT_COLOUR.get(item.get("status"), "dim")
+ nom = f"{item['name']:<24}"
+ lignes.append(
+ f" {icone} {paint(nom, teinte, colour)} {phrase}{rejeu}"
+ )
+
+ lst_failure = failures(dct)
+ lignes.append(f"\n❌ {t('Commands that failed')} : {len(lst_failure)}")
+ for item in lst_failure[:10]:
+ lignes.append(
+ f" · [{item.get('step') or '?'}]"
+ f" {paint(item.get('name') or '', 'fail', colour)}"
+ )
+
+ lignes.append(f"\n🔷 {t('What was done, step by step')}")
+ for section in journal_by_step(dct):
+ lst_cmd = section["lst_cmd"]
+ journal = " 📄" if step_log_path(dct, section["step"]) else ""
+ scan = step_log_scan(dct, section["step"])
+ graves = severe_count(scan)
+ # Le nombre d'ERROR à côté de l'étape : c'est la seule façon de
+ # voir d'un coup d'œil OÙ la migration a souffert, sans ouvrir
+ # treize mégaoctets de journal.
+ alerte = f" {paint(f'❌ {graves}', 'fail', colour)}" if graves else ""
+ lignes.append(
+ f" {paint(section['step'], 'step', colour)}"
+ f" ({len(lst_cmd)}){journal}{alerte}"
+ )
+ for item in scan["errors"][:3]:
+ lignes.append(
+ f" ×{item['times']:<3}"
+ f" {paint(item['message'][:100], 'warn', colour)}"
+ )
+ for cmd in lst_cmd[:limit_cmd]:
+ # LA demande : distinguer d'un coup d'œil ce qui a été lancé
+ # du reste du rapport. Une liste de commandes en texte plat se
+ # confond avec ses titres dès qu'elle dépasse l'écran.
+ lignes.append(f" {paint(cmd[:120], 'cmd', colour)}")
+ if len(lst_cmd) > limit_cmd:
+ reste = len(lst_cmd) - limit_cmd
+ lignes.append(f" … {reste} {t('more')}")
+ return "\n".join(lignes)
+
+
+def main(argv=None):
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description="Show what this migration has done, and what failed."
+ )
+ parser.add_argument("-f", "--file", default=DEFAULT_PATH)
+ parser.add_argument(
+ "--text",
+ action="store_true",
+ help="print the report instead of opening the full screen",
+ )
+ config = parser.parse_args(argv)
+ dct = read(config.file)
+ if not config.text:
+ try:
+ from script.todo.migration_status_tui import run_tui
+ except Exception:
+ run_tui = None
+ if run_tui and run_tui(dct, path=config.file):
+ return 0
+ print(render_text(dct))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py
new file mode 100644
index 0000000..9c54888
--- /dev/null
+++ b/script/todo/migration_status_tui.py
@@ -0,0 +1,420 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""L'état d'une migration, en plein écran.
+
+Le rapport texte dit tout, mais il dit tout D'UN COUP : sur une migration
+de six paliers il fait plusieurs centaines de lignes, et ce qu'on cherche —
+l'étape où ça a cassé, ce que le test de fumée a conclu — se trouve
+quelque part au milieu. Un écran qui se parcourt règle exactement cela.
+
+Les données viennent de `migration_status`, comme le rapport texte. Deux
+assemblages séparés dériveraient l'un de l'autre sans que rien ne le dise,
+et l'on finirait par lire deux états contradictoires de la même migration.
+"""
+
+import os
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+from script.todo import migration_status as status # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 4; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#left { width: 46; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def head_text(dct):
+ """Ce qui identifie la migration. Toujours visible, jamais à chercher."""
+ info = status.overview(dct)
+ lst_test = status.tests_summary(dct)
+ casse = [x for x in lst_test if x.get("status")]
+ return (
+ f"{info['database']} · {info['file']}\n"
+ f"{t('current step')} : {info['step']}\n"
+ f"{t('finished')} : {info['updated']}"
+ f" · {t('duration')} {info['elapsed']}"
+ f" · {len(status.failures(dct))} {t('failed')}"
+ f" · {len(casse)}/{len(lst_test)} {t('Test results')}"
+ )
+
+
+def rows(dct):
+ """Les lignes du panneau de gauche : tests d'abord, puis les étapes.
+
+ Les tests en tête parce que c'est la question qu'on se pose en ouvrant
+ cet écran ; les étapes ensuite parce que c'est là qu'on cherche le
+ détail une fois qu'on sait QUOI chercher.
+ """
+ lst = []
+ for item in status.tests_summary(dct):
+ icone, _phrase = status.verdict(item.get("status"))
+ # Le NUMÉRO de l'étape en tête : une migration lance le même outil
+ # à chaque palier, et sans lui la liste montrait six lignes
+ # identiques sans dire laquelle appartenait à quel palier.
+ numero = (item.get("step") or "").split(" - ")[0]
+ tete = f"{numero} " if numero else ""
+ lst.append(
+ {
+ "kind": "test",
+ "label": f"{icone} {tete}{item['name']}",
+ "detail": str(item.get("runs", 1)),
+ "data": item,
+ }
+ )
+ for section in status.journal_by_step(dct):
+ graves = status.severe_count(
+ status.step_log_scan(dct, section["step"])
+ )
+ lst.append(
+ {
+ "kind": "step",
+ "label": section["step"],
+ "detail": str(len(section["lst_cmd"])),
+ "severe": str(graves) if graves else "",
+ "data": section,
+ }
+ )
+ return lst
+
+
+def pane_text(dct, row, colour=False, show_log=True):
+ """Le détail de la ligne choisie.
+
+ Le coloriage passe par de l'ANSI, que Rich sait décoder — et qui rend
+ au passage le texte LITTÉRAL : une commande contenant « [1] » était
+ jusqu'ici prise pour du balisage Rich et avalée sans un mot.
+ """
+ if row is None:
+ return t("Nothing to show yet.")
+ if row["kind"] == "test":
+ item = row["data"]
+ icone, phrase = status.verdict(item.get("status"))
+ teinte = status.VERDICT_COLOUR.get(item.get("status"), "dim")
+ lignes = [
+ f"{icone} {status.paint(item['name'], teinte, colour)}",
+ f" {status.paint(item.get('step') or '?', 'step', colour)}",
+ f" {phrase} ({t('exit code')} {item.get('status')})",
+ f" {t('runs')} : {item.get('runs')}",
+ f" {item.get('at') or ''}",
+ ]
+ return "\n".join(lignes)
+ section = row["data"]
+ lignes = [status.paint(section["step"], "step", colour), ""]
+ lst_failure = [
+ item
+ for item in status.failures(dct)
+ if (item.get("step") or "") == section["step"]
+ ]
+ if lst_failure:
+ lignes.append(f"❌ {t('Commands that failed')} :")
+ for item in lst_failure:
+ lignes.append(
+ f" {status.paint(item.get('name') or '', 'fail', colour)}"
+ )
+ lignes.append("")
+ for cmd in section["lst_cmd"]:
+ lignes.append(f"· {status.paint(cmd, 'cmd', colour)}")
+ # La SORTIE des commandes, relue sur disque. C'est ce qui manquait :
+ # la liste des commandes dit ce qui a été lancé, jamais ce que cela a
+ # répondu. Mais les deux mélangés dans un même panneau se confondent —
+ # d'où « l », qui les sépare.
+ # Les erreurs DISTINCTES d'abord, le journal brut ensuite : quarante-
+ # huit fois le même message est un problème vu quarante-huit fois, et
+ # la liste brute le noie au milieu de cent mille lignes.
+ scan = status.step_log_scan(dct, section["step"])
+ if scan["errors"]:
+ lignes.append("")
+ lignes.append(
+ f"── {t('errors in the log')} :" f" {status.severe_count(scan)} ──"
+ )
+ for item in scan["errors"][:15]:
+ lignes.append(
+ f" ×{item['times']:<4}"
+ f" {status.paint(item['message'], 'fail', colour)}"
+ )
+ lignes.append(
+ f" {status.paint(item['logger'], 'dim', colour)}"
+ f" · {item['database']}"
+ )
+ tail, total = status.step_log_tail(dct, section["step"])
+ if not show_log:
+ if total:
+ lignes.append("")
+ lignes.append(
+ f"── {t('server log')} : {total} {t('lines')}"
+ f" ({t('press l to show')}) ──"
+ )
+ return "\n".join(lignes)
+ if tail:
+ lignes.append("")
+ cache = (
+ f" — {t('last')} {len(tail)} {t('of')} {total}"
+ if total > len(tail)
+ else ""
+ )
+ lignes.append(f"── {t('server log')}{cache} ──")
+ lignes.extend(tail)
+ elif not section["lst_cmd"]:
+ lignes.append(t("No tool has run yet."))
+ return "\n".join(lignes)
+
+
+PANELS = ("all", "no-summary", "detail")
+
+
+def panel_label(state):
+ """Le nom de l'état courant, tel qu'il s'affiche dans le sous-titre."""
+ return {
+ "all": t("summary + list + detail"),
+ "no-summary": t("list + detail"),
+ "detail": t("detail only"),
+ }[PANELS[state % len(PANELS)]]
+
+
+def panel_visibility(state):
+ """(résumé visible, liste visible) pour cet état.
+
+ Trois états, du plus complet au plus dépouillé. Un simple bascule ne
+ libérait que quatre lignes ; ce qui prend la place, c'est la COLONNE de
+ gauche — et sur un journal de serveur, chaque caractère gagné en
+ largeur compte.
+ """
+ etat = PANELS[state % len(PANELS)]
+ return etat == "all", etat != "detail"
+
+
+def current_row(lst_row, index):
+ """La ligne choisie, ou None quand il n'y en a aucune."""
+ return lst_row[index] if lst_row and 0 <= index < len(lst_row) else None
+
+
+def reread(path):
+ """Relire la progression, ou None si l'on ne sait pas d'où."""
+ return status.read(path) if path else None
+
+
+def apply_panels(app):
+ """Poser la visibilité des panneaux, et NOMMER l'état choisi.
+
+ Le sous-titre reste visible dans les trois : un panneau qui disparaît
+ sans un mot se lit comme un écran cassé.
+ """
+ resume, liste = panel_visibility(app.panel_state)
+ app.query_one("#head").display = resume
+ app.query_one("#left").display = liste
+ app.sub_title = panel_label(app.panel_state)
+
+
+def apply_width(app, delta):
+ """Déplacer la séparation, bornée des deux côtés.
+
+ Une colonne de zéro ne se retrouve plus ; une qui mange tout l'écran ne
+ laisse rien à lire.
+ """
+ app.left_width = max(
+ app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + delta)
+ )
+ app.query_one("#left").styles.width = app.left_width
+
+
+def fill_table(app):
+ """(Re)construire la liste de gauche depuis les données courantes."""
+ table = app.query_one("#left")
+ table.clear(columns=True)
+ table.add_columns(t("Test results"), "#", "❌")
+ for row in app.lst_row:
+ table.add_row(row["label"][:38], row["detail"], row.get("severe", ""))
+ table.styles.width = app.left_width
+ app.query_one("#head").update(head_text(app.dct))
+
+
+def show_pane(app):
+ """Peindre le détail de la ligne choisie.
+
+ `from_ansi` fait DEUX choses : il rend les couleurs, et il traite le
+ reste comme du texte LITTÉRAL. Sans lui, une commande contenant
+ « [1] » passait pour du balisage Rich et disparaissait sans un mot.
+ """
+ from rich.text import Text
+
+ row = current_row(app.lst_row, app.index)
+ app.query_one("#content").update(
+ Text.from_ansi(
+ pane_text(app.dct, row, colour=True, show_log=app.show_log)
+ )
+ )
+
+
+def build_app(dct, path=None):
+ """Textual est importé ICI, pas au chargement du module.
+
+ Le module reste importable — donc testable — sur une machine sans
+ Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le
+ rapport texte plutôt que d'échouer.
+ """
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ class StatusApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [
+ ("q,escape", "quit", t("Quit")),
+ ("r", "refresh", t("Refresh")),
+ ("l", "toggle_log", t("Logs")),
+ ("p", "cycle_panels", t("Panels")),
+ ("k", "quality", t("Quality")),
+ ("plus,equal", "wider", t("Wider")),
+ ("minus,underscore", "narrower", t("Narrower")),
+ ]
+
+ LEFT_MIN = 16
+ LEFT_MAX = 110
+ LEFT_STEP = 6
+
+ def __init__(self, dct, path=None):
+ super().__init__()
+ self.dct = dct
+ self.path = path
+ self.lst_row = rows(dct)
+ self.index = 0
+ self.show_log = True
+ self.left_width = 46
+ self.panel_state = 0
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="left", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = t("Migration state")
+ fill_table(self)
+ apply_panels(self)
+ show_pane(self)
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "left" and self.lst_row:
+ self.index = event.cursor_row
+ show_pane(self)
+
+ def action_refresh(self):
+ """Relire le disque. La migration écrit PENDANT qu'on regarde.
+
+ L'écran s'ouvre au milieu d'une migration qui continue : sans
+ cela, il fallait le fermer et le rouvrir pour voir le palier
+ suivant.
+ """
+ neuf = reread(self.path)
+ if neuf is None:
+ return
+ self.dct = neuf
+ self.lst_row = rows(self.dct)
+ self.index = min(self.index, max(0, len(self.lst_row) - 1))
+ fill_table(self)
+ show_pane(self)
+
+ def action_quality(self):
+ """Ouvrir le rapport de qualité, sans quitter celui-ci.
+
+ Les deux écrans répondent à deux questions voisines : « où en
+ est-on » et « qu'a-t-on gagné ou perdu en chemin ».
+
+ En SOUS-PROCESSUS, et ce n'est pas un choix de confort :
+ `app.run()` appelle `asyncio.run()`, qui refuse de tourner
+ dans une boucle déjà en cours — et nous sommes justement
+ dedans. Mesuré, la trace est
+ « asyncio.run() cannot be called from a running event loop ».
+ `suspend()` rend le terminal ; le sous-processus a sa propre
+ boucle et le repeint.
+ """
+ import subprocess
+
+ with self.suspend():
+ subprocess.call(
+ [
+ sys.executable,
+ os.path.join(
+ "script", "analyse", "check_migration_quality.py"
+ ),
+ ]
+ )
+
+ def action_toggle_log(self):
+ self.show_log = not self.show_log
+ show_pane(self)
+
+ def action_cycle_panels(self):
+ """Trois états, du plus complet au plus dépouillé.
+
+ Un simple bascule ne libérait que quatre lignes. Ce qui prend
+ la place, c'est la COLONNE de gauche : sur un journal de
+ serveur, chaque caractère gagné en largeur compte. On enchaîne
+ donc tout → sans résumé → détail seul, et l'on revient.
+
+ L'état est écrit dans le sous-titre, qui reste visible dans les
+ trois : un panneau qui disparaît sans un mot se lit comme un
+ écran cassé.
+ """
+ self.panel_state = (self.panel_state + 1) % len(PANELS)
+ apply_panels(self)
+
+ def action_wider(self):
+ apply_width(self, self.LEFT_STEP)
+
+ def action_narrower(self):
+ apply_width(self, -self.LEFT_STEP)
+
+ return StatusApp(dct, path)
+
+
+def run_tui(dct, run_app=True, path=None):
+ """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.
+
+ Se taire ferait réafficher le rapport texte à la place de l'écran
+ demandé, sans rien qui distingue les deux.
+ """
+ if not dct:
+ return False
+ if not sys.stdout.isatty():
+ print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
+ return False
+ try:
+ from script.todo import textual_setup
+ except Exception:
+ textual_setup = None
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(dct, path=path)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ return False
+ if not run_app:
+ return app
+ app.run()
+ return True
diff --git a/script/todo/todo.py b/script/todo/todo.py
index 321ddf4..4614ffa 100755
--- a/script/todo/todo.py
+++ b/script/todo/todo.py
@@ -889,7 +889,17 @@ class TODO:
return False
elif status == str(len(choices) - 1):
upgrade = todo_upgrade.TodoUpgrade(self)
- upgrade.execute_odoo_upgrade()
+ try:
+ upgrade.execute_odoo_upgrade()
+ except todo_upgrade.MigrationRewind:
+ # L'état est déjà rembobiné et écrit : il ne reste qu'à
+ # relancer, et l'écran de reprise repartira de l'étape
+ # choisie. Sortir d'ici plutôt que de rappeler la méthode
+ # évite de la reprendre au milieu de son état local.
+ print(
+ f"\n⏪ {t('Rewound.')}"
+ f" {t('Relaunch the migration to resume from there.')}"
+ )
elif status == str(len(choices)):
self.upgrade_poetry()
else:
@@ -7702,6 +7712,8 @@ class TODO:
)
},
{"prompt_description": t("Studio and hand-made x_ fields")},
+ {"section": t("Migration")},
+ {"prompt_description": t("Quality of a migration, step by step")},
]
help_info = self.fill_help_info(choices)
@@ -7716,9 +7728,41 @@ class TODO:
self.execute_analyse_view_custom()
elif status == "3":
self.execute_analyse_custom_field()
+ elif status == "4":
+ self.execute_analyse_migration_quality()
else:
print(t("Command not found !"))
+ def execute_analyse_migration_quality(self):
+ """Ce qu'une migration a gagné et perdu, palier par palier.
+
+ Appelé comme les autres analyses — même interpréteur, aucun
+ sous-processus — mais l'écran plein est ouvert par l'outil
+ lui-même, qui sait retomber sur son rapport texte s'il ne peut pas.
+
+ Lecture seule de bout en bout : les bases de palier sont parfois la
+ seule copie qui reste d'un état intermédiaire.
+ """
+ from script.analyse import check_migration_quality as quality
+
+ dct = quality.read_progression()
+ if not quality.chain(dct):
+ print(f"\nℹ️ {t('No migration in progress.')}")
+ return
+ try:
+ lst = quality.survey(
+ dct, echo=lambda texte: print(f"⧖ {texte}", flush=True)
+ )
+ except Exception as exc:
+ print(f"❌ {t('Analysis failed: ')}{exc}")
+ return
+ try:
+ from script.analyse.check_migration_quality_tui import run_tui
+ except Exception:
+ run_tui = None
+ if not (run_tui and run_tui(lst)):
+ print(quality.render_text(lst))
+
def _analyse_select_source(self):
"""(est_une_sauvegarde, cible), ou None si l'on renonce.
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index b4e69f7..6a5118a 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -3860,6 +3860,2531 @@ TRANSLATIONS = {
" module source. The classification above needs none; only the"
" differences do. Restore it, or run this on the database.",
},
+ "Could not archive the previous log": {
+ "fr": "Impossible d'archiver le journal précédent",
+ "en": "Could not archive the previous log",
+ },
+ "Previous migration log kept in": {
+ "fr": "Journal de la migration précédente conservé dans",
+ "en": "Previous migration log kept in",
+ },
+ "done early, before the neutralization": {
+ "fr": "faite plus tôt, avant la neutralisation",
+ "en": "done early, before the neutralization",
+ },
+ "compare each website copy with the module view it shadows": {
+ "fr": "comparer chaque copie de site web à la vue de module qu'elle"
+ " masque",
+ "en": "compare each website copy with the module view it shadows",
+ },
+ "Compare the website copies with the view they shadow": {
+ "fr": "🪞 Comparer les copies de site web à la vue qu'elles masquent",
+ "en": "🪞 Compare the website copies with the view they shadow",
+ },
+ "website copies compared with their module view,": {
+ "fr": "copies de site web comparées à leur vue de module,",
+ "en": "website copies compared with their module view,",
+ },
+ "differ.": {"fr": "diffèrent.", "en": "differ."},
+ "What do you want to do with these COW copies?": {
+ "fr": "Que voulez-vous faire de ces copies COW ?",
+ "en": "What do you want to do with these COW copies?",
+ },
+ "Enter = decide at the version bump": {
+ "fr": "Entrée = décider au palier de version",
+ "en": "Enter = decide at the version bump",
+ },
+ "what each copy holds": {
+ "fr": "ce que chaque copie porte",
+ "en": "what each copy holds",
+ },
+ "why it breaks": {
+ "fr": "pourquoi ça casse",
+ "en": "why it breaks",
+ },
+ "full screen": {
+ "fr": "plein écran",
+ "en": "full screen",
+ },
+ "neutralize now, reversible": {
+ "fr": "neutraliser maintenant, réversible",
+ "en": "neutralize now, reversible",
+ },
+ "Undo with": {
+ "fr": "Annuler avec",
+ "en": "Undo with",
+ },
+ "Nothing decided": {
+ "fr": "Rien de décidé",
+ "en": "Nothing decided",
+ },
+ "the migration will ask again at the version bump.": {
+ "fr": "la migration reposera la question au palier de version.",
+ "en": "the migration will ask again at the version bump.",
+ },
+ "Target version directory not found": {
+ "fr": "Répertoire de la version cible introuvable",
+ "en": "Target version directory not found",
+ },
+ "No website COW view changes shape in": {
+ "fr": "Aucune vue COW de site ne change de forme dans",
+ "en": "No website COW view changes shape in",
+ },
+ "website COW view(s) will break when moving to": {
+ "fr": "vue(s) COW de site casseront au passage à",
+ "en": "website COW view(s) will break when moving to",
+ },
+ "the copy keeps an arch whose shape no longer matches what": {
+ "fr": "la copie garde une arch dont la forme ne correspond plus à ce que",
+ "en": "the copy keeps an arch whose shape no longer matches what",
+ },
+ "the target module view expects.": {
+ "fr": "la vue module cible attend.",
+ "en": "the target module view expects.",
+ },
+ "The migration offers to neutralize them, and shows what": {
+ "fr": "La migration propose de les neutraliser, et montre ce que",
+ "en": "The migration offers to neutralize them, and shows what",
+ },
+ "each copy holds before you answer. To look now:": {
+ "fr": "chaque copie porte avant que vous répondiez. Pour regarder maintenant :",
+ "en": "each copy holds before you answer. To look now:",
+ },
+ "reversible with --restore": {
+ "fr": "réversible avec --restore",
+ "en": "reversible with --restore",
+ },
+ "Or by hand. To neutralize a copy, rename its key": {
+ "fr": "Ou à la main. Pour neutraliser une copie, renommer sa clé",
+ "en": "Or by hand. To neutralize a copy, rename its key",
+ },
+ "an unmatched key is never paired with the": {
+ "fr": "une clé sans correspondance n'est jamais appariée à la",
+ "en": "an unmatched key is never paired with the",
+ },
+ "module view, so the copy never receives the new": {
+ "fr": "vue module, la copie ne reçoit donc jamais le nouveau",
+ "en": "module view, so the copy never receives the new",
+ },
+ "Setting active=false alone is NOT enough:": {
+ "fr": "Mettre active=false seul ne suffit PAS :",
+ "en": "Setting active=false alone is NOT enough:",
+ },
+ "an inactive copy keeping the same key still shadows it.": {
+ "fr": "une copie inactive gardant la même clé la masque toujours.",
+ "en": "an inactive copy keeping the same key still shadows it.",
+ },
+ "COW view(s) belong to a module absent from": {
+ "fr": "vue(s) COW appartiennent à un module absent de",
+ "en": "COW view(s) belong to a module absent from",
+ },
+ "COW view(s) are pages or records made in the website": {
+ "fr": "vue(s) COW sont des pages ou des enregistrements faits dans",
+ "en": "COW view(s) are pages or records made in the website",
+ },
+ "editor (no module view of that name): not at risk.": {
+ "fr": "l'éditeur de site (aucune vue module de ce nom) : hors de danger.",
+ "en": "editor (no module view of that name): not at risk.",
+ },
+ "Use -v to list them.": {
+ "fr": "Utiliser -v pour les lister.",
+ "en": "Use -v to list them.",
+ },
+ "No view with this prefix on": {
+ "fr": "Aucune vue avec ce préfixe sur",
+ "en": "No view with this prefix on",
+ },
+ "archived COW view(s) on": {
+ "fr": "vue(s) COW archivées sur",
+ "en": "archived COW view(s) on",
+ },
+ "Their arch is intact. Restore them all with --restore,": {
+ "fr": "Leur arch est intacte. Toutes les restaurer avec --restore,",
+ "en": "Their arch is intact. Restore them all with --restore,",
+ },
+ "once the module view they shadow has the right shape.": {
+ "fr": "une fois que la vue module qu'elles masquent a la bonne forme.",
+ "en": "once the module view they shadow has the right shape.",
+ },
+ "COW view(s) restored on": {
+ "fr": "vue(s) COW restaurées sur",
+ "en": "COW view(s) restored on",
+ },
+ "No website COW view to neutralize.": {
+ "fr": "Aucune vue COW de site à neutraliser.",
+ "en": "No website COW view to neutralize.",
+ },
+ "website COW view(s) would break the bump": {
+ "fr": "vue(s) COW de site casseraient le palier",
+ "en": "website COW view(s) would break the bump",
+ },
+ "to": {
+ "fr": "vers",
+ "en": "to",
+ },
+ "Dry-run. Add --apply to rename their key to": {
+ "fr": "Simulation. Ajouter --apply pour renommer leur clé en",
+ "en": "Dry-run. Add --apply to rename their key to",
+ },
+ "and deactivate them. Reversible": {
+ "fr": "et les désactiver. Réversible",
+ "en": "and deactivate them. Reversible",
+ },
+ "with --restore; the arch stays in database.": {
+ "fr": "avec --restore ; l'arch reste en base.",
+ "en": "with --restore; the arch stays in database.",
+ },
+ "COW view(s) neutralized (key prefixed with": {
+ "fr": "vue(s) COW neutralisées (clé préfixée par",
+ "en": "COW view(s) neutralized (key prefixed with",
+ },
+ "deactivated). The arch is kept as an": {
+ "fr": "désactivées). L'arch est conservée comme",
+ "en": "deactivated). The arch is kept as an",
+ },
+ "archive; use --restore to undo.": {
+ "fr": "archive ; utiliser --restore pour annuler.",
+ "en": "archive; use --restore to undo.",
+ },
+ "target inherits, copy holds a standalone template": {
+ "fr": "la cible hérite, la copie porte un modèle autonome",
+ "en": "target inherits, copy holds a standalone template",
+ },
+ "target is a root view, copy holds inheritance specs": {
+ "fr": "la cible est une vue racine, la copie porte des specs d'héritage",
+ "en": "target is a root view, copy holds inheritance specs",
+ },
+ "No module view carries this key, so there is nothing to": {
+ "fr": "Aucune vue module ne porte cette clé, il n'y a donc rien à",
+ "en": "No module view carries this key, so there is nothing to",
+ },
+ "compare: this copy is a page made in the website editor.": {
+ "fr": "comparer : cette copie est une page faite dans l'éditeur de site.",
+ "en": "compare: this copy is a page made in the website editor.",
+ },
+ "The copy is identical to the module view.": {
+ "fr": "La copie est identique à la vue module.",
+ "en": "The copy is identical to the module view.",
+ },
+ "line(s) added": {
+ "fr": "ligne(s) ajoutée(s)",
+ "en": "line(s) added",
+ },
+ "removed": {
+ "fr": "retirée(s)",
+ "en": "removed",
+ },
+ "this is what neutralizing gives up.": {
+ "fr": "c'est ce que la neutralisation abandonne.",
+ "en": "this is what neutralizing gives up.",
+ },
+ "the module no longer declares this template": {
+ "fr": "le module ne déclare plus ce modèle",
+ "en": "the module no longer declares this template",
+ },
+ "inheritance specs (needs inherit_id)": {
+ "fr": "des specs d'héritage (exige inherit_id)",
+ "en": "inheritance specs (needs inherit_id)",
+ },
+ "a standalone template": {
+ "fr": "un modèle autonome",
+ "en": "a standalone template",
+ },
+ "Odoo changing the shape of its own template is NOT the": {
+ "fr": "Qu'Odoo change la forme de son propre modèle n'est PAS le",
+ "en": "Odoo changing the shape of its own template is NOT the",
+ },
+ "problem: on a database without a copy, the module upgrade": {
+ "fr": "problème : sur une base sans copie, la mise à jour du module",
+ "en": "problem: on a database without a copy, the module upgrade",
+ },
+ "rewrites the view and nothing breaks. It breaks here because": {
+ "fr": "réécrit la vue et rien ne casse. Ça casse ici parce qu'une",
+ "en": "rewrites the view and nothing breaks. It breaks here because",
+ },
+ "a COPY exists and froze the old shape — the copy follows the": {
+ "fr": "COPIE existe et a figé l'ancienne forme — la copie suit le",
+ "en": "a COPY exists and froze the old shape — the copy follows the",
+ },
+ "module and becomes an extension, while still holding a": {
+ "fr": "module et devient une extension, tout en portant encore un",
+ "en": "module and becomes an extension, while still holding a",
+ },
+ "standalone template. Odoo then applies that template as an": {
+ "fr": "modèle autonome. Odoo applique alors ce modèle comme une",
+ "en": "standalone template. Odoo then applies that template as an",
+ },
+ "inheritance spec and stops on « cannot be located in parent": {
+ "fr": "spec d'héritage et s'arrête sur « cannot be located in parent",
+ "en": "inheritance spec and stops on « cannot be located in parent",
+ },
+ "view ».": {
+ "fr": "view ».",
+ "en": "view ».",
+ },
+ "This copy has no module view of that name: it is a page": {
+ "fr": "Cette copie n'a aucune vue module de ce nom : c'est une page",
+ "en": "This copy has no module view of that name: it is a page",
+ },
+ "made in the website editor, and nothing holds its content.": {
+ "fr": "faite dans l'éditeur de site, et rien d'autre ne porte son contenu.",
+ "en": "made in the website editor, and nothing holds its content.",
+ },
+ "This copy is IDENTICAL to the module view it shadows: it": {
+ "fr": "Cette copie est IDENTIQUE à la vue module qu'elle masque : elle",
+ "en": "This copy is IDENTICAL to the module view it shadows: it",
+ },
+ "holds no customization, so neutralizing it loses nothing.": {
+ "fr": "ne porte aucune personnalisation, la neutraliser ne perd rien.",
+ "en": "holds no customization, so neutralizing it loses nothing.",
+ },
+ "This copy differs from the module view by": {
+ "fr": "Cette copie diffère de la vue module de",
+ "en": "This copy differs from the module view by",
+ },
+ "line(s):": {
+ "fr": "ligne(s) :",
+ "en": "line(s):",
+ },
+ "that is the customization, and all neutralizing gives up.": {
+ "fr": "c'est la personnalisation, et tout ce que la neutralisation abandonne.",
+ "en": "that is the customization, and all neutralizing gives up.",
+ },
+ "Run without --shape to read it.": {
+ "fr": "Lancer sans --shape pour la lire.",
+ "en": "Run without --shape to read it.",
+ },
+ "No website COW view is at risk.": {
+ "fr": "Aucune vue COW de site n'est en danger.",
+ "en": "No website COW view is at risk.",
+ },
+ "No COW copy has drifted from its module view.": {
+ "fr": "Aucune copie COW n'a dérivé de sa vue module.",
+ "en": "No COW copy has drifted from its module view.",
+ },
+ "COW copy(ies) drifted from their module": {
+ "fr": "copie(s) COW ont dérivé de leur vue",
+ "en": "COW copy(ies) drifted from their module",
+ },
+ "view in": {
+ "fr": "module dans",
+ "en": "view in",
+ },
+ "Odoo surfaces this when the module view is rewritten (a": {
+ "fr": "Odoo le fait apparaître quand la vue module est réécrite (un",
+ "en": "Odoo surfaces this when the module view is rewritten (a",
+ },
+ "version bump) or when the page is rendered.": {
+ "fr": "palier de version) ou quand la page est rendue.",
+ "en": "version bump) or when the page is rendered.",
+ },
+ "NO module view with this key": {
+ "fr": "AUCUNE vue module avec cette clé",
+ "en": "NO module view with this key",
+ },
+ "child": {
+ "fr": "enfant",
+ "en": "child",
+ },
+ "cannot apply": {
+ "fr": "ne peut s'appliquer",
+ "en": "cannot apply",
+ },
+ "Nothing changed. Re-run with --reset --apply to": {
+ "fr": "Rien n'a changé. Relancer avec --reset --apply pour",
+ "en": "Nothing changed. Re-run with --reset --apply to",
+ },
+ "reset a copy onto its module view.": {
+ "fr": "réinitialiser une copie sur sa vue module.",
+ "en": "reset a copy onto its module view.",
+ },
+ "no module view to reset onto, skipped.": {
+ "fr": "aucune vue module sur laquelle réinitialiser, ignorée.",
+ "en": "no module view to reset onto, skipped.",
+ },
+ "dry-run": {
+ "fr": "simulation",
+ "en": "dry-run",
+ },
+ "would reset": {
+ "fr": "réinitialiserait",
+ "en": "would reset",
+ },
+ "onto": {
+ "fr": "sur",
+ "en": "onto",
+ },
+ "reset": {
+ "fr": "réinitialisé",
+ "en": "reset",
+ },
+ "previous arch saved to": {
+ "fr": "arch précédente sauvegardée dans",
+ "en": "previous arch saved to",
+ },
+ "copy(ies) reset. Re-apply any real customisation": {
+ "fr": "copie(s) réinitialisée(s). Réappliquer toute vraie personnalisation",
+ "en": "copy(ies) reset. Re-apply any real customisation",
+ },
+ "as an INHERITING view, not a copy, so it cannot go stale": {
+ "fr": "comme une vue HÉRITANTE, pas une copie, pour qu'elle ne périme plus",
+ "en": "as an INHERITING view, not a copy, so it cannot go stale",
+ },
+ "again.": {
+ "fr": "à l'avenir.",
+ "en": "again.",
+ },
+ "lxml is required to resolve the xpath expressions.": {
+ "fr": "lxml est requis pour résoudre les expressions xpath.",
+ "en": "lxml is required to resolve the xpath expressions.",
+ },
+ "COW view(s) recorded in": {
+ "fr": "vue(s) COW enregistrées dans",
+ "en": "COW view(s) recorded in",
+ },
+ "COW view(s) disappeared": {
+ "fr": "vue(s) COW ont disparu",
+ "en": "COW view(s) disappeared",
+ },
+ "COW view(s) appeared": {
+ "fr": "vue(s) COW sont apparues",
+ "en": "COW view(s) appeared",
+ },
+ "arch rewritten": {
+ "fr": "arch réécrite",
+ "en": "arch rewritten",
+ },
+ "COW view(s) changed": {
+ "fr": "vue(s) COW ont changé",
+ "en": "COW view(s) changed",
+ },
+ "No change on the website COW views.": {
+ "fr": "Aucun changement sur les vues COW de site.",
+ "en": "No change on the website COW views.",
+ },
+ "Welcome to the Odoo database upgrade with ERPLibre": {
+ "fr": "Bienvenue dans la mise à niveau de base de données Odoo avec ERPLibre",
+ "en": "Welcome to the Odoo database upgrade with ERPLibre",
+ },
+ "Select the zip file of your database backup.": {
+ "fr": "Sélectionnez le fichier zip de votre sauvegarde de base de données.",
+ "en": "Select the zip file of your database backup.",
+ },
+ "Give the path of the file, or empty to use a file": {
+ "fr": "Donnez le chemin du fichier, ou rien pour utiliser un",
+ "en": "Give the path of the file, or empty to use a file",
+ },
+ "browser, or type": {
+ "fr": "navigateur de fichiers, ou tapez",
+ "en": "browser, or type",
+ },
+ "to download from production": {
+ "fr": "pour télécharger depuis la production",
+ "en": "to download from production",
+ },
+ "Cannot retrieve the database from remote, please retry the migration.": {
+ "fr": "Impossible de récupérer la base distante, veuillez relancer la migration.",
+ "en": "Cannot retrieve the database from remote, please retry the migration.",
+ },
+ "Open file": {
+ "fr": "Ouverture du fichier",
+ "en": "Open file",
+ },
+ "Detected Odoo CE version": {
+ "fr": "Version Odoo CE détectée",
+ "en": "Detected Odoo CE version",
+ },
+ "Which version do you want to upgrade to?": {
+ "fr": "Vers quelle version voulez-vous migrer ?",
+ "en": "Which version do you want to upgrade to?",
+ },
+ "Search the Odoo version": {
+ "fr": "Recherche de la version Odoo",
+ "en": "Search the Odoo version",
+ },
+ "Find the right environment, read the .zip file": {
+ "fr": "Recherche du bon environnement, lecture du fichier .zip",
+ "en": "Find the right environment, read the .zip file",
+ },
+ "Would you like to install": {
+ "fr": "Voulez-vous installer",
+ "en": "Would you like to install",
+ },
+ "You need an installed system before": {
+ "fr": "Il faut un système installé avant de",
+ "en": "You need an installed system before",
+ },
+ "continuing, check your Odoo installation.": {
+ "fr": "continuer, vérifiez votre installation Odoo.",
+ "en": "continuing, check your Odoo installation.",
+ },
+ "Install the environment if missing": {
+ "fr": "Installation de l'environnement s'il manque",
+ "en": "Install the environment if missing",
+ },
+ "Search missing modules": {
+ "fr": "Recherche des modules manquants",
+ "en": "Search missing modules",
+ },
+ "Install the missing modules, search for them or": {
+ "fr": "Installer les modules manquants, les chercher ou",
+ "en": "Install the missing modules, search for them or",
+ },
+ "ask to uninstall them (can break data)": {
+ "fr": "demander à les désinstaller (peut casser des données)",
+ "en": "ask to uninstall them (can break data)",
+ },
+ "Cannot set up the environment to begin.": {
+ "fr": "Impossible de préparer l'environnement pour commencer.",
+ "en": "Cannot set up the environment to begin.",
+ },
+ "Missing module": {
+ "fr": "Module manquant",
+ "en": "Missing module",
+ },
+ "Duplicate module": {
+ "fr": "Module en double",
+ "en": "Duplicate module",
+ },
+ "Missing or duplicate module detected at init,": {
+ "fr": "Module manquant ou en double détecté à l'init,",
+ "en": "Missing or duplicate module detected at init,",
+ },
+ "do you want to continue?": {
+ "fr": "voulez-vous continuer ?",
+ "en": "do you want to continue?",
+ },
+ "Missing or duplicate module detected at init, do": {
+ "fr": "Module manquant ou en double détecté à l'init,",
+ "en": "Missing or duplicate module detected at init, do",
+ },
+ "you want to continue?": {
+ "fr": "voulez-vous continuer ?",
+ "en": "you want to continue?",
+ },
+ "Which database name do you want to work with?": {
+ "fr": "Avec quel nom de base voulez-vous travailler ?",
+ "en": "Which database name do you want to work with?",
+ },
+ "Ignore the database neutralization": {
+ "fr": "Ignorer la neutralisation de la base",
+ "en": "Ignore the database neutralization",
+ },
+ "Working with database": {
+ "fr": "Travail sur la base",
+ "en": "Working with database",
+ },
+ "will copy": {
+ "fr": "va copier",
+ "en": "will copy",
+ },
+ "a file already exists, do you want to": {
+ "fr": "un fichier existe déjà, voulez-vous",
+ "en": "a file already exists, do you want to",
+ },
+ "continue?": {
+ "fr": "continuer ?",
+ "en": "continue?",
+ },
+ "Restore the database": {
+ "fr": "Restauration de la base",
+ "en": "Restore the database",
+ },
+ "Update all addons before neutralizing (already": {
+ "fr": "Mettre à jour tous les modules avant la neutralisation (déjà",
+ "en": "Update all addons before neutralizing (already",
+ },
+ "neutralized by Odoo if supported)": {
+ "fr": "neutralisée par Odoo si pris en charge)",
+ "en": "neutralized by Odoo if supported)",
+ },
+ "Do you need to upgrade before neutralizing the": {
+ "fr": "Faut-il mettre à jour avant de neutraliser la",
+ "en": "Do you need to upgrade before neutralizing the",
+ },
+ "database? Press enter to ignore": {
+ "fr": "base ? Entrée pour ignorer",
+ "en": "database? Press enter to ignore",
+ },
+ "Update the database before neutralizing, by module": {
+ "fr": "Mise à jour de la base avant neutralisation, par module",
+ "en": "Update the database before neutralizing, by module",
+ },
+ "Neutralize the database": {
+ "fr": "Neutralisation de la base",
+ "en": "Neutralize the database",
+ },
+ "Modules to uninstall before the migration": {
+ "fr": "Modules à désinstaller avant la migration",
+ "en": "Modules to uninstall before the migration",
+ },
+ "Uninstall modules": {
+ "fr": "Désinstallation des modules",
+ "en": "Uninstall modules",
+ },
+ "Install modules": {
+ "fr": "Installation des modules",
+ "en": "Install modules",
+ },
+ "Go to Settings / Technical / Cleanup... / Purge and": {
+ "fr": "Aller dans Configuration / Technique / Nettoyage... / Purger et",
+ "en": "Go to Settings / Technical / Cleanup... / Purge and",
+ },
+ "purge the obsolete modules": {
+ "fr": "purger les modules obsolètes",
+ "en": "purge the obsolete modules",
+ },
+ "Press enter to continue step 3": {
+ "fr": "Entrée pour continuer l'étape 3",
+ "en": "Press enter to continue step 3",
+ },
+ "Inspect zip": {
+ "fr": "Inspection du zip",
+ "en": "Inspect zip",
+ },
+ "Import database from zip": {
+ "fr": "Import de la base depuis le zip",
+ "en": "Import database from zip",
+ },
+ "Succeed update all addons": {
+ "fr": "Mise à jour de tous les modules",
+ "en": "Succeed update all addons",
+ },
+ "Clean up database before data migration": {
+ "fr": "Nettoyage de la base avant la migration des données",
+ "en": "Clean up database before data migration",
+ },
+ "Upgrade version with OpenUpgrade": {
+ "fr": "Montée de version avec OpenUpgrade",
+ "en": "Upgrade version with OpenUpgrade",
+ },
+ "Cleaning up database after upgrade": {
+ "fr": "Nettoyage de la base après la montée de version",
+ "en": "Cleaning up database after upgrade",
+ },
+ "Migration finished": {
+ "fr": "Migration terminée",
+ "en": "Migration finished",
+ },
+ "Clone to Odoo": {
+ "fr": "Clonage vers Odoo",
+ "en": "Clone to Odoo",
+ },
+ "FAILED (status": {
+ "fr": "ÉCHEC (statut",
+ "en": "FAILED (status",
+ },
+ "Stopping:": {
+ "fr": "Arrêt :",
+ "en": "Stopping:",
+ },
+ "is not usable.": {
+ "fr": "n'est pas utilisable.",
+ "en": "is not usable.",
+ },
+ "Clone Odoo": {
+ "fr": "Clonage Odoo",
+ "en": "Clone Odoo",
+ },
+ "nothing": {
+ "fr": "rien",
+ "en": "nothing",
+ },
+ "Switch Odoo": {
+ "fr": "Bascule Odoo",
+ "en": "Switch Odoo",
+ },
+ "done with update": {
+ "fr": "terminée avec mise à jour",
+ "en": "done with update",
+ },
+ "Module upgrade Odoo": {
+ "fr": "Mise à jour des modules Odoo",
+ "en": "Module upgrade Odoo",
+ },
+ "Database upgrade Odoo": {
+ "fr": "Migration de la base Odoo",
+ "en": "Database upgrade Odoo",
+ },
+ "Modules to uninstall before Odoo": {
+ "fr": "Modules à désinstaller avant Odoo",
+ "en": "Modules to uninstall before Odoo",
+ },
+ "to redo the command": {
+ "fr": "pour refaire la commande",
+ "en": "to redo the command",
+ },
+ "Error detected, press enter to continue or": {
+ "fr": "Erreur détectée, Entrée pour continuer ou",
+ "en": "Error detected, press enter to continue or",
+ },
+ "to stop": {
+ "fr": "pour arrêter",
+ "en": "to stop",
+ },
+ "Clone done for Odoo": {
+ "fr": "Clonage terminé pour Odoo",
+ "en": "Clone done for Odoo",
+ },
+ "Clone already done for Odoo": {
+ "fr": "Clonage déjà fait pour Odoo",
+ "en": "Clone already done for Odoo",
+ },
+ "Switch already done for Odoo": {
+ "fr": "Bascule déjà faite pour Odoo",
+ "en": "Switch already done for Odoo",
+ },
+ "Switch done with update for Odoo": {
+ "fr": "Bascule terminée avec mise à jour pour Odoo",
+ "en": "Switch done with update for Odoo",
+ },
+ "Module upgrade done for Odoo": {
+ "fr": "Mise à jour des modules terminée pour Odoo",
+ "en": "Module upgrade done for Odoo",
+ },
+ "Module upgrade already done for Odoo": {
+ "fr": "Mise à jour des modules déjà faite pour Odoo",
+ "en": "Module upgrade already done for Odoo",
+ },
+ "Database upgrade done for Odoo": {
+ "fr": "Migration de la base terminée pour Odoo",
+ "en": "Database upgrade done for Odoo",
+ },
+ "Database upgrade already done for Odoo": {
+ "fr": "Migration de la base déjà faite pour Odoo",
+ "en": "Database upgrade already done for Odoo",
+ },
+ "Cloning to Odoo": {
+ "fr": "Clonage vers Odoo",
+ "en": "Cloning to Odoo",
+ },
+ "from": {
+ "fr": "depuis",
+ "en": "from",
+ },
+ "Duplicate module in Odoo": {
+ "fr": "Module en double dans Odoo",
+ "en": "Duplicate module in Odoo",
+ },
+ "Duplicate module error detected, handle it": {
+ "fr": "Erreur de module en double détectée, la traiter",
+ "en": "Duplicate module error detected, handle it",
+ },
+ "manually then press enter to continue.": {
+ "fr": "manuellement puis Entrée pour continuer.",
+ "en": "manually then press enter to continue.",
+ },
+ "Missing module error detected, missing in": {
+ "fr": "Erreur de module manquant détectée, manquant dans",
+ "en": "Missing module error detected, missing in",
+ },
+ "All of the list above": {
+ "fr": "Toute la liste ci-dessus",
+ "en": "All of the list above",
+ },
+ "Add an extra custom one": {
+ "fr": "Ajouter un module personnalisé",
+ "en": "Add an extra custom one",
+ },
+ "List the missing modules to delete,": {
+ "fr": "Énumérer les modules manquants à supprimer,",
+ "en": "List the missing modules to delete,",
+ },
+ "separated by commas. The others will be": {
+ "fr": "séparés par des virgules. Les autres seront",
+ "en": "separated by commas. The others will be",
+ },
+ "migrated": {
+ "fr": "migrés",
+ "en": "migrated",
+ },
+ "Migration fix for Odoo": {
+ "fr": "Correctif de migration pour Odoo",
+ "en": "Migration fix for Odoo",
+ },
+ "Migration fix done for Odoo": {
+ "fr": "Correctif de migration terminé pour Odoo",
+ "en": "Migration fix done for Odoo",
+ },
+ "No migration fix to run for Odoo": {
+ "fr": "Aucun correctif de migration à exécuter pour Odoo",
+ "en": "No migration fix to run for Odoo",
+ },
+ "Migration fix already done for Odoo": {
+ "fr": "Correctif de migration déjà fait pour Odoo",
+ "en": "Migration fix already done for Odoo",
+ },
+ "List the module names to delete,": {
+ "fr": "Énumérer les noms de modules à supprimer,",
+ "en": "List the module names to delete,",
+ },
+ "separated by commas": {
+ "fr": "séparés par des virgules",
+ "en": "separated by commas",
+ },
+ "Missing module error": {
+ "fr": "Erreur de module manquant",
+ "en": "Missing module error",
+ },
+ "Duplicate module error": {
+ "fr": "Erreur de module en double",
+ "en": "Duplicate module error",
+ },
+ "Module error": {
+ "fr": "Erreur de module",
+ "en": "Module error",
+ },
+ "Please validate the commits after the code": {
+ "fr": "Veuillez valider les commits après la migration",
+ "en": "Please validate the commits after the code",
+ },
+ "migration.": {
+ "fr": "du code.",
+ "en": "migration.",
+ },
+ "To show the repo status": {
+ "fr": "Pour afficher l'état des dépôts",
+ "en": "To show the repo status",
+ },
+ "Please validate this path in config.conf": {
+ "fr": "Veuillez valider ce chemin dans config.conf",
+ "en": "Please validate this path in config.conf",
+ },
+ "Database migration to Odoo": {
+ "fr": "Migration de la base vers Odoo",
+ "en": "Database migration to Odoo",
+ },
+ "is now half migrated: replaying the command on": {
+ "fr": "est maintenant à moitié migrée : rejouer la commande dessus",
+ "en": "is now half migrated: replaying the command on",
+ },
+ "it would never recover, so it is NOT offered.": {
+ "fr": "ne s'en remettrait jamais, ce n'est donc PAS proposé.",
+ "en": "it would never recover, so it is NOT offered.",
+ },
+ "The clone step has been reset. Fix the": {
+ "fr": "L'étape de clonage a été réinitialisée. Corrigez la",
+ "en": "The clone step has been reset. Fix the",
+ },
+ "cause, then relaunch the migration and answer": {
+ "fr": "cause, puis relancez la migration et répondez",
+ "en": "cause, then relaunch the migration and answer",
+ },
+ "continue": {
+ "fr": "continuer",
+ "en": "continue",
+ },
+ "will be dropped and rebuilt from the previous": {
+ "fr": "sera supprimée et reconstruite depuis la version",
+ "en": "will be dropped and rebuilt from the previous",
+ },
+ "version before retrying.": {
+ "fr": "précédente avant de réessayer.",
+ "en": "version before retrying.",
+ },
+ "Do you want to upgrade all": {
+ "fr": "Voulez-vous tout mettre à jour",
+ "en": "Do you want to upgrade all",
+ },
+ "Press y/Y to upgrade all addons of the": {
+ "fr": "y/Y pour mettre à jour tous les modules de la",
+ "en": "Press y/Y to upgrade all addons of the",
+ },
+ "Open the server with Selenium": {
+ "fr": "Ouvrir le serveur avec Selenium",
+ "en": "Open the server with Selenium",
+ },
+ "Do you want to test this upgrade? Choose": {
+ "fr": "Voulez-vous tester cette migration ? Choisissez",
+ "en": "Do you want to test this upgrade? Choose",
+ },
+ "or press enter to ignore it": {
+ "fr": "ou Entrée pour ignorer",
+ "en": "or press enter to ignore it",
+ },
+ "Press enter to continue": {
+ "fr": "Entrée pour continuer",
+ "en": "Press enter to continue",
+ },
+ "Re-update i18n, purge the data and the tables": {
+ "fr": "Remise à jour de l'i18n, purge des données et des tables",
+ "en": "Re-update i18n, purge the data and the tables",
+ },
+ "except mail_test and mail_test_full": {
+ "fr": "sauf mail_test et mail_test_full",
+ "en": "except mail_test and mail_test_full",
+ },
+ "A backup can be created": {
+ "fr": "Une sauvegarde peut être créée",
+ "en": "A backup can be created",
+ },
+ "Press y/Y or type filename.zip to export, or": {
+ "fr": "y/Y ou un nom fichier.zip pour exporter, ou",
+ "en": "Press y/Y or type filename.zip to export, or",
+ },
+ "enter to continue": {
+ "fr": "Entrée pour continuer",
+ "en": "enter to continue",
+ },
+ "Test the migration, press y/Y": {
+ "fr": "Tester la migration, y/Y",
+ "en": "Test the migration, press y/Y",
+ },
+ "Documentation for this version": {
+ "fr": "Documentation de cette version",
+ "en": "Documentation for this version",
+ },
+ "Command not found": {
+ "fr": "Commande non trouvée",
+ "en": "Command not found",
+ },
+ "Execute command": {
+ "fr": "Exécution de la commande",
+ "en": "Execute command",
+ },
+ "Textual is missing from this interpreter:": {
+ "fr": "Textual manque à cet interpréteur :",
+ "en": "Textual is missing from this interpreter:",
+ },
+ "Run it with": {
+ "fr": "Le lancer avec",
+ "en": "Run it with",
+ },
+ "No leftover for theme": {
+ "fr": "Aucun reste pour le thème",
+ "en": "No leftover for theme",
+ },
+ "attachment(s) still under": {
+ "fr": "pièce(s) jointe(s) encore sous",
+ "en": "attachment(s) still under",
+ },
+ "view(s) whose key still names it": {
+ "fr": "vue(s) dont la clé le nomme encore",
+ "en": "view(s) whose key still names it",
+ },
+ "Nothing was deleted: their content may be the only trace": {
+ "fr": "Rien n'a été supprimé : leur contenu peut être la seule trace",
+ "en": "Nothing was deleted: their content may be the only trace",
+ },
+ "of a customization. Read before removing.": {
+ "fr": "d'une personnalisation. Lire avant de retirer.",
+ "en": "of a customization. Read before removing.",
+ },
+ "Installed theme(s) on": {
+ "fr": "Thème(s) installé(s) sur",
+ "en": "Installed theme(s) on",
+ },
+ "A theme carries view copies and SCSS through every": {
+ "fr": "Un thème traîne des copies de vues et des SCSS à travers chaque",
+ "en": "A theme carries view copies and SCSS through every",
+ },
+ "version bump, and a bump can rename what they rely on.": {
+ "fr": "palier de version, et un palier peut renommer ce dont ils dépendent.",
+ "en": "version bump, and a bump can rename what they rely on.",
+ },
+ "Uninstall them properly before migrating?": {
+ "fr": "Les désinstaller proprement avant de migrer ?",
+ "en": "Uninstall them properly before migrating?",
+ },
+ "Kept. Nothing was uninstalled.": {
+ "fr": "Conservés. Rien n'a été désinstallé.",
+ "en": "Kept. Nothing was uninstalled.",
+ },
+ "No customized SCSS is at risk in": {
+ "fr": "Aucun SCSS personnalisé n'est en danger dans",
+ "en": "No customized SCSS is at risk in",
+ },
+ "customized SCSS use(s) a variable that": {
+ "fr": "SCSS personnalisé(s) utilisent une variable que",
+ "en": "customized SCSS use(s) a variable that",
+ },
+ "no longer defines: the bundle will not compile.": {
+ "fr": "ne définit plus : le bundle ne compilera pas.",
+ "en": "no longer defines: the bundle will not compile.",
+ },
+ "Each one is a copy frozen on an older version. Dropping it": {
+ "fr": "Chacun est une copie figée sur une version antérieure. L'abandonner",
+ "en": "Each one is a copy frozen on an older version. Dropping it",
+ },
+ "restores the module file:": {
+ "fr": "rend le fichier du module :",
+ "en": "restores the module file:",
+ },
+ "Read it first: what it holds beyond the stale variable is": {
+ "fr": "Le lire d'abord : ce qu'il porte au-delà de la variable périmée est",
+ "en": "Read it first: what it holds beyond the stale variable is",
+ },
+ "a real customization, to re-apply as a small file.": {
+ "fr": "une vraie personnalisation, à réappliquer en petit fichier.",
+ "en": "a real customization, to re-apply as a small file.",
+ },
+ "Read the customizations above before continuing.": {
+ "fr": "Lire les personnalisations ci-dessus avant de continuer.",
+ "en": "Read the customizations above before continuing.",
+ },
+ "Missing:": {
+ "fr": "Manquantes :",
+ "en": "Missing:",
+ },
+ "No module file of that name in": {
+ "fr": "Aucun fichier de module de ce nom dans",
+ "en": "No module file of that name in",
+ },
+ "the target no longer ships it, so there is nothing to": {
+ "fr": "la cible ne le livre plus, il n'y a donc rien sur quoi",
+ "en": "the target no longer ships it, so there is nothing to",
+ },
+ "fall back on. Read the copy before dropping it.": {
+ "fr": "retomber. Lire la copie avant de l'abandonner.",
+ "en": "fall back on. Read the copy before dropping it.",
+ },
+ "The copy is identical to the module file.": {
+ "fr": "La copie est identique au fichier du module.",
+ "en": "The copy is identical to the module file.",
+ },
+ "line(s): that is what resetting gives up.": {
+ "fr": "ligne(s) : c'est ce que la réinitialisation abandonne.",
+ "en": "line(s): that is what resetting gives up.",
+ },
+ "What do you want to do with these customizations?": {
+ "fr": "Que voulez-vous faire de ces personnalisations ?",
+ "en": "What do you want to do with these customizations?",
+ },
+ "Enter = nothing": {
+ "fr": "Entrée = rien",
+ "en": "Enter = nothing",
+ },
+ "what the copy changed": {
+ "fr": "ce que la copie a changé",
+ "en": "what the copy changed",
+ },
+ "reset them onto the module file": {
+ "fr": "les réinitialiser sur le fichier du module",
+ "en": "reset them onto the module file",
+ },
+ "Full screen view unavailable.": {
+ "fr": "Vue plein écran indisponible.",
+ "en": "Full screen view unavailable.",
+ },
+ "Saved before resetting": {
+ "fr": "Sauvegardé avant réinitialisation",
+ "en": "Saved before resetting",
+ },
+ "Reset failed, nothing was changed.": {
+ "fr": "La réinitialisation a échoué, rien n'a été modifié.",
+ "en": "Reset failed, nothing was changed.",
+ },
+ "Reset done.": {
+ "fr": "Réinitialisation faite.",
+ "en": "Reset done.",
+ },
+ "This copy uses variables that": {
+ "fr": "Cette copie utilise des variables que",
+ "en": "This copy uses variables that",
+ },
+ "no longer defines": {
+ "fr": "ne définit plus",
+ "en": "no longer defines",
+ },
+ "The copy was written against an older version and frozen": {
+ "fr": "La copie a été écrite contre une version antérieure et figée",
+ "en": "The copy was written against an older version and frozen",
+ },
+ "there. The module has since renamed what it relies on.": {
+ "fr": "là. Le module a depuis renommé ce dont elle dépend.",
+ "en": "there. The module has since renamed what it relies on.",
+ },
+ "Resetting restores": {
+ "fr": "La réinitialisation rend",
+ "en": "Resetting restores",
+ },
+ "(nothing: the target no longer ships this file)": {
+ "fr": "(rien : la cible ne livre plus ce fichier)",
+ "en": "(nothing: the target no longer ships this file)",
+ },
+ "Reset command copied.": {
+ "fr": "Commande de réinitialisation copiée.",
+ "en": "Reset command copied.",
+ },
+ "Resetting needs Odoo 13.0 or later; this checkout is on": {
+ "fr": "La réinitialisation exige Odoo 13.0 ou plus ; ce checkout est sur",
+ "en": "Resetting needs Odoo 13.0 or later; this checkout is on",
+ },
+ "The prediction stands, the fix does not: run it again once": {
+ "fr": "La prédiction tient, pas la correction : relancer une fois",
+ "en": "The prediction stands, the fix does not: run it again once",
+ },
+ "the bump is done, on the upgraded database.": {
+ "fr": "le palier passé, sur la base montée de version.",
+ "en": "the bump is done, on the upgraded database.",
+ },
+ "Applying it from here fails with": {
+ "fr": "L'appliquer d'ici échoue sur",
+ "en": "Applying it from here fails with",
+ },
+ "and changes nothing.": {
+ "fr": "et ne change rien.",
+ "en": "and changes nothing.",
+ },
+ "Reset one of them onto its module view": {
+ "fr": "Réinitialiser l'une d'elles sur sa vue module",
+ "en": "Reset one of them onto its module view",
+ },
+ "Drifted COW copies": {
+ "fr": "Copies COW ayant dérivé",
+ "en": "Drifted COW copies",
+ },
+ "Which one(s) to reset onto the module view?": {
+ "fr": "Laquelle ou lesquelles réinitialiser sur la vue module ?",
+ "en": "Which one(s) to reset onto the module view?",
+ },
+ "numbers separated by commas, a = all, empty =": {
+ "fr": "numéros séparés par des virgules, a = toutes, vide =",
+ "en": "numbers separated by commas, a = all, empty =",
+ },
+ "Kept. Nothing was reset.": {
+ "fr": "Conservées. Rien n'a été réinitialisé.",
+ "en": "Kept. Nothing was reset.",
+ },
+ "Unknown choice, nothing was reset.": {
+ "fr": "Choix inconnu, rien n'a été réinitialisé.",
+ "en": "Unknown choice, nothing was reset.",
+ },
+ "Delete these leftovers, or keep them?": {
+ "fr": "Effacer ces restes, ou les garder ?",
+ "en": "Delete these leftovers, or keep them?",
+ },
+ "Enter = keep": {
+ "fr": "Entrée = garder",
+ "en": "Enter = keep",
+ },
+ "delete, after saving them": {
+ "fr": "effacer, après les avoir sauvegardés",
+ "en": "delete, after saving them",
+ },
+ "Kept. Nothing was deleted.": {
+ "fr": "Conservés. Rien n'a été effacé.",
+ "en": "Kept. Nothing was deleted.",
+ },
+ "Saved before deleting": {
+ "fr": "Sauvegardé avant effacement",
+ "en": "Saved before deleting",
+ },
+ "Deletion failed, nothing was removed.": {
+ "fr": "L'effacement a échoué, rien n'a été retiré.",
+ "en": "Deletion failed, nothing was removed.",
+ },
+ "attachment(s) deleted.": {
+ "fr": "pièce(s) jointe(s) effacée(s).",
+ "en": "attachment(s) deleted.",
+ },
+ "Request every public URL of this database now?": {
+ "fr": "Interroger maintenant toutes les URL publiques de cette base ?",
+ "en": "Request every public URL of this database now?",
+ },
+ "The sitemap listed no URL: nothing was tested.": {
+ "fr": "Le sitemap n'a listé aucune URL : rien n'a été testé.",
+ "en": "The sitemap listed no URL: nothing was tested.",
+ },
+ "public URL(s) answered without error.": {
+ "fr": "URL publique(s) ont répondu sans erreur.",
+ "en": "public URL(s) answered without error.",
+ },
+ "public URL(s) failed": {
+ "fr": "URL publique(s) ont échoué",
+ "en": "public URL(s) failed",
+ },
+ "no answer": {
+ "fr": "aucune réponse",
+ "en": "no answer",
+ },
+ "A page listed for search engines that does not answer is": {
+ "fr": "Une page listée pour les moteurs de recherche qui ne répond pas est",
+ "en": "A page listed for search engines that does not answer is",
+ },
+ "a page your visitors do not reach either.": {
+ "fr": "une page que vos visiteurs n'atteignent pas non plus.",
+ "en": "a page your visitors do not reach either.",
+ },
+ "The server never answered on": {
+ "fr": "Le serveur n'a jamais répondu sur",
+ "en": "The server never answered on",
+ },
+ "Could not read": {
+ "fr": "Impossible de lire",
+ "en": "Could not read",
+ },
+ "Starting Odoo on": {
+ "fr": "Démarrage d'Odoo sur",
+ "en": "Starting Odoo on",
+ },
+ "port": {
+ "fr": "port",
+ "en": "port",
+ },
+ "parent view(s) in cause": {
+ "fr": "vue(s) parente(s) en cause",
+ "en": "parent view(s) in cause",
+ },
+ "Those parents are copies frozen on an older version;": {
+ "fr": "Ces parents sont des copies figées sur une version antérieure ;",
+ "en": "Those parents are copies frozen on an older version;",
+ },
+ "resetting them onto the module view is the fix": {
+ "fr": "les réinitialiser sur la vue module est le correctif",
+ "en": "resetting them onto the module view is the fix",
+ },
+ "No parent view named: nothing to offer.": {
+ "fr": "Aucune vue parente nommée : rien à proposer.",
+ "en": "No parent view named: nothing to offer.",
+ },
+ "Copies to reset onto their module view": {
+ "fr": "Copies à réinitialiser sur leur vue module",
+ "en": "Copies to reset onto their module view",
+ },
+ "Which one(s) to reset?": {
+ "fr": "Laquelle ou lesquelles réinitialiser ?",
+ "en": "Which one(s) to reset?",
+ },
+ "Re-checked the": {
+ "fr": "Revérification des",
+ "en": "Re-checked the",
+ },
+ "failing URL(s) after the reset": {
+ "fr": "URL en échec après la réinitialisation",
+ "en": "failing URL(s) after the reset",
+ },
+ "Something already listens on port": {
+ "fr": "Quelque chose écoute déjà sur le port",
+ "en": "Something already listens on port",
+ },
+ "it would be tested instead of this database.": {
+ "fr": "il serait testé à la place de cette base.",
+ "en": "it would be tested instead of this database.",
+ },
+ "Enter =": {
+ "fr": "Entrée =",
+ "en": "Enter =",
+ },
+ "No COW copy carries this key": {
+ "fr": "Aucune copie COW ne porte cette clé",
+ "en": "No COW copy carries this key",
+ },
+ "already identical to the module view.": {
+ "fr": "déjà identique à la vue module.",
+ "en": "already identical to the module view.",
+ },
+ "Odoo surfaces this when the module view is rewritten": {
+ "fr": "Odoo le fait apparaître quand la vue module est réécrite",
+ "en": "Odoo surfaces this when the module view is rewritten",
+ },
+ "(a version bump) or when the page is rendered.": {
+ "fr": "(un palier de version) ou quand la page est rendue.",
+ "en": "(a version bump) or when the page is rendered.",
+ },
+ "Before starting: what already answers, and what": {
+ "fr": "Avant de commencer : ce qui répond déjà, et ce qui",
+ "en": "Before starting: what already answers, and what",
+ },
+ "does not. A page broken now will still be broken": {
+ "fr": "ne répond pas. Une page cassée maintenant le sera encore",
+ "en": "does not. A page broken now will still be broken",
+ },
+ "after — and that is not the migration.": {
+ "fr": "après — et ce ne sera pas la migration.",
+ "en": "after — and that is not the migration.",
+ },
+ "No child fails on this copy: it drifted without": {
+ "fr": "Aucun enfant n'échoue sur cette copie : elle a dérivé sans",
+ "en": "No child fails on this copy: it drifted without",
+ },
+ "breaking anything yet.": {
+ "fr": "encore rien casser.",
+ "en": "breaking anything yet.",
+ },
+ "These children no longer find their anchor in the copy": {
+ "fr": "Ces enfants ne trouvent plus leur point d'ancrage dans la copie",
+ "en": "These children no longer find their anchor in the copy",
+ },
+ "The anchor exists in the module view": {
+ "fr": "L'ancrage existe dans la vue module",
+ "en": "The anchor exists in the module view",
+ },
+ "but not in this copy, frozen on an older version.": {
+ "fr": "mais pas dans cette copie, figée sur une version antérieure.",
+ "en": "but not in this copy, frozen on an older version.",
+ },
+ "Resetting restores the module arch; the customization the": {
+ "fr": "Réinitialiser rend l'arch du module ; la personnalisation que",
+ "en": "Resetting restores the module arch; the customization the",
+ },
+ "copy carried is saved first, and is yours to re-apply as an": {
+ "fr": "la copie portait est sauvegardée d'abord, à vous de la réappliquer en",
+ "en": "copy carried is saved first, and is yours to re-apply as an",
+ },
+ "INHERITING view with its own key.": {
+ "fr": "vue HÉRITANTE avec sa propre clé.",
+ "en": "INHERITING view with its own key.",
+ },
+ "No module view carries this key: nothing to reset": {
+ "fr": "Aucune vue module ne porte cette clé : rien sur quoi",
+ "en": "No module view carries this key: nothing to reset",
+ },
+ "onto.": {
+ "fr": "réinitialiser.",
+ "en": "onto.",
+ },
+ "Browse the differences full screen": {
+ "fr": "Parcourir les différences en plein écran",
+ "en": "Browse the differences full screen",
+ },
+ "The checkout is on Odoo": {
+ "fr": "Le checkout est sur Odoo",
+ "en": "The checkout is on Odoo",
+ },
+ "and the database on": {
+ "fr": "et la base sur",
+ "en": "and the database on",
+ },
+ "Opening it with the wrong version writes to it before": {
+ "fr": "L'ouvrir avec la mauvaise version y écrit avant",
+ "en": "Opening it with the wrong version writes to it before",
+ },
+ "failing. Switch the checkout first.": {
+ "fr": "d'échouer. Basculer le checkout d'abord.",
+ "en": "failing. Switch the checkout first.",
+ },
+ "Database cleanup on": {
+ "fr": "Nettoyage de la base",
+ "en": "Database cleanup on",
+ },
+ "Cleaning": {
+ "fr": "Nettoyage de",
+ "en": "Cleaning",
+ },
+ "pass": {
+ "fr": "passe",
+ "en": "pass",
+ },
+ "purged": {
+ "fr": "purgés",
+ "en": "purged",
+ },
+ "purged;": {
+ "fr": "purgés ;",
+ "en": "purged;",
+ },
+ "could not be, and are left as they are": {
+ "fr": "n'ont pas pu l'être, et restent en l'état",
+ "en": "could not be, and are left as they are",
+ },
+ "entries purged, nothing left.": {
+ "fr": "entrées purgées, rien ne reste.",
+ "en": "entries purged, nothing left.",
+ },
+ "no such wizard in this version, skipped.": {
+ "fr": "pas d'assistant pour cela dans cette version, ignoré.",
+ "en": "no such wizard in this version, skipped.",
+ },
+ "A database can carry leftovers nothing can remove.": {
+ "fr": "Une base peut porter des restes que rien ne peut retirer.",
+ "en": "A database can carry leftovers nothing can remove.",
+ },
+ "This is a warning, not a failure.": {
+ "fr": "C'est un avertissement, pas un échec.",
+ "en": "This is a warning, not a failure.",
+ },
+ "The cleanup produced no report.": {
+ "fr": "Le nettoyage n'a produit aucun rapport.",
+ "en": "The cleanup produced no report.",
+ },
+ "Unreadable report": {
+ "fr": "Rapport illisible",
+ "en": "Unreadable report",
+ },
+ "obsolete models": {
+ "fr": "modèles obsolètes",
+ "en": "obsolete models",
+ },
+ "obsolete modules": {
+ "fr": "modules obsolètes",
+ "en": "obsolete modules",
+ },
+ "obsolete columns": {
+ "fr": "colonnes obsolètes",
+ "en": "obsolete columns",
+ },
+ "obsolete tables": {
+ "fr": "tables obsolètes",
+ "en": "obsolete tables",
+ },
+ "obsolete data entries": {
+ "fr": "entrées de données obsolètes",
+ "en": "obsolete data entries",
+ },
+ "obsolete menu entries": {
+ "fr": "entrées de menu obsolètes",
+ "en": "obsolete menu entries",
+ },
+ "missing indexes": {
+ "fr": "index manquants",
+ "en": "missing indexes",
+ },
+ "obsolete properties": {
+ "fr": "propriétés obsolètes",
+ "en": "obsolete properties",
+ },
+ "entries would be purged": {
+ "fr": "entrées seraient purgées",
+ "en": "entries would be purged",
+ },
+ "nothing was changed": {
+ "fr": "rien n'a été modifié",
+ "en": "nothing was changed",
+ },
+ "Enter = 1": {
+ "fr": "Entrée = 1",
+ "en": "Enter = 1",
+ },
+ "Enter = all, n = none": {
+ "fr": "Entrée = tous, n = aucun",
+ "en": "Enter = all, n = none",
+ },
+ "Enter = delete, after saving them": {
+ "fr": "Entrée = effacer, après les avoir sauvegardés",
+ "en": "Enter = delete, after saving them",
+ },
+ "Enter = neutralize now, reversible": {
+ "fr": "Entrée = neutraliser maintenant, réversible",
+ "en": "Enter = neutralize now, reversible",
+ },
+ "Enter = reset them onto the module file": {
+ "fr": "Entrée = les réinitialiser sur le fichier du module",
+ "en": "Enter = reset them onto the module file",
+ },
+ "Enter upgrades all addons of the": {
+ "fr": "Entrée met à jour tous les modules de la",
+ "en": "Enter upgrades all addons of the",
+ },
+ "Open it at EVERY version bump, stop asking": {
+ "fr": "L'ouvrir à CHAQUE palier de version, ne plus demander",
+ "en": "Open it at EVERY version bump, stop asking",
+ },
+ "Testing every bump, as chosen earlier.": {
+ "fr": "Test à chaque palier, comme choisi plus tôt.",
+ "en": "Testing every bump, as chosen earlier.",
+ },
+ "numbers separated by commas, Enter = all, n =": {
+ "fr": "numéros séparés par des virgules, Entrée = toutes, n =",
+ "en": "numbers separated by commas, Enter = all, n =",
+ },
+ "decide at the version bump": {
+ "fr": "décider au palier de version",
+ "en": "decide at the version bump",
+ },
+ "keep": {
+ "fr": "garder",
+ "en": "keep",
+ },
+ "The server did not serve the login page.": {
+ "fr": "Le serveur n'a pas servi la page de connexion.",
+ "en": "The server did not serve the login page.",
+ },
+ "Wrong login or password for user": {
+ "fr": "Identifiant ou mot de passe refusé pour l'utilisateur",
+ "en": "Wrong login or password for user",
+ },
+ "Could not read the menus": {
+ "fr": "Impossible de lire les menus",
+ "en": "Could not read the menus",
+ },
+ "Apps opened as the test user": {
+ "fr": "Applications ouvertes avec l'utilisateur test",
+ "en": "Apps opened as the test user",
+ },
+ "not openable this way (client or server": {
+ "fr": "non ouvrables ainsi (action client ou",
+ "en": "not openable this way (client or server",
+ },
+ "action): nothing to render.": {
+ "fr": "serveur) : rien à rendre.",
+ "en": "action): nothing to render.",
+ },
+ "opened with an empty domain: theirs": {
+ "fr": "ouvertes avec un domaine vide : le leur",
+ "en": "opened with an empty domain: theirs",
+ },
+ "needs the browser to evaluate it.": {
+ "fr": "demande le navigateur pour être évalué.",
+ "en": "needs the browser to evaluate it.",
+ },
+ "Every app opened its first page.": {
+ "fr": "Chaque application a ouvert sa première page.",
+ "en": "Every app opened its first page.",
+ },
+ "Apps that failed to open": {
+ "fr": "Applications qui n'ont pas pu s'ouvrir",
+ "en": "Apps that failed to open",
+ },
+ "reading the action": {
+ "fr": "lecture de l'action",
+ "en": "reading the action",
+ },
+ "rendering the views": {
+ "fr": "rendu des vues",
+ "en": "rendering the views",
+ },
+ "loading the first records": {
+ "fr": "chargement des premiers enregistrements",
+ "en": "loading the first records",
+ },
+ "These are back-office failures: the public smoke test": {
+ "fr": "Ce sont des échecs du back-office : le test de fumée public",
+ "en": "These are back-office failures: the public smoke test",
+ },
+ "cannot see them.": {
+ "fr": "ne peut pas les voir.",
+ "en": "cannot see them.",
+ },
+ "Could not log in as": {
+ "fr": "Connexion impossible en tant que",
+ "en": "Could not log in as",
+ },
+ "No": {
+ "fr": "Aucun",
+ "en": "No",
+ },
+ "user in": {
+ "fr": "utilisateur dans",
+ "en": "user in",
+ },
+ "the database was not neutralized, nothing to browse.": {
+ "fr": "la base n'a pas été neutralisée, rien à parcourir.",
+ "en": "the database was not neutralized, nothing to browse.",
+ },
+ "Back office not browsed": {
+ "fr": "Back-office non parcouru",
+ "en": "Back office not browsed",
+ },
+ "no test user: the database was not neutralized": {
+ "fr": "pas d'utilisateur test : la base n'a pas été neutralisée",
+ "en": "no test user: the database was not neutralized",
+ },
+ "Could not tell whether the": {
+ "fr": "Impossible de savoir si l'utilisateur",
+ "en": "Could not tell whether the",
+ },
+ "user exists in": {
+ "fr": "existe dans",
+ "en": "user exists in",
+ },
+ "could not tell whether the test user exists": {
+ "fr": "impossible de savoir si l'utilisateur test existe",
+ "en": "could not tell whether the test user exists",
+ },
+ "listing the model fields": {
+ "fr": "inventaire des champs du modèle",
+ "en": "listing the model fields",
+ },
+ "model absent from the running registry": {
+ "fr": "modèle absent du registre en cours",
+ "en": "model absent from the running registry",
+ },
+ "installed in the database, but its module code": {
+ "fr": "installé dans la base, mais le code de son module",
+ "en": "installed in the database, but its module code",
+ },
+ "is not in this addons path.": {
+ "fr": "n'est pas dans cet addons path.",
+ "en": "is not in this addons path.",
+ },
+ "Views naming a field the model no longer has": {
+ "fr": "Vues nommant un champ que le modèle n'a plus",
+ "en": "Views naming a field the model no longer has",
+ },
+ "Public pages, then the back office as the test user": {
+ "fr": "Les pages publiques, puis le back-office avec l'utilisateur test",
+ "en": "Public pages, then the back office as the test user",
+ },
+ "if the database was neutralized.": {
+ "fr": "si la base a été neutralisée.",
+ "en": "if the database was neutralized.",
+ },
+ "Portal": {
+ "fr": "Portail",
+ "en": "Portal",
+ },
+ "Portal pages opened as the test user": {
+ "fr": "Pages du portail ouvertes avec l'utilisateur test",
+ "en": "Portal pages opened as the test user",
+ },
+ "not signed in": {
+ "fr": "pas connecté",
+ "en": "not signed in",
+ },
+ "the page served was the login form": {
+ "fr": "la page servie était le formulaire de connexion",
+ "en": "the page served was the login form",
+ },
+ "server log": {
+ "fr": "journal du serveur",
+ "en": "server log",
+ },
+ "Purging modules reloads the whole registry: on a big": {
+ "fr": "Purger les modules recharge tout le registre : sur un grand",
+ "en": "Purging modules reloads the whole registry: on a big",
+ },
+ "addons path this takes minutes, not seconds.": {
+ "fr": "addons path cela prend des minutes, pas des secondes.",
+ "en": "addons path this takes minutes, not seconds.",
+ },
+ "Took": {
+ "fr": "Durée :",
+ "en": "Took",
+ },
+ "seconds.": {
+ "fr": "secondes.",
+ "en": "seconds.",
+ },
+ "pass": {
+ "fr": "passe",
+ "en": "pass",
+ },
+ "The cleanup was still running after": {
+ "fr": "Le nettoyage tournait encore après",
+ "en": "The cleanup was still running after",
+ },
+ "seconds and was stopped.": {
+ "fr": "secondes et a été arrêté.",
+ "en": "seconds and was stopped.",
+ },
+ "the whole batch": {
+ "fr": "le lot entier",
+ "en": "the whole batch",
+ },
+ "Still failing after": {
+ "fr": "Échoue toujours après",
+ "en": "Still failing after",
+ },
+ "attempts: this one needs a developer.": {
+ "fr": "tentatives : celle-ci demande un développeur.",
+ "en": "attempts: this one needs a developer.",
+ },
+ "Error detected. Choose, or ctrl+c to": {
+ "fr": "Erreur détectée. Choisissez, ou ctrl+c pour",
+ "en": "Error detected. Choose, or ctrl+c to",
+ },
+ "stop": {
+ "fr": "arrêter",
+ "en": "stop",
+ },
+ "Too many turns on this prompt: moving on.": {
+ "fr": "Trop de tours sur cette invite : on passe.",
+ "en": "Too many turns on this prompt: moving on.",
+ },
+ "Please validate git commit on repos, press to": {
+ "fr": "Veuillez valider les commits sur les dépôts, appuyez pour",
+ "en": "Please validate git commit on repos, press to",
+ },
+ "Check the migrated code, press to continue": {
+ "fr": "Vérifiez le code migré, appuyez pour continuer",
+ "en": "Check the migrated code, press to continue",
+ },
+ "Please validate the repo is ready to run": {
+ "fr": "Veuillez valider que le dépôt est prêt pour",
+ "en": "Please validate the repo is ready to run",
+ },
+ "press to continue": {
+ "fr": "appuyez pour continuer",
+ "en": "press to continue",
+ },
+ "smoke_internal_ui.py is missing": {
+ "fr": "smoke_internal_ui.py est absent",
+ "en": "smoke_internal_ui.py is missing",
+ },
+ "the test user is gone from a neutralized database: the back office was NOT checked": {
+ "fr": "l'utilisateur test a disparu d'une base neutralisée : le back-office n'a PAS été vérifié",
+ "en": "the test user is gone from a neutralized database: the back office was NOT checked",
+ },
+ "Back office NOT browsed": {
+ "fr": "Back-office NON parcouru",
+ "en": "Back office NOT browsed",
+ },
+ "Public pages, then the back office and /my as the": {
+ "fr": "Les pages publiques, puis le back-office et /my avec",
+ "en": "Public pages, then the back office and /my as the",
+ },
+ "test user (neutralized database).": {
+ "fr": "l'utilisateur test (base neutralisée).",
+ "en": "test user (neutralized database).",
+ },
+ "Public pages only:": {
+ "fr": "Pages publiques seulement :",
+ "en": "Public pages only:",
+ },
+ "was not neutralized, so there is no test user to": {
+ "fr": "n'a pas été neutralisée, il n'y a donc pas d'utilisateur test pour",
+ "en": "was not neutralized, so there is no test user to",
+ },
+ "sign in with.": {
+ "fr": "s'y connecter.",
+ "en": "sign in with.",
+ },
+ "Migration state": {
+ "fr": "État de la migration",
+ "en": "Migration state",
+ },
+ "started": {
+ "fr": "démarrée",
+ "en": "started",
+ },
+ "last written": {
+ "fr": "dernière écriture",
+ "en": "last written",
+ },
+ "current step": {
+ "fr": "étape courante",
+ "en": "current step",
+ },
+ "Test results": {
+ "fr": "Résultat des tests",
+ "en": "Test results",
+ },
+ "No tool has run yet.": {
+ "fr": "Aucun outil n'a encore tourné.",
+ "en": "No tool has run yet.",
+ },
+ "Commands that failed": {
+ "fr": "Commandes en échec",
+ "en": "Commands that failed",
+ },
+ "What was done, step by step": {
+ "fr": "Ce qui a été fait, étape par étape",
+ "en": "What was done, step by step",
+ },
+ "runs": {
+ "fr": "passages",
+ "en": "runs",
+ },
+ "nothing to report": {
+ "fr": "rien à signaler",
+ "en": "nothing to report",
+ },
+ "findings to look at": {
+ "fr": "des trouvailles à regarder",
+ "en": "findings to look at",
+ },
+ "the tool itself failed": {
+ "fr": "l'outil lui-même a échoué",
+ "en": "the tool itself failed",
+ },
+ "unknown result": {
+ "fr": "résultat inconnu",
+ "en": "unknown result",
+ },
+ "before the first step": {
+ "fr": "avant la première étape",
+ "en": "before the first step",
+ },
+ "No migration in progress.": {
+ "fr": "Aucune migration en cours.",
+ "en": "No migration in progress.",
+ },
+ "show the migration state": {
+ "fr": "afficher l'état de la migration",
+ "en": "show the migration state",
+ },
+ "Nothing to show yet.": {
+ "fr": "Rien à montrer pour l'instant.",
+ "en": "Nothing to show yet.",
+ },
+ "(b = go back a step, t = show the migration state)": {
+ "fr": "(b = revenir à une étape précédente,"
+ " t = afficher l'état de la migration)",
+ "en": "(b = go back a step, t = show the migration state)",
+ },
+ "Migration state (full screen)": {
+ "fr": "État de la migration (plein écran)",
+ "en": "Migration state (full screen)",
+ },
+ "Export a backup?": {
+ "fr": "Exporter une sauvegarde ?",
+ "en": "Export a backup?",
+ },
+ "or type filename.zip": {
+ "fr": "ou tapez nomfichier.zip",
+ "en": "or type filename.zip",
+ },
+ "The login page itself failed": {
+ "fr": "La page de connexion elle-même a échoué",
+ "en": "The login page itself failed",
+ },
+ "No CSRF token on the login page": {
+ "fr": "Aucun jeton CSRF sur la page de connexion",
+ "en": "No CSRF token on the login page",
+ },
+ "after the reset": {
+ "fr": "après la réinitialisation",
+ "en": "after the reset",
+ },
+ "The differential detection found nothing; resetting": {
+ "fr": "La détection différentielle n'a rien trouvé ; réinitialisation",
+ "en": "The differential detection found nothing; resetting",
+ },
+ "the requested key(s) anyway.": {
+ "fr": "de la ou des clés demandées malgré tout.",
+ "en": "the requested key(s) anyway.",
+ },
+ "lines": {
+ "fr": "lignes",
+ "en": "lines",
+ },
+ "press l to show": {
+ "fr": "l pour afficher",
+ "en": "press l to show",
+ },
+ "last": {
+ "fr": "dernières",
+ "en": "last",
+ },
+ "Refresh": {
+ "fr": "Rafraîchir",
+ "en": "Refresh",
+ },
+ "Logs": {
+ "fr": "Journaux",
+ "en": "Logs",
+ },
+ "Panel": {
+ "fr": "Panneau",
+ "en": "Panel",
+ },
+ "Wider": {
+ "fr": "Élargir",
+ "en": "Wider",
+ },
+ "Narrower": {
+ "fr": "Rétrécir",
+ "en": "Narrower",
+ },
+ "finished": {
+ "fr": "terminée",
+ "en": "finished",
+ },
+ "duration": {
+ "fr": "durée",
+ "en": "duration",
+ },
+ "Panels": {
+ "fr": "Panneaux",
+ "en": "Panels",
+ },
+ "summary + list + detail": {
+ "fr": "résumé + liste + détail",
+ "en": "summary + list + detail",
+ },
+ "list + detail": {
+ "fr": "liste + détail",
+ "en": "list + detail",
+ },
+ "detail only": {
+ "fr": "détail seul",
+ "en": "detail only",
+ },
+ "errors in the log": {
+ "fr": "erreurs dans le journal",
+ "en": "errors in the log",
+ },
+ "Migration quality, step by step": {
+ "fr": "Qualité de la migration, palier par palier",
+ "en": "Migration quality, step by step",
+ },
+ "database not found": {
+ "fr": "base introuvable",
+ "en": "database not found",
+ },
+ "attachment files missing from the filestore": {
+ "fr": "fichiers de pièces jointes absents du filestore",
+ "en": "attachment files missing from the filestore",
+ },
+ "From start to finish": {
+ "fr": "Du départ à l'arrivée",
+ "en": "From start to finish",
+ },
+ "not comparable: a database is missing": {
+ "fr": "non comparable : une base manque",
+ "en": "not comparable: a database is missing",
+ },
+ "probable table rename(s)": {
+ "fr": "renommage(s) de table probable(s)",
+ "en": "probable table rename(s)",
+ },
+ "table(s) lost rows": {
+ "fr": "table(s) ont perdu des lignes",
+ "en": "table(s) lost rows",
+ },
+ "attachments": {
+ "fr": "pièces jointes",
+ "en": "attachments",
+ },
+ "probably renamed to": {
+ "fr": "probablement renommée en",
+ "en": "probably renamed to",
+ },
+ "steps": {
+ "fr": "paliers",
+ "en": "steps",
+ },
+ "Migration": {
+ "fr": "🚚 Migration",
+ "en": "🚚 Migration",
+ },
+ "Quality of a migration, step by step": {
+ "fr": "📐 Qualité d'une migration, palier par palier",
+ "en": "📐 Quality of a migration, step by step",
+ },
+ "Quality": {
+ "fr": "Qualité",
+ "en": "Quality",
+ },
+ "Already inside a running screen: open it in its own": {
+ "fr": "Déjà dans un écran en cours : l'ouvrir dans son propre",
+ "en": "Already inside a running screen: open it in its own",
+ },
+ "process instead.": {
+ "fr": "processus.",
+ "en": "process instead.",
+ },
+ "Missing files": {
+ "fr": "Fichiers absents",
+ "en": "Missing files",
+ },
+ "every attachment file is present": {
+ "fr": "tous les fichiers de pièces jointes sont présents",
+ "en": "every attachment file is present",
+ },
+ "Could not read their metadata.": {
+ "fr": "Impossible de lire leurs métadonnées.",
+ "en": "Could not read their metadata.",
+ },
+ "by model and field": {
+ "fr": "par modèle et par champ",
+ "en": "by model and field",
+ },
+ "one by one": {
+ "fr": "un par un",
+ "en": "one by one",
+ },
+ "press m to list them": {
+ "fr": "m pour en voir la liste",
+ "en": "press m to list them",
+ },
+ "Pick a step to see its missing files.": {
+ "fr": "Choisissez un palier pour voir ses fichiers absents.",
+ "en": "Pick a step to see its missing files.",
+ },
+ "table(s) lost rows, unexplained": {
+ "fr": "table(s) ont perdu des lignes, sans explication",
+ "en": "table(s) lost rows, unexplained",
+ },
+ "table(s) explained by an Odoo change": {
+ "fr": "table(s) expliquées par un changement d'Odoo",
+ "en": "table(s) explained by an Odoo change",
+ },
+ "Cannot read the database: ": {
+ "fr": "Lecture impossible de la base : ",
+ "en": "Cannot read the database: ",
+ },
+ "Cannot read the package file: ": {
+ "fr": "Lecture impossible du fichier de packages : ",
+ "en": "Cannot read the package file: ",
+ },
+ "Modules missing from the default package": {
+ "fr": "📦 Modules absents du package par défaut",
+ "en": "📦 Modules missing from the default package",
+ },
+ "Show every entry": {
+ "fr": "Tout afficher",
+ "en": "Show every entry",
+ },
+ "List the known packages": {
+ "fr": "Lister les packages connus",
+ "en": "List the known packages",
+ },
+ "Already repaired: the group exists.": {
+ "fr": "Déjà réparé : le groupe existe.",
+ "en": "Already repaired: the group exists.",
+ },
+ "DMS access groups:": {
+ "fr": "Groupes d'accès DMS :",
+ "en": "DMS access groups:",
+ },
+ "DMS documents in the database": {
+ "fr": "Documents DMS dans la base",
+ "en": "DMS documents in the database",
+ },
+ "No DMS user to test visibility with.": {
+ "fr": "Aucun utilisateur DMS pour éprouver la visibilité.",
+ "en": "No DMS user to test visibility with.",
+ },
+ "Nothing written. Re-run with --apply.": {
+ "fr": "Rien d'écrit. Relancer avec --apply.",
+ "en": "Nothing written. Re-run with --apply.",
+ },
+ "Now visible to": {
+ "fr": "Désormais visibles pour",
+ "en": "Now visible to",
+ },
+ "Visible to": {
+ "fr": "Visibles pour",
+ "en": "Visible to",
+ },
+ "Would create one access group over": {
+ "fr": "Créerait un groupe d'accès sur",
+ "en": "Would create one access group over",
+ },
+ "file(s)": {
+ "fr": "fichier(s)",
+ "en": "file(s)",
+ },
+ "folder(s)": {
+ "fr": "dossier(s)",
+ "en": "folder(s)",
+ },
+ "root folder(s)": {
+ "fr": "dossier(s) racine",
+ "en": "root folder(s)",
+ },
+ "No DMS in this database, nothing to repair.": {
+ "fr": "Pas de DMS dans cette base, rien à réparer.",
+ "en": "No DMS in this database, nothing to repair.",
+ },
+ "Every one of them is visible to someone.": {
+ "fr": "Chacun est visible par quelqu'un.",
+ "en": "Every one of them is visible to someone.",
+ },
+ "No internal user to test visibility with.": {
+ "fr": "Aucun utilisateur interne pour éprouver la visibilité.",
+ "en": "No internal user to test visibility with.",
+ },
+ "The data is there; a global rule hides all of it.": {
+ "fr": "Les données sont là ; une règle globale les masque entièrement.",
+ "en": "The data is there; a global rule hides all of it.",
+ },
+ "checked against": {
+ "fr": "éprouvés avec",
+ "en": "checked against",
+ },
+ "internal user(s)": {
+ "fr": "utilisateur(s) interne(s)",
+ "en": "internal user(s)",
+ },
+ "model(s) nobody can see a single row of": {
+ "fr": "modèle(s) dont personne ne voit une seule ligne",
+ "en": "model(s) nobody can see a single row of",
+ },
+ "model(s) with a global rule and some data,": {
+ "fr": "modèle(s) avec une règle globale et des données,",
+ "en": "model(s) with a global rule and some data,",
+ },
+ "row(s)": {
+ "fr": "ligne(s)",
+ "en": "row(s)",
+ },
+ "Against OpenUpgrade": {
+ "fr": "Face à OpenUpgrade",
+ "en": "Against OpenUpgrade",
+ },
+ "NOT declared by OpenUpgrade": {
+ "fr": "NON déclarés par OpenUpgrade",
+ "en": "NOT declared by OpenUpgrade",
+ },
+ "No OpenUpgrade analysis for this step.": {
+ "fr": "Pas d'analyse OpenUpgrade pour ce palier.",
+ "en": "No OpenUpgrade analysis for this step.",
+ },
+ "became a per-company jsonb column": {
+ "fr": "passés en colonne jsonb par société",
+ "en": "became a per-company jsonb column",
+ },
+ "computed now — the field remains, the column does not": {
+ "fr": "devenus calculés — le champ demeure, la colonne non",
+ "en": "computed now — the field remains, the column does not",
+ },
+ "core module(s) analysed": {
+ "fr": "module(s) cœur analysé(s)",
+ "en": "core module(s) analysed",
+ },
+ "declared obsolete": {
+ "fr": "déclarés obsolètes",
+ "en": "declared obsolete",
+ },
+ "declared removed": {
+ "fr": "déclarés supprimés",
+ "en": "declared removed",
+ },
+ "field(s) lost": {
+ "fr": "champ(s) perdu(s)",
+ "en": "field(s) lost",
+ },
+ "model(s) lost": {
+ "fr": "modèle(s) perdu(s)",
+ "en": "model(s) lost",
+ },
+ "moved to another module": {
+ "fr": "déplacés vers un autre module",
+ "en": "moved to another module",
+ },
+ "renamed by Odoo": {
+ "fr": "renommés par Odoo",
+ "en": "renamed by Odoo",
+ },
+ "their model went away too": {
+ "fr": "leur modèle a disparu aussi",
+ "en": "their model went away too",
+ },
+ "in a module OpenUpgrade does not analyse": {
+ "fr": "dans un module qu'OpenUpgrade n'analyse pas",
+ "en": "in a module OpenUpgrade does not analyse",
+ },
+ "model(s)": {
+ "fr": "modèle(s)",
+ "en": "model(s)",
+ },
+ "other field name(s)": {
+ "fr": "autre(s) nom(s) de champ",
+ "en": "other field name(s)",
+ },
+ "Filestore of": {
+ "fr": "Filestore de",
+ "en": "Filestore of",
+ },
+ "file(s) missing": {
+ "fr": "fichier(s) absent(s)",
+ "en": "file(s) missing",
+ },
+ "file(s) on disk": {
+ "fr": "fichier(s) sur disque",
+ "en": "file(s) on disk",
+ },
+ "file(s) sit in nested filestores Odoo never reads.": {
+ "fr": "fichier(s) échoué(s) dans des filestores imbriqués qu'Odoo ne lit jamais.",
+ "en": "file(s) sit in nested filestores Odoo never reads.",
+ },
+ "intact in another database's filestore": {
+ "fr": "intacts dans le filestore d'une autre base",
+ "en": "intact in another database's filestore",
+ },
+ "its field no longer exists — nothing reads it": {
+ "fr": "leur champ n'existe plus — rien ne les lit",
+ "en": "its field no longer exists — nothing reads it",
+ },
+ "nowhere to be found — truly lost": {
+ "fr": "introuvables — réellement perdus",
+ "en": "nowhere to be found — truly lost",
+ },
+ "still in a backup zip": {
+ "fr": "encore dans une sauvegarde zip",
+ "en": "still in a backup zip",
+ },
+ "stored attachment(s)": {
+ "fr": "pièce(s) jointe(s) stockée(s)",
+ "en": "stored attachment(s)",
+ },
+ "stranded in a nested filestore Odoo never reads": {
+ "fr": "échoués dans un filestore imbriqué qu'Odoo ne lit pas",
+ "en": "stranded in a nested filestore Odoo never reads",
+ },
+ "Files": {
+ "fr": "Fichiers",
+ "en": "Files",
+ },
+ "Attachment files missing from the filestore": {
+ "fr": "🗄 Fichiers de pièces jointes absents du filestore",
+ "en": "🗄 Attachment files missing from the filestore",
+ },
+ "Scanning filestores and backups…": {
+ "fr": "Examen des filestores et des sauvegardes…",
+ "en": "Scanning filestores and backups…",
+ },
+ "Filestore restored:": {
+ "fr": "Filestore restauré :",
+ "en": "Filestore restored:",
+ },
+ "file(s) in place": {
+ "fr": "fichier(s) en place",
+ "en": "file(s) in place",
+ },
+ "Filestore restore looks wrong for": {
+ "fr": "Restauration du filestore suspecte pour",
+ "en": "Filestore restore looks wrong for",
+ },
+ "landed in a nested filestore Odoo never reads": {
+ "fr": "atterris dans un filestore imbriqué qu'Odoo ne lit jamais",
+ "en": "landed in a nested filestore Odoo never reads",
+ },
+ "To fix:": {
+ "fr": "Pour corriger :",
+ "en": "To fix:",
+ },
+ "never landed at all": {
+ "fr": "ne sont arrivés nulle part",
+ "en": "never landed at all",
+ },
+ "record still exists": {
+ "fr": "l'enregistrement existe encore",
+ "en": "record still exists",
+ },
+ "record is gone — nothing will miss it": {
+ "fr": "l'enregistrement a disparu — personne ne la cherchera",
+ "en": "record is gone — nothing will miss it",
+ },
+ "Delete these": {
+ "fr": "Supprimer ces",
+ "en": "Delete these",
+ },
+ "Directory:": {
+ "fr": "Dossier :",
+ "en": "Directory:",
+ },
+ "No nested filestore to tidy.": {
+ "fr": "Pas de filestore imbriqué à ranger.",
+ "en": "No nested filestore to tidy.",
+ },
+ "Nothing to purge.": {
+ "fr": "Rien à purger.",
+ "en": "Nothing to purge.",
+ },
+ "Nothing was deleted.": {
+ "fr": "Rien n'a été supprimé.",
+ "en": "Nothing was deleted.",
+ },
+ "Nothing was moved.": {
+ "fr": "Rien n'a été déplacé.",
+ "en": "Nothing was moved.",
+ },
+ "The purge failed.": {
+ "fr": "La purge a échoué.",
+ "en": "The purge failed.",
+ },
+ "attachment row(s) deleted.": {
+ "fr": "ligne(s) de pièce jointe supprimée(s).",
+ "en": "attachment row(s) deleted.",
+ },
+ "attachment row(s) for good?": {
+ "fr": "ligne(s) de pièce jointe, définitivement ?",
+ "en": "attachment row(s) for good?",
+ },
+ "duplicate(s) removed": {
+ "fr": "doublon(s) supprimé(s)",
+ "en": "duplicate(s) removed",
+ },
+ "file(s) to move up": {
+ "fr": "fichier(s) à remonter",
+ "en": "file(s) to move up",
+ },
+ "moved up": {
+ "fr": "remonté(s)",
+ "en": "moved up",
+ },
+ "pure duplicate(s) to delete": {
+ "fr": "doublon(s) pur(s) à supprimer",
+ "en": "pure duplicate(s) to delete",
+ },
+ "🧹 Purge attachments whose field no longer exists": {
+ "fr": "🧹 Purger les pièces jointes dont le champ n'existe plus",
+ "en": "🧹 Purge attachments whose field no longer exists",
+ },
+ "🧹 Tidy the nested filestore Odoo never reads": {
+ "fr": "🧹 Ranger le filestore imbriqué qu'Odoo ne lit pas",
+ "en": "🧹 Tidy the nested filestore Odoo never reads",
+ },
+ "The purge ran but said nothing.": {
+ "fr": "La purge a tourné sans rien annoncer.",
+ "en": "The purge ran but said nothing.",
+ },
+ "row(s) whose field is gone still hold their file": {
+ "fr": "ligne(s) à champ disparu retiennent encore leur fichier",
+ "en": "row(s) whose field is gone still hold their file",
+ },
+ "file(s) sit in a nested filestore Odoo never reads.": {
+ "fr": "fichier(s) échoué(s) dans un filestore imbriqué qu'Odoo ne lit jamais.",
+ "en": "file(s) sit in a nested filestore Odoo never reads.",
+ },
+ "such file(s) sit in OTHER databases filestores.": {
+ "fr": "fichier(s) de ce genre dorment dans les filestores d'AUTRES bases.",
+ "en": "such file(s) sit in OTHER databases filestores.",
+ },
+ "Could not inspect the attachments: ": {
+ "fr": "Inspection des pièces jointes impossible : ",
+ "en": "Could not inspect the attachments: ",
+ },
+ "Delete them?": {
+ "fr": "Les supprimer ?",
+ "en": "Delete them?",
+ },
+ "No attachment points at a field that is gone.": {
+ "fr": "Aucune pièce jointe ne pointe vers un champ disparu.",
+ "en": "No attachment points at a field that is gone.",
+ },
+ "attachment(s) point at a field that no longer exists": {
+ "fr": "pièce(s) jointe(s) pointent vers un champ qui n'existe plus",
+ "en": "attachment(s) point at a field that no longer exists",
+ },
+ "Corrected.": {
+ "fr": "Corrigé.",
+ "en": "Corrected.",
+ },
+ "No module update will fix this: the type is set once,": {
+ "fr": "Aucune mise à jour de module ne corrigera cela : le type est posé une fois,",
+ "en": "No module update will fix this: the type is set once,",
+ },
+ "The correction failed.": {
+ "fr": "La correction a échoué.",
+ "en": "The correction failed.",
+ },
+ "at creation. Re-run with --apply.": {
+ "fr": "à la création. Relancer avec --apply.",
+ "en": "at creation. Re-run with --apply.",
+ },
+ "their inheritance": {
+ "fr": "leur héritage",
+ "en": "their inheritance",
+ },
+ "view(s) whose stored type contradicts": {
+ "fr": "vue(s) dont le type stocké contredit",
+ "en": "view(s) whose stored type contradicts",
+ },
+ "Fix views whose type contradicts their": {
+ "fr": "Corriger les vues dont le type contredit leur",
+ "en": "Fix views whose type contradicts their",
+ },
+ "inheritance": {
+ "fr": "héritage",
+ "en": "inheritance",
+ },
+ "Correct them?": {
+ "fr": "Les corriger ?",
+ "en": "Correct them?",
+ },
+ "Uninstall the theme(s) the error names": {
+ "fr": "Désinstaller le(s) thème(s) que l'erreur nomme",
+ "en": "Uninstall the theme(s) the error names",
+ },
+ "Every view agrees with what Odoo expects.": {
+ "fr": "Toutes les vues s'accordent avec ce qu'Odoo attend.",
+ "en": "Every view agrees with what Odoo expects.",
+ },
+ "custom — nothing will ever rewrite it": {
+ "fr": "faite main — rien ne la réécrira jamais",
+ "en": "custom — nothing will ever rewrite it",
+ },
+ "from a module — a module update also fixes it": {
+ "fr": "d'un module — une mise à jour la répare aussi",
+ "en": "from a module — a module update also fixes it",
+ },
+ "renamed to ": {
+ "fr": "a renommée en ",
+ "en": "renamed to ",
+ },
+ "view(s) still carry a tag Odoo 18": {
+ "fr": "vue(s) portent encore une balise qu'Odoo 18",
+ "en": "view(s) still carry a tag Odoo 18",
+ },
+ "Census": {
+ "fr": "Recensement",
+ "en": "Census",
+ },
+ "Compared against nothing — census only.": {
+ "fr": "Comparé à rien — recensement seul.",
+ "en": "Compared against nothing — census only.",
+ },
+ "Default package:": {
+ "fr": "Package par défaut :",
+ "en": "Default package:",
+ },
+ "Every suggested module is installed.": {
+ "fr": "Tous les modules suggérés sont installés.",
+ "en": "Every suggested module is installed.",
+ },
+ "Installed modules by author": {
+ "fr": "Modules installés, par auteur",
+ "en": "Installed modules by author",
+ },
+ "Modules of": {
+ "fr": "Modules de",
+ "en": "Modules of",
+ },
+ "No default package known for this version": {
+ "fr": "Aucun package par défaut connu pour cette version",
+ "en": "No default package known for this version",
+ },
+ "Odoo": {
+ "fr": "Odoo",
+ "en": "Odoo",
+ },
+ "absent from the addons path — sync the repo first": {
+ "fr": "hors du chemin des addons — synchroniser le dépôt d'abord",
+ "en": "absent from the addons path — sync the repo first",
+ },
+ "also needs": {
+ "fr": "réclame aussi",
+ "en": "also needs",
+ },
+ "half-way — finish the pending update": {
+ "fr": "à mi-chemin — finir la mise à jour en attente",
+ "en": "half-way — finish the pending update",
+ },
+ "installed": {
+ "fr": "déjà en place",
+ "en": "installed",
+ },
+ "known": {
+ "fr": "connus",
+ "en": "known",
+ },
+ "known to the database — install it": {
+ "fr": "dans la base — à installer",
+ "en": "known to the database — install it",
+ },
+ "present but broken — fix the dependency": {
+ "fr": "en panne de dépendance — à réparer",
+ "en": "present but broken — fix the dependency",
+ },
+ "suggested module(s)": {
+ "fr": "module(s) suggéré(s)",
+ "en": "suggested module(s)",
+ },
+ "suggested module(s) not installed": {
+ "fr": "module(s) suggéré(s) non installé(s)",
+ "en": "suggested module(s) not installed",
+ },
+ "unknown author": {
+ "fr": "auteur inconnu",
+ "en": "unknown author",
+ },
+ "rows dropped, the table remains": {
+ "fr": "lignes supprimées, la table demeure",
+ "en": "rows dropped, the table remains",
+ },
+ "retired from the database": {
+ "fr": "retirée de la base",
+ "en": "retired from the database",
+ },
+ "but it gained nothing": {
+ "fr": "mais elle n'a rien reçu",
+ "en": "but it gained nothing",
+ },
+ "there": {
+ "fr": "là-bas",
+ "en": "there",
+ },
+ "lost": {
+ "fr": "perdus",
+ "en": "lost",
+ },
+ "gained": {
+ "fr": "gagnés",
+ "en": "gained",
+ },
+ "fields": {
+ "fr": "champs",
+ "en": "fields",
+ },
+ "cow": {
+ "fr": "copies COW",
+ "en": "cow",
+ },
+ "tables": {
+ "fr": "tables",
+ "en": "tables",
+ },
+ "Details": {
+ "fr": "Détails",
+ "en": "Details",
+ },
+ "summary": {
+ "fr": "résumé",
+ "en": "summary",
+ },
+ "Clean the database before testing the pages?": {
+ "fr": "Nettoyer la base avant de tester les pages ?",
+ "en": "Clean the database before testing the pages?",
+ },
+ "Auto-run: take the default answer after": {
+ "fr": "Auto-exécution : prendre la réponse par défaut après",
+ "en": "Auto-run: take the default answer after",
+ },
+ "seconds?": {
+ "fr": "secondes ?",
+ "en": "seconds?",
+ },
+ "Auto-run on: each prompt waits": {
+ "fr": "Auto-exécution activée : chaque invite attend",
+ "en": "Auto-run on: each prompt waits",
+ },
+ "seconds, then takes its default.": {
+ "fr": "secondes, puis prend son défaut.",
+ "en": "seconds, then takes its default.",
+ },
+ "(default)": {
+ "fr": "(défaut)",
+ "en": "(default)",
+ },
+ "database_cleanup is not installed on this base;": {
+ "fr": "database_cleanup n'est pas installé sur cette base ;",
+ "en": "database_cleanup is not installed on this base;",
+ },
+ "installing it first.": {
+ "fr": "installation préalable.",
+ "en": "installing it first.",
+ },
+ "Could not install database_cleanup.": {
+ "fr": "Impossible d'installer database_cleanup.",
+ "en": "Could not install database_cleanup.",
+ },
+ "Nothing to decide yet": {
+ "fr": "Rien à décider pour l'instant",
+ "en": "Nothing to decide yet",
+ },
+ "the migration will offer to neutralize these copies": {
+ "fr": "la migration proposera de neutraliser ces copies",
+ "en": "the migration will offer to neutralize these copies",
+ },
+ "at the version bump itself, showing what each one holds.": {
+ "fr": "au moment du palier lui-même, en montrant ce que chacune"
+ " contient.",
+ "en": "at the version bump itself, showing what each one holds.",
+ },
+ "Press to continue": {
+ "fr": "Appuyez pour continuer",
+ "en": "Press to continue",
+ },
+ "(b = go back to a previous step)": {
+ "fr": "(b = revenir à une étape précédente)",
+ "en": "(b = go back to a previous step)",
+ },
+ "Neutralize database, press to continue": {
+ "fr": "Neutraliser la base, appuyez pour continuer",
+ "en": "Neutralize database, press to continue",
+ },
+ "Replay from which step? (empty to cancel)": {
+ "fr": "Rejouer à partir de quelle étape ? (vide pour annuler)",
+ "en": "Replay from which step? (empty to cancel)",
+ },
+ "Rewound.": {"fr": "Rembobiné.", "en": "Rewound."},
+ "Relaunch the migration to resume from there.": {
+ "fr": "Relancez la migration pour reprendre à partir de là.",
+ "en": "Relaunch the migration to resume from there.",
+ },
+ "Go back to a step": {
+ "fr": "Revenir à une étape",
+ "en": "Go back to a step",
+ },
+ "Choose a step, Enter replays from there.": {
+ "fr": "Choisissez une étape, Entrée rejoue à partir de là.",
+ "en": "Choose a step, Enter replays from there.",
+ },
# --- script/analyse : vues personnalisées ---
"Customised views": {
"fr": "Vues personnalisées",
diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py
index 6c37836..8dd444c 100755
--- a/script/todo/todo_upgrade.py
+++ b/script/todo/todo_upgrade.py
@@ -8,13 +8,17 @@ import logging
import os
import re
import shutil
+import subprocess
import sys
import zipfile
from uuid import uuid4
-import click
-import todo_file_browser
+from script.todo import (
+ auto_ask,
+ migration_status,
+ todo_file_browser,
+)
from script.todo.version_manager import get_odoo_version
try:
@@ -59,10 +63,18 @@ LOCAL_MANIFEST = os.path.join(
# 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_ ». 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.
+# Le journal permanent des échecs et des verdicts, hors du fichier de
+# progression : celui-ci est remis à zéro quand on recommence.
+STEP_LOG_DIR = "step_log"
+# Les deux premières étapes tournent avant qu'on ait choisi le nom de
+# la base : leurs journaux attendent ici, puis rejoignent la migration.
+UNNAMED_MIGRATION = "sans-nom"
+EVENT_FILE = "events.jsonl"
+# Steps of the migration, in order. What each one owns is declared just below,
+# by GLOBAL_PROGRESSION_KEY and STEP_OWNED_KEY — not by the prefix alone, which
+# lies on some keys and is missing on others. Rewinding to a step drops
+# everything it and the later steps own, so the run really replays from there.
+# Labels go through t(): the key IS the English string, as everywhere else.
MIGRATION_STEP = [
(0, "Prepare the environment"),
(1, "Restore and neutralize the database"),
@@ -72,6 +84,86 @@ MIGRATION_STEP = [
]
+# Ce que le journal garde quoi qu'il arrive : des décisions et de la
+# métadonnée, jamais du progrès. Rembobiner ne doit pas faire oublier QUELLE
+# base on migre ni vers quelle version — on repartirait sur autre chose.
+GLOBAL_PROGRESSION_KEY = frozenset(
+ {
+ "command_executed",
+ # Le registre des échecs et des verdicts d'outils, comme le journal
+ # des commandes : un retour en arrière ne doit PAS l'effacer. C'est
+ # justement ce qu'on vient consulter après être revenu.
+ "lst_event",
+ "config_database_name",
+ "config_migrate_repo",
+ "date_create",
+ "date_update",
+ "migration_file",
+ "target_odoo_version",
+ }
+)
+
+# Clés que leur nom ne rattache à aucune étape, et l'étape qui les produit.
+#
+# Tout le reste s'auto-décrit : `state__` et `config_state__` nomment
+# leur étape. Ces huit-là sont les sorties de la recherche de modules de
+# l'étape 0 ; `dct_module_exist` est ensuite lu par l'étape 4.
+#
+# `state_1_update_all` est l'exception inverse : son nom dit 1, son travail
+# est celui de l'étape 2 — c'est la mise à jour précoce, offerte avant la
+# neutralisation quand la base vient d'une vieille version.
+STEP_OWNED_KEY = {
+ 0: (
+ "dct_module_exist",
+ "dct_module_per_version",
+ "len_dct_module_exist",
+ "len_lst_module_duplicate",
+ "len_lst_module_missing",
+ "lst_module_duplicate",
+ "lst_module_missing",
+ "lst_module_per_version_origin",
+ ),
+ 2: ("state_1_update_all",),
+}
+
+# Réponses de l'étape 4 indexées par bump de version, une entrée par palier —
+# exactement comme les drapeaux `state_4_*_odoo_lst`. Leur nom ne le dit pas,
+# d'où cette liste : rejouer un palier sans les vider réutilisait en silence
+# les modules choisis au passage précédent.
+STEP_4_PER_BUMP_KEY = (
+ "config_state_4_install_module",
+ "config_state_4_module_to_migrate_code",
+ "config_state_4_uninstall_module",
+)
+
+STEP_PREFIX_RE = re.compile(r"^(?:config_)?state_(\d+)_")
+
+
+def flag_step(key):
+ """Étape dont relève une clé du journal, ou None si elle est globale.
+
+ Le préfixe ne suffit pas : une clé peut le porter à tort
+ (`state_1_update_all`) ou ne rien porter du tout (les listes de modules).
+ La table tranche d'abord, le nom ensuite.
+ """
+ for step, lst_key in STEP_OWNED_KEY.items():
+ if key in lst_key:
+ return step
+ match = STEP_PREFIX_RE.match(key)
+ return int(match.group(1)) if match else None
+
+
+class MigrationRewind(Exception):
+ """L'utilisateur a demandé de revenir à une étape antérieure.
+
+ Une exception, et non un code de retour : la demande peut venir de
+ n'importe laquelle des invites, à n'importe quelle profondeur d'une
+ méthode de mille lignes. La faire remonter par des valeurs de retour
+ obligerait chaque appelant intermédiaire à la reconnaître et à la
+ propager — autant d'endroits où l'oublier.
+ """
+
+
class TodoUpgrade:
def __init__(self, todo):
self.file_path = None
@@ -108,6 +200,26 @@ class TodoUpgrade:
)
return {}
+ @staticmethod
+ def needs_update_all(dct_progression, already_done_early=False):
+ """Faut-il encore mettre à jour tous les modules ?
+
+ Trois façons de l'avoir déjà fait, et il faut les trois. Le drapeau de
+ l'étape ; celui de la mise à jour précoce, posée avant la
+ neutralisation quand la base vient d'une vieille version ; et la
+ variable de la session en cours.
+
+ Le second est ce qui manquait. Il n'est vrai que dans la session qui
+ l'a posé, alors qu'à la reprise seule la trace écrite subsiste : sans
+ le lire ici, reprendre une migration relançait `update_addons_all` sur
+ une base déjà à jour — des heures, pour rien.
+ """
+ return not (
+ dct_progression.get("state_2_update_all")
+ or dct_progression.get("state_1_update_all")
+ or already_done_early
+ )
+
@staticmethod
def step_status(dct_progression, step):
"""Return (icon, detail) telling how far a migration step went.
@@ -121,6 +233,15 @@ class TodoUpgrade:
for key, value in dct_progression.items()
if key.startswith(prefix)
}
+ # Un journal écrit avant que l'étape 2 n'enregistre son propre drapeau
+ # ne porte que state_1_update_all. Le travail a bien eu lieu ; le lire
+ # ici évite de dire « non démarrée » d'une étape terminée.
+ if (
+ step == 2
+ and not dct_flag
+ and dct_progression.get("state_1_update_all")
+ ):
+ return "✅", t("done early, before the neutralization")
if not dct_flag:
return "⬜", t("not started")
@@ -154,6 +275,10 @@ class TodoUpgrade:
return ("✅" if total and done == total else "⏳"), detail
if all(dct_flag.values()):
+ if step == 2 and dct_progression.get("state_2_done_early"):
+ # Le doute vient du moment, pas du résultat : le dire évite de
+ # relancer une mise à jour qui a déjà eu lieu.
+ return "✅", t("done early, before the neutralization")
return "✅", t("done")
return "⏳", t("partially done")
@@ -229,6 +354,70 @@ class TodoUpgrade:
print(f" [r] {t('Keep the zip only, ask every question again')}")
print(f" [q] {t('Quit without doing anything')}")
+ @staticmethod
+ def archive_progression(old_dct_progression, reason):
+ """Mettre le journal de côté avant qu'une nouvelle migration l'écrase.
+
+ Le journal ne vit qu'à un seul endroit et la migration suivante écrit
+ par-dessus : recommencer effaçait donc tout ce qu'on savait de la
+ précédente — quels paliers étaient passés, quels modules manquaient,
+ combien de temps chacun avait pris. Autant d'éléments qu'on ne cherche
+ justement qu'APRÈS avoir dû recommencer.
+
+ Le nom porte la base d'origine et l'horodatage de la copie, pour que
+ deux tentatives sur la même base ne se recouvrent pas et qu'un fichier
+ déplacé se décrive encore lui-même. Renvoie le chemin, ou None s'il n'y
+ avait rien qui vaille d'être gardé.
+
+ L'archive vit sous `private/`, pas dans le venv : une réinstallation
+ efface `.venv.erplibre/`, et avec elle l'historique qu'on vient de
+ sauver.
+ """
+ if not old_dct_progression:
+ return None
+ # Un journal qui ne porte aucun état n'apprend rien à personne.
+ if not any(k.startswith("state_") for k in old_dct_progression):
+ return None
+
+ database_name = (
+ old_dct_progression.get("config_database_name")
+ or os.path.splitext(
+ os.path.basename(
+ old_dct_progression.get("migration_file") or ""
+ )
+ )[0]
+ or "unknown"
+ )
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss")
+ directory = os.path.join(
+ PATH_MIGRATION_PRIVATE, database_name, "migration_log"
+ )
+ path = os.path.join(directory, f"{database_name}_{stamp}.json")
+ # Deux copies dans la même seconde portent le même horodatage. Écraser
+ # la première annulerait exactement la perte qu'on cherche à éviter,
+ # et sans rien dire.
+ suffix = 2
+ while os.path.exists(path):
+ path = os.path.join(
+ directory, f"{database_name}_{stamp}_{suffix}.json"
+ )
+ suffix += 1
+ payload = dict(old_dct_progression)
+ payload["archived_at"] = str(datetime.datetime.now())
+ payload["archived_reason"] = reason
+ payload["archived_database"] = database_name
+ try:
+ os.makedirs(directory, exist_ok=True)
+ with open(path, "w") as handle:
+ json.dump(payload, handle, indent=4)
+ except OSError as exc:
+ # Ne jamais empêcher une migration de repartir pour une histoire
+ # de copie : on le dit, et on continue.
+ print(f"⚠️ {t('Could not archive the previous log')} : {exc}")
+ return None
+ print(f"📦 {t('Previous migration log kept in')} : {path}")
+ return path
+
def apply_resume_answer(self, old_dct_progression, answer, ctx):
"""Turn the answer into (progression, changed), or None to quit.
@@ -242,9 +431,15 @@ class TodoUpgrade:
return old_dct_progression, False
if answer == "q":
return None
+ # « n » et « r » repartent de zéro : dans les deux cas la progression
+ # enregistrée disparaît, donc dans les deux cas on la garde d'abord.
if answer == "n":
+ self.archive_progression(old_dct_progression, "restart_from_zero")
return {}, True
if answer == "r":
+ self.archive_progression(
+ old_dct_progression, "restart_same_backup"
+ )
return {
"migration_file": old_dct_progression.get("migration_file"),
"date_create": old_dct_progression.get("date_create"),
@@ -310,7 +505,11 @@ class TodoUpgrade:
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()
+ # `auto_ask` et non `input` : c'est la PREMIÈRE question posée
+ # après l'activation de l'auto-exécution, et un input() nu y
+ # arrêtait la migration avant même qu'elle ne commence. Méthode
+ # statique, d'où l'appel direct plutôt que `self.ask`.
+ answer = auto_ask.ask(t("Choice (0-3, default 1): ")).strip()
return {"0": None, "2": "cli", "3": "stats"}.get(answer, "tui")
@staticmethod
@@ -389,10 +588,19 @@ class TodoUpgrade:
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" [t] {t('Migration state (full screen)')}")
print(f" [0] {t('Back')}")
- answer = input(f"💬 {t('Your choice')} : ").strip()
+ # `self.ask` : cet écran est atteint DEPUIS execute_odoo_upgrade,
+ # donc après la question d'auto-exécution. Un input() nu y
+ # arrêtait une migration automatique sans rien signaler.
+ answer = self.ask(f"💬 {t('Your choice')} : ").strip().lower()
if answer in ("", "0"):
return
+ if answer == "t":
+ # La MÊME lettre que dans les invites : une lettre qui
+ # change de sens d'un écran à l'autre ne s'apprend pas.
+ self.show_migration_status()
+ continue
if answer == "1":
for version, detail in sorted(stats["uninstall"].items()):
print(f"\n── {version - 1}.0 → {version}.0 ──")
@@ -552,34 +760,203 @@ class TodoUpgrade:
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.
+
+ The answers given for a bump — STEP_4_PER_BUMP_KEY, indexed exactly
+ like the flags — are trimmed too. They were not, so replaying a bump
+ reused the modules chosen the previous time and asked nothing.
"""
dct_kept = dict(old_dct_progression)
for key, value in old_dct_progression.items():
- if (
+ if not isinstance(value, list):
+ continue
+ per_bump = (
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)
- ]
+ ) or key in STEP_4_PER_BUMP_KEY
+ if not per_bump:
+ continue
+ # Vider en gardant le type : `config_state_4_module_to_migrate_code`
+ # porte des listes, sur lesquelles le code fait .append() sans les
+ # avoir revues. Un False à leur place plante le palier rejoué.
+ dct_kept[key] = [
+ (
+ item
+ if i < index
+ else ([] if isinstance(item, list) else False)
+ )
+ for i, item in enumerate(value)
+ ]
return dct_kept
+ AUTO_DELAY = 5
+ MAX_ERROR_RETRY = 3
+ MAX_ERROR_TURNS = 8
+
+ def prompt_auto_execute(self):
+ """Proposer que les invites prennent leur défaut après un délai.
+
+ Une migration pose des dizaines de questions dont la réponse est
+ presque toujours celle proposée. Les enchaîner à la main immobilise
+ quelqu'un pendant des heures pour taper Entrée.
+
+ Posée AVANT le choix de version, donc avant la première décision :
+ activée, elle vaut pour toutes les suivantes, celle-là comprise.
+ """
+ # L'auto-exécution ne s'active JAMAIS d'elle-même : on efface le
+ # drapeau avant de poser la question, sinon une variable héritée du
+ # shell ferait répondre « oui » à la place de quelqu'un — et cette
+ # réponse-là engage toutes les suivantes.
+ auto_ask.export(False)
+ answer = (
+ auto_ask.ask(
+ f"💬 {t('Auto-run: take the default answer after')}"
+ f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : ",
+ default="n",
+ )
+ .strip()
+ .lower()
+ )
+ self.auto_execute = answer == "y"
+ # Dans l'ENVIRONNEMENT, car c'est le seul canal qu'un sous-processus
+ # partage avec nous : la moitié des invites d'une migration sont
+ # posées par des outils lancés à part — désinstalleur de thème,
+ # détecteur de SCSS figé — et sans cela elles attendraient une
+ # frappe qui ne vient jamais.
+ auto_ask.export(self.auto_execute, self.AUTO_DELAY)
+ if self.auto_execute:
+ print(
+ f"⏱ {t('Auto-run on: each prompt waits')}"
+ f" {self.AUTO_DELAY} {t('seconds, then takes its default.')}"
+ )
+
+ def ask(self, prompt, default=""):
+ """Lire une réponse ; rendre le défaut si rien n'arrive.
+
+ Le compte à rebours vit dans `auto_ask`, pas ici : les outils lancés
+ en sous-processus posent EUX AUSSI des questions, et une deuxième
+ implémentation aurait dérivé de celle-ci sans que rien ne le dise.
+
+ L'export à chaque appel n'est pas de la précaution : l'environnement
+ est le SEUL canal qui traverse un `fork`. Sans lui, un outil lancé
+ plus bas attendrait indéfiniment une frappe qui ne vient pas.
+ """
+ auto_ask.export(getattr(self, "auto_execute", False), self.AUTO_DELAY)
+ return auto_ask.ask(prompt, default=default, seconds=self.AUTO_DELAY)
+
+ def ask_gate(self, prompt, default=""):
+ """Une invite d'attente, avec une porte de sortie vers l'arrière.
+
+ Ces invites ne demandent qu'à continuer. Quand on s'aperçoit à ce
+ moment-là qu'une étape précédente méritait un autre choix, la seule
+ issue était Ctrl+C — qui laisse la progression telle quelle et oblige
+ à retrouver l'écran de reprise. « b » fait le travail proprement :
+ il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer.
+ """
+ while True:
+ answer = self.ask(prompt, default=default)
+ reponse = (answer or "").strip().lower()
+ if reponse == "t":
+ # Regarder n'est pas répondre : on repose la MÊME question
+ # après. Sans cela, ouvrir l'état vaudrait « continuer », ce
+ # qui est exactement ce qu'on ne voulait pas faire.
+ self.show_migration_status()
+ continue
+ if reponse != "b":
+ return answer
+ if self.rewind_to_chosen_step():
+ raise MigrationRewind()
+ # Renoncer au retour en arrière ne doit pas arrêter la migration :
+ # on revient à la même invite, exactement là où l'on était.
+
+ @staticmethod
+ def backup_command(answer, template, default_cmd):
+ """La commande de sauvegarde à lancer, ou « » pour ne rien faire.
+
+ Trois réponses, trois sens, et le troisième est celui qui piège :
+ « n » refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE
+ RESTE est un nom de fichier. Une réponse comme « non.zip » est donc
+ un fichier, pas un refus — d'où la comparaison exacte plutôt qu'un
+ début de mot.
+
+ Entrée sauvegarde : au bout d'une migration de plusieurs heures,
+ l'archive est ce qu'on voulait de toute façon. Mais refuser doit
+ rester possible, et avoir un mot pour le dire.
+ """
+ reponse = (answer or "").strip()
+ if reponse.lower() in ("", "n"):
+ return ""
+ if reponse.lower() == "y":
+ return default_cmd
+ return f"{template} {reponse}"
+
+ def show_migration_status(self):
+ """Ouvrir l'état de la migration, en plein écran.
+
+ On ÉCRIT avant de lire : l'écran lit le fichier de progression, et
+ ce qui vient de se passer n'y serait pas encore.
+
+ Pas par `run_on_terminal` : celui-ci consigne ce qu'il lance dans
+ le journal, et le journal est justement ce que cet écran montre.
+ Regarder l'état polluerait alors l'état.
+ """
+ # Écrire seulement si l'on a quelque chose EN MÉMOIRE : l'écran
+ # de statistiques est ouvert avant que la progression ne soit
+ # chargée, et `write_config` y échouait sur un attribut absent.
+ # Sans rien en mémoire, le fichier sur disque est déjà la vérité.
+ if getattr(self, "dct_progression", None):
+ self.write_config()
+ subprocess.call(
+ f"{PYTHON_BIN} ./script/todo/migration_status.py",
+ shell=True,
+ executable="/bin/bash",
+ )
+
+ def rewind_to_chosen_step(self):
+ """Demander l'étape et rembobiner jusqu'à elle. Écrit la progression."""
+ ctx = self.resume_context(self.dct_progression)
+ print()
+ print(f"📍 {t('Migration in progress')}")
+ for item in ctx["steps"]:
+ print(
+ f" [{item['step']}] {item['icon']} {item['label']:<44}"
+ f" {item['detail']}"
+ )
+ answer = input(
+ f"💬 {t('Replay from which step? (empty to cancel)')} : "
+ )
+ answer = (answer or "").strip()
+ if not answer.isdigit() or int(answer) > MIGRATION_STEP[-1][0]:
+ print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
+ return False
+ self.dct_progression = self.rewind_progression(
+ self.dct_progression, int(answer)
+ )
+ self.write_config()
+ return True
+
@staticmethod
def rewind_progression(old_dct_progression, step):
- """Drop the progression of `step` and of every later step.
+ """Drop everything `step` and the later steps own, not just their flags.
- Configuration answers (config_*), the zip and the target version are
- kept: they are decisions, not progress. Only « state_* » is rewound.
+ Rewinding used to keep every key that was not « state_* ». The chosen
+ step replayed, then the ones after it ran on the previous course's
+ leftovers: the per-bump answers of step 4 — `config_state_4_*`, one
+ entry per version bump — were still there, so the questions were not
+ asked again and the work was taken for decided.
+
+ So ownership is declared rather than guessed: GLOBAL_PROGRESSION_KEY
+ survives everything, STEP_OWNED_KEY and the « state__ » prefixes
+ say the rest. A key belonging to no one is progress by default and
+ goes: a replay that keeps an unknown leftover is the very defect
+ above, and `test_todo_upgrade_steps` fails on any unclassified key.
"""
dct_kept = {}
for key, value in old_dct_progression.items():
- if not key.startswith("state_"):
+ if key in GLOBAL_PROGRESSION_KEY:
dct_kept[key] = value
continue
- index = key[len("state_") :].split("_", 1)[0]
- if index.isdigit() and int(index) < step:
+ index = flag_step(key)
+ if index is not None and 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.
@@ -769,8 +1146,9 @@ class TodoUpgrade:
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 : "
+ status = self.ask(
+ f"💬 {t('Please validate git commit on repos, press to')}"
+ f" {t('continue')} : "
).strip()
def internal_module_upgrade(
@@ -818,7 +1196,9 @@ class TodoUpgrade:
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 : ")
+ self.ask(
+ f"💬 {t('Check the migrated code, press to continue')} : "
+ )
# source_module_path = dct_module_result.get(
# "source_module_path"
@@ -854,8 +1234,9 @@ class TodoUpgrade:
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 : "
+ status = self.ask(
+ f"💬 {t('Please validate the repo is ready to run')}"
+ f" views_migration_18, {t('press to continue')} : "
).strip()
# Apply modification with views_migration_18
has_cmd = False
@@ -878,11 +1259,15 @@ class TodoUpgrade:
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 : ")
+ self.ask(
+ f"💬 {t('Check the migrated code, press to continue')}"
+ " (18) : "
+ )
if next_version == 17:
- status = input(
- f"💬 Please validate repo is ready to run upgrade views_migration_17, press to continue : "
+ status = self.ask(
+ f"💬 {t('Please validate the repo is ready to run')}"
+ f" views_migration_17, {t('press to continue')} : "
).strip()
# Apply modification with views_migration_17
has_cmd = False
@@ -909,13 +1294,17 @@ class TodoUpgrade:
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 : ")
+ self.ask(
+ f"💬 {t('Check the migrated code, press to continue')}"
+ " (17) : "
+ )
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 🤖")
+ print(t("Welcome to the Odoo database upgrade with ERPLibre") + " 🤖")
+ self.prompt_auto_execute()
self.lst_command_executed = []
self.dct_module_per_version = {}
self.dct_module_per_dct_version_path = {}
@@ -950,10 +1339,12 @@ class TodoUpgrade:
self.file_path = self.dct_progression["migration_file"]
else:
print("")
- print("Select the zip file of you database backup.")
+ print(t("Select the zip file of your 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 : "
+ self.file_path = self.ask(
+ f"💬 {t('Give the path of the file, or empty to use a file')}"
+ f" {t('browser, or type')} 'remote'"
+ f" {t('to download from production')} : "
)
if not self.file_path.strip():
self.file_path = None
@@ -969,19 +1360,22 @@ class TodoUpgrade:
)
if status:
_logger.error(
- "Cannot retrieve database from remote, please retry migration."
+ t(
+ "Cannot retrieve the database from remote, please"
+ " retry the migration."
+ )
)
return
self.dct_progression["migration_file"] = self.file_path
self.write_config()
- print(f"✅ Open file {self.file_path}")
+ print(f"✅ {t('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(f"✅ {t('Detected Odoo CE version')} '{odoo_actual_version}'.")
# print("What is your actual Odoo version?")
lst_version, lst_version_installed, odoo_installed_version = (
@@ -998,11 +1392,52 @@ class TodoUpgrade:
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?")
+ # Le défaut est le NUMÉRO de la version la plus haute, calculé
+ # sur la liste réellement affichée : « 6 » n'est pas une
+ # constante, c'est le rang de 18.0 aujourd'hui. Une version de
+ # plus dans le catalogue le déplace, et l'écrire en dur ferait
+ # choisir 18.0 quand l'écran propose 19.0.
+ #
+ # La plus haute se COMPARE, elle ne se lit pas au bout de la
+ # liste : l'ordre d'affichage n'est pas une garantie, et « 9.0 »
+ # se trie après « 18.0 » en chaînes.
+ default_index = None
+ default_version = None
+ if lst_odoo_version:
+ default_index = (
+ max(
+ range(len(lst_odoo_version)),
+ key=lambda i: float(
+ lst_odoo_version[i].get("prompt_description")
+ ),
+ )
+ + 1
+ )
+ default_version = lst_odoo_version[default_index - 1].get(
+ "prompt_description"
+ )
+ print(
+ f"💬 {t('Which version do you want to upgrade to?')}"
+ + (
+ f" ({t('Enter =')} {default_version})"
+ if default_version
+ else ""
+ )
+ )
odoo_target_version = None
cmd_no_found = True
while cmd_no_found:
- status = click.prompt(help_info)
+ # `self.ask` et non click.prompt : c'est lui qui porte le
+ # mode auto, et click ne sait pas rendre la main après un
+ # délai — l'auto se serait arrêté à la première question.
+ # La marque « [6] » est donc posée à la main.
+ marque = f" [{default_index}]" if default_index else ""
+ status = self.ask(
+ f"{help_info}{marque}: ",
+ default=str(default_index) if default_index else "",
+ )
+ if not str(status).strip() and default_index:
+ status = str(default_index)
try:
int_cmd = int(status)
if 0 < int_cmd <= len(lst_odoo_version):
@@ -1013,7 +1448,7 @@ class TodoUpgrade:
except ValueError:
pass
if cmd_no_found:
- print("Commande non trouvée 🤖!")
+ print(t("Command not found") + " 🤖!")
self.dct_progression["target_odoo_version"] = odoo_target_version
self.write_config()
@@ -1032,7 +1467,7 @@ class TodoUpgrade:
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 :")
+ print(f"✨ {t('Documentation for this version')} :")
# TODO Generate it locally and show it if asked
for next_version in range_version:
@@ -1042,10 +1477,10 @@ class TodoUpgrade:
# ⚠️ ℹ 💬 ❗ 🔷 ✨ 🟦 🔹 🔵 ⟳ ⧖ ⚙ ✔ ✅ ❌ ⏵ ⏸ ⏹ ◆ ◇ … ➤ ⚑ ★ ☆ ☰ ⬍ ⍟ ⊗ ⌘ ⏻ ⍰
msg = "0 - Inspect zip"
- print(f"🔷 {msg}")
+ self.print_step(msg)
self.add_comment_progression(msg)
- print("✅ -> Search odoo version")
- print("✅ -> Find good environment, read the .zip file")
+ print(f"✅ -> {t('Search the Odoo version')}")
+ print(f"✅ -> {t('Find the right environment, read the .zip file')}")
is_state_4_reach_open_upgrade = self.dct_progression.get(
"state_4_reach_open_upgrade"
@@ -1065,8 +1500,9 @@ class TodoUpgrade:
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) : "
+ want_continue = self.ask(
+ f"💬 {t('Would you like to install')}"
+ f" '{odoo_version_to_install}' (y/Y) : "
)
if want_continue.strip().lower() != "y":
return
@@ -1076,7 +1512,8 @@ class TodoUpgrade:
if not os.path.isfile(FILENAME_ODOO_VERSION):
print(
- "⚠️ You need an installed system before continue, check your Odoo installation."
+ f"⚠️ {t('You need an installed system before')}"
+ f" {t('continuing, check your Odoo installation.')}"
)
return
@@ -1088,7 +1525,7 @@ class TodoUpgrade:
# self.dct_progression["state_0_switch_odoo"] = True
# self.write_config()
- print("✅ -> Install environment if missing")
+ print(f"✅ -> {t('Install the environment if missing')}")
if not self.dct_progression.get("state_0_search_missing_module"):
self.switch_odoo(odoo_actual_version)
@@ -1129,15 +1566,16 @@ class TodoUpgrade:
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.")
+ print(t("Cannot set up the environment to begin."))
if lst_module_missing:
- print("Missing module :")
+ print(f"{t('Missing module')} :")
print(lst_module_missing)
if lst_module_duplicate:
- print("Duplicate module :")
+ print(f"{t('Duplicate module')} :")
print(lst_module_duplicate)
- want_continue = input(
- "💬 Detect error missing/duplicate module init, do you want to continue? (Y/N): "
+ want_continue = self.ask(
+ f"💬 {t('Missing or duplicate module detected at init,')}"
+ f" {t('do you want to continue?')} (Y/N) : "
)
if want_continue.strip().lower() != "y":
return
@@ -1148,21 +1586,23 @@ class TodoUpgrade:
# TODO fill from config
lst_module_missing = []
- print("✅ -> Search missing module")
+ print(f"✅ -> {t('Search missing modules')}")
print(
- "❌ -> Install missing module, do a research or ask to uninstall it (can break data)"
+ f"❌ -> {t('Install the missing modules, search for them or')}"
+ f" {t('ask to uninstall them (can break data)')}"
)
msg = "1 - Import database from zip"
- print(f"🔷 {msg}")
+ self.print_step(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}) : "
+ self.ask(
+ f"💬 {t('Which database name do you want to work with?')}"
+ f" {t('Default')} ({default_database_name}) : "
).strip()
or default_database_name
)
@@ -1171,9 +1611,13 @@ class TodoUpgrade:
do_neutralize = False
if not self.dct_progression.get("state_1_neutralize_database"):
- print("[1] Ignore neutralize database")
+ print(f"[1] {t('Ignore the database neutralization')}")
wait_continue = (
- input("💬 Neutralize database, press to continue : ")
+ self.ask_gate(
+ "💬 "
+ + t("Neutralize database, press to continue")
+ + f" {t('(b = go back a step, t = show the migration state)')} : "
+ )
.strip()
.lower()
)
@@ -1183,20 +1627,22 @@ class TodoUpgrade:
self.dct_progression["config_database_name"] = database_name
self.write_config()
- print(f"★ Work with database '{database_name}'")
+ print(f"★ {t('Working 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}'"
+ f"🤖 {t('will copy')} '{self.file_path}'"
+ f" {t('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(
+ status_overwrite_image_db = self.ask(
f"{str_will_copy}, "
- f"a file already exist, do you want to continue (y/Y) : "
+ f"{t('a file already exists, do you want to')}"
+ f" {t('continue?')} (y/Y) : "
).strip()
if status_overwrite_image_db.lower() == "y":
do_copy = True
@@ -1220,16 +1666,18 @@ class TodoUpgrade:
self.dct_progression["state_1_restore_database"] = True
self.write_config()
- print("✅ -> Restore database")
+ print(f"✅ -> {t('Restore the 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)"
+ f"[1] {t('Update all addons before neutralizing (already')}"
+ f" {t('neutralized by Odoo if supported)')}"
)
wait_continue = (
- input(
- "💬 Do you need to upgrade before a database neutralization, press to ignore : "
+ self.ask(
+ f"💬 {t('Do you need to upgrade before neutralizing the')}"
+ f" {t('database? Press enter to ignore')} : "
)
.strip()
.lower()
@@ -1243,11 +1691,20 @@ class TodoUpgrade:
if not status:
already_update_state_1 = True
self.dct_progression["state_1_update_all"] = True
+ # La mise à jour de tous les modules EST l'étape 2, même
+ # faite plus tôt. Ne l'enregistrer que sous state_1 la
+ # laissait « non démarrée » à l'écran alors qu'elle venait
+ # de tourner, et faisait la refaire entièrement à la
+ # reprise suivante — sur une grosse base, des heures.
+ self.dct_progression["state_2_update_all"] = True
+ self.dct_progression["state_2_done_early"] = True
self.write_config()
- print("✅ -> Update database before neutralize by module")
+ print(
+ f"✅ -> {t('Update the database before neutralizing, by module')}"
+ )
- print("✅ -> Neutralize database")
+ print(f"✅ -> {t('Neutralize the database')}")
if do_neutralize:
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/update_prod_to_dev.sh {database_name}",
@@ -1273,6 +1730,11 @@ class TodoUpgrade:
self.dct_progression["state_1_neutralize_database"] = True
self.write_config()
+ if not self.dct_progression.get("state_1_theme_uninstalled"):
+ self.prompt_uninstall_theme(database_name)
+ self.dct_progression["state_1_theme_uninstalled"] = True
+ self.write_config()
+
config_state_1_uninstall_module = self.dct_progression.get(
"config_state_1_uninstall_module"
)
@@ -1285,7 +1747,7 @@ class TodoUpgrade:
self.read_uninstall_module_list(start_version, database_name)
)
if lst_uninstall_reason:
- print("✨ Modules to uninstall before migration :")
+ print(f"✨ {t('Modules to uninstall before the migration')} :")
self.print_uninstall_reason(lst_uninstall_reason)
if config_state_1_uninstall_module:
@@ -1306,9 +1768,9 @@ class TodoUpgrade:
self.write_config()
- print("✅ -> Uninstall module")
+ print(f"✅ -> {t('Uninstall modules')}")
- print("✅ -> install module")
+ print(f"✅ -> {t('Install modules')}")
if not is_state_4_reach_open_upgrade:
lst_module_to_install = []
if config_state_1_install_module:
@@ -1327,13 +1789,10 @@ class TodoUpgrade:
self.write_config()
msg = "2 - Succeed update all addons"
- print(f"🔷 {msg}")
+ self.print_step(msg)
self.add_comment_progression(msg)
- if (
- not self.dct_progression.get("state_2_update_all")
- and not already_update_state_1
- ):
+ if self.needs_update_all(self.dct_progression, already_update_state_1):
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/update_addons_all.sh {database_name}",
single_source_odoo=True,
@@ -1350,44 +1809,66 @@ class TodoUpgrade:
# -- 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(
+ # Sur le terminal, sans capturer : plus rien ne relit cette sortie —
+ # la décision se prend sur le code de retour — et la capture faisait
+ # annoncer « Command returned error code: 1 » sur un rapport qui va
+ # bien. 1 veut dire « des copies casseront », pas « l'outil a raté ».
+ status = self.run_on_terminal(
f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py"
- f" -d {database_name} -t odoo{start_version + 1}.0",
- wait_at_error=False,
+ f" -d {database_name} -t odoo{start_version + 1}.0"
+ )
+ # L'avertissement annonçait un problème, invitait à arbitrer, et
+ # aucune question ne suivait : dire « la question viendra au palier »
+ # ne remplace pas la possibilité de regarder maintenant. Les copies
+ # sont visibles ici, et les neutraliser ici vaut pour tous les paliers
+ # — chaque base de palier est un clone de celle-ci.
+ # Le code de sortie, pas le texte : 1 = des copies casseront.
+ # Chercher une phrase anglaise dans la sortie rendait cette invite
+ # muette dès que l'outil parlait français.
+ if status == 1:
+ self.prompt_cow_prediction(database_name, start_version + 1)
+
+ # Même prédiction, autre matière : un SCSS personnalisé est lui aussi
+ # une copie figée, et un palier renomme aussi des variables. Celle-ci
+ # ne se voit qu'à l'écran d'une page — « Style error », sans dire quel
+ # fichier ni depuis quand — et seulement une fois le palier passé.
+ # Ici on PRÉDIT, on ne corrige pas : le checkout est encore sur la
+ # version d'avant, et reset_asset n'existe qu'à partir de 13.0.
+ # Appliquer depuis là lève « KeyError: 'web_editor.assets' » — c'est
+ # arrivé sur une vraie migration, et l'échec est passé pour un
+ # succès. La correction est proposée après le palier, plus bas.
+ self.run_on_terminal(
+ f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py"
+ f" -d {database_name} -t odoo{start_version + 1}.0 --report-only"
)
+ # La mesure de DÉPART. Sans elle, une page qui rendait déjà 500 avant
+ # la migration se lit comme un dégât de la migration, et l'on cherche
+ # des heures du côté du palier. Mesuré : deux URL cassaient avant même
+ # de commencer.
+ # Le nettoyage AVANT la mesure : interroger les pages sur une base
+ # encombrée fait chercher des pannes dans des restes, et le nettoyage
+ # en répare une partie de lui-même.
+ self.prompt_database_cleanup(database_name)
+ self.prompt_smoke_public_url(database_name, baseline=True)
+
msg = "3 - Clean up database before data migration"
- print(f"🔷 {msg}")
+ self.print_step(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()
-
+ # Le nettoyage est fait à l'étape 2, par l'outil, et il pose lui-même
+ # le module s'il manque. Redemander ici « avez-vous fini de nettoyer
+ # dans l'interface ? » ferait faire à la main ce qui vient d'être
+ # fait — et l'installation qui suivait arrivait APRÈS l'usage.
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_install_clean_database"] = True
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.print_step(msg)
self.add_comment_progression(msg)
self.dct_progression["state_4_reach_open_upgrade"] = True
@@ -1485,7 +1966,9 @@ class TodoUpgrade:
self.switch_odoo(next_version - 1)
print(
- f"⧖ -> Clone to odoo.'{next_version}', from '{database_name}' to '{database_name_upgrade}'."
+ f"⧖ -> {t('Cloning to Odoo')}{next_version},"
+ f" {t('from')} '{database_name}'"
+ f" {t('to')} '{database_name_upgrade}'."
)
# Delete if exist database
self.todo_upgrade_execute(
@@ -1503,18 +1986,22 @@ class TodoUpgrade:
# 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."
+ f"❌ -> {t('Clone to Odoo')}{next_version}"
+ f" {t('FAILED (status')} {status})."
+ f" {t('Stopping:')} '{database_name_upgrade}'"
+ f" {t('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")
+ print(f"✅ -> {t('Clone done for Odoo')}{next_version}")
else:
- print(f"✅ -> Clone Odoo{next_version} - nothing")
+ print(
+ f"✅ -> {t('Clone already done for Odoo')}"
+ f"{next_version}"
+ )
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Uninstall module"
@@ -1538,7 +2025,8 @@ class TodoUpgrade:
)
if lst_detail:
print(
- f"✨ Modules to uninstall before Odoo{next_version} :"
+ f"✨ {t('Modules to uninstall before Odoo')}"
+ f"{next_version} :"
)
self.print_uninstall_reason(lst_detail)
lst_module_to_uninstall = list(
@@ -1623,9 +2111,15 @@ class TodoUpgrade:
lst_switch_odoo
)
self.write_config()
- print(f"✅ -> Switch Odoo{next_version} done with update")
+ print(
+ f"✅ -> {t('Switch done with update for Odoo')}"
+ f"{next_version}"
+ )
else:
- print(f"✅ -> Switch Odoo{next_version} - nothing")
+ print(
+ f"✅ -> {t('Switch already done for Odoo')}"
+ f"{next_version}"
+ )
lst_state_4_module_migrate_code = self.dct_progression.get(
"config_state_4_module_to_migrate_code",
@@ -1691,17 +2185,19 @@ class TodoUpgrade:
)
if lst_module_duplicate:
- print(f"Duplicate module into odoo{next_version} : ")
+ print(f"{t('Duplicate module in Odoo')}{next_version} : ")
print(lst_module_duplicate)
- input(
- f"💬 Detect error duplicate module, manage this problem manually and press to continue."
+ self.ask(
+ f"💬 {t('Duplicate module error detected, handle it')}"
+ f" {t('manually then press enter 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} :"
+ f"👹 {t('Missing module error detected, missing in')}"
+ f" Odoo{next_version} :"
)
for index_missing_module, module_missing in enumerate(
lst_module_missing_next_version
@@ -1712,13 +2208,16 @@ class TodoUpgrade:
print(
f"[{index_missing_module}] {module_missing} - {old_path}"
)
- print("[a] All list above")
- print("[e] Add extra custom")
+ print(f"[a] {t('All of the list above')}")
+ print(f"[e] {t('Add an extra custom one')}")
want_continue = (
- input(
- f"💬 Enumerate missing module separate by coma to delete it"
- f". The others will be migrate : "
+ self.ask(
+ f"💬 {t('List the missing modules to delete,')}"
+ f" {t('separated by commas. The others will be')}"
+ f" {t('migrated')}"
+ f" ({t('Enter = all, n = none')}) : ",
+ default="a",
)
.strip()
.lower()
@@ -1726,6 +2225,11 @@ class TodoUpgrade:
is_delete_all = False
+ # « n » saute le bloc ENTIER, ajouts par version compris :
+ # Entrée valant « toutes », il faut un mot pour dire non,
+ # et il doit vraiment ne rien supprimer.
+ if want_continue == "n":
+ want_continue = ""
if want_continue:
lst_want_continue = [
a.strip() for a in want_continue.split(",")
@@ -1779,8 +2283,9 @@ class TodoUpgrade:
if "e" in lst_want_continue:
want_continue = (
- input(
- f"💬 Enumerate module name to delete, separate by coma : "
+ self.ask(
+ f"💬 {t('List the module names to delete,')}"
+ f" {t('separated by commas')} : "
)
.strip()
.lower()
@@ -1833,15 +2338,17 @@ class TodoUpgrade:
if lst_module_missing_last:
print(
- f"Error missing module : {lst_module_missing_last}"
+ f"{t('Missing module error')} :"
+ f" {lst_module_missing_last}"
)
if lst_module_duplicate_last:
print(
- f"Error duplicate module : {lst_module_duplicate_last}"
+ f"{t('Duplicate module error')} :"
+ f" {lst_module_duplicate_last}"
)
if lst_module_error_last:
print(
- f"Error error module : {lst_module_error_last}"
+ f"{t('Module error')} : {lst_module_error_last}"
)
if lst_module_exist_last:
@@ -1984,9 +2491,15 @@ class TodoUpgrade:
)
self.write_config()
- print(f"✅ -> Module upgrade Odoo{next_version} done")
+ print(
+ f"✅ -> {t('Module upgrade done for Odoo')}"
+ f"{next_version}"
+ )
else:
- print(f"✅ -> Module upgrade Odoo{next_version} - nothing")
+ print(
+ f"✅ -> {t('Module upgrade already done for Odoo')}"
+ f"{next_version}"
+ )
option_comment += 1
msg = f"4.{index}.{chr(option_comment + 65)} - Fix migrate code"
@@ -2029,8 +2542,8 @@ class TodoUpgrade:
# problem the fix exists to prevent.
if status:
print(
- f"❌ -> Fix migration Odoo{next_version} FAILED"
- f" (status {status}):"
+ f"❌ -> {t('Migration fix for Odoo')}{next_version}"
+ f" {t('FAILED (status')} {status}) :"
f" {file_path_fix_migration}"
)
return
@@ -2040,13 +2553,20 @@ class TodoUpgrade:
lst_fix_migration_odoo
)
self.write_config()
- print(f"✅ -> Fix migration Odoo{next_version} done")
+ print(
+ f"✅ -> {t('Migration fix done for Odoo')}"
+ f"{next_version}"
+ )
else:
print(
- f"✅ -> Fix migration Odoo{next_version} - no fix to execute"
+ f"✅ -> {t('No migration fix to run for Odoo')}"
+ f"{next_version}"
)
else:
- print(f"✅ -> Fix migration Odoo{next_version} - nothing")
+ print(
+ f"✅ -> {t('Migration fix already done for Odoo')}"
+ f"{next_version}"
+ )
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"
@@ -2077,12 +2597,22 @@ class TodoUpgrade:
)
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}'."
+ f"🚸 {t('Please validate the commits after the code')}"
+ f" {t('migration.')}"
)
- status = input(f"💬 Press to continue {msg} : ").strip()
+ print(
+ f"ℹ {t('To show the repo status')} :"
+ "\nmake repo_show_status"
+ )
+ print(
+ f"🚸 {t('Please validate this path in config.conf')} :"
+ f" '{path_addons_openupgrade}'."
+ )
+ status = self.ask_gate(
+ f"💬 {t('Press to continue')} {msg}"
+ f" {t('(b = go back a step, t = show the migration state)')} : "
+ ).strip()
# The technique change at version 14
if next_version <= 13:
erplibre_version = self.install_OCA_openupgrade(
@@ -2135,15 +2665,17 @@ class TodoUpgrade:
)
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."
+ f"\n❌ -> {t('Database migration to Odoo')}"
+ f"{next_version} {t('FAILED (status')} {status}).\n"
+ f" '{database_name_upgrade}'"
+ f" {t('is now half migrated: replaying the command on')}"
+ f" {t('it would never recover, so it is NOT offered.')}"
+ f"\n {t('The clone step has been reset. Fix the')}"
+ f" {t('cause, then relaunch the migration and answer')}"
+ f" [c] ({t('continue')}) :"
+ f" '{database_name_upgrade}'"
+ f" {t('will be dropped and rebuilt from the previous')}"
+ f" {t('version before retrying.')}"
)
return
@@ -2161,6 +2693,17 @@ class TodoUpgrade:
)
self.write_config()
+ # ICI seulement la correction est possible : le checkout est
+ # passé à la version cible, donc le shell sait faire
+ # reset_asset. Et c'est le dernier moment utile — la mise à
+ # jour des modules qui suit est ce qui compile les bundles,
+ # donc ce qui échoue si un SCSS figé reste en place.
+ self.run_on_terminal(
+ f"{PYTHON_BIN}"
+ " ./script/odoo/migration/check_stale_scss.py"
+ f" -d {database_name_upgrade} -t odoo{next_version}.0"
+ )
+
str_wait_next_version = (
" (or wait next version 🤖)"
if next_version != lst_next_version[-1]
@@ -2168,8 +2711,12 @@ class TodoUpgrade:
)
status = (
- input(
- f"💬 Do you want to upgrade all{str_wait_next_version}? Press y/Y to upgrade all addons database : "
+ self.ask(
+ f"💬 {t('Do you want to upgrade all')}"
+ f"{str_wait_next_version} ?"
+ f" (Y/n, {t('Enter upgrades all addons of the')}"
+ f" {t('database')}) : ",
+ default="y",
)
.strip()
.lower()
@@ -2180,61 +2727,342 @@ class TodoUpgrade:
f"./script/addons/update_addons_all.sh {database_name_upgrade}",
)
- print(f"✅ -> Database upgrade Odoo{next_version} done")
+ # Au palier 13 SEULEMENT, et après la mise à jour des
+ # modules : c'est là que MuK DMS devient OCA DMS, et donc
+ # là que le modèle de sécurité change. OCA pose des règles
+ # GLOBALES sur `permission_read`, accordé par une
+ # `dms.access.group` — que la conversion ne crée pas,
+ # puisque MuK n'en avait aucune. Les documents restent en
+ # base, intacts, et plus personne ne les voit.
+ #
+ # L'outil ne fait rien s'il n'y a pas de DMS, et rien non
+ # plus s'il a déjà réparé : le rejouer est sans effet.
+ if next_version == 13:
+ self.run_on_terminal(
+ f"{PYTHON_BIN}"
+ " ./script/odoo/migration/dms_access_repair.py"
+ f" -d {database_name_upgrade} --apply"
+ )
+
+ print(
+ f"✅ -> {t('Database upgrade done for Odoo')}"
+ f"{next_version}"
+ )
# 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()
+
+ # Une migration peut charger tous ses modules et servir
+ # quand même un 500 sur une page que personne n'ouvre. Mesuré
+ # ici : /blog//post/ et /contactus, alors que
+ # le journal de migration n'avait rien signalé.
+ self.prompt_database_cleanup(database_name_upgrade)
+ self.prompt_smoke_public_url(database_name_upgrade)
+
+ # Le trou que ni les comptages ni le test de fumée ne
+ # voient : des données PRÉSENTES qu'une règle globale
+ # masque intégralement. Les comptages disent « tout est
+ # là » — et c'est vrai. Les pages publiques répondent — et
+ # c'est vrai aussi. Pourtant plus personne n'atteint les
+ # données. Mesuré sur DMS au palier 13 : 69 fichiers et
+ # 23 Mo intacts, zéro visible, pour tous les utilisateurs.
+ self.run_on_terminal(
+ f"{PYTHON_BIN}"
+ " ./script/odoo/migration/check_hidden_models.py"
+ f" -d {database_name_upgrade}"
)
+
+ print(f"[y] {t('Open the server with Selenium')}")
+ print(f"[a] {t('Open it at EVERY version bump, stop asking')}")
+ # Une migration traverse jusqu'à six paliers. Répondre « y »
+ # à chacun oblige à rester devant ; « a » est la réponse
+ # qu'on donnait de toute façon six fois de suite, dite une
+ # seule fois — et retenue, donc valable après une reprise.
+ if self.dct_progression.get("state_4_selenium_every_bump"):
+ status = "y"
+ print(
+ f"ℹ -> {t('Testing every bump, as chosen earlier.')}"
+ )
+ else:
+ status = (
+ self.ask(
+ f"💬 {t('Do you want to test this upgrade? Choose')}"
+ f" {t('or press enter to ignore it')} : "
+ )
+ .strip()
+ .lower()
+ )
+ if status == "a":
+ self.dct_progression["state_4_selenium_every_bump"] = (
+ True
+ )
+ self.write_config()
+ status = "y"
"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} : "
+ status = self.ask(
+ f"💬 {t('Press enter to continue')} 4.{index} : "
).strip()
else:
- print(f"✅ -> Database upgrade Odoo{next_version} - nothing")
+ print(
+ f"✅ -> {t('Database upgrade already done for Odoo')}"
+ f"{next_version}"
+ )
#
- # waiting_input = input("💬 Press any keyboard key to continue...")
+ # waiting_input = self.ask("💬 Press any keyboard key to continue...")
print("")
msg = "5 - Cleaning up database after upgrade"
- print(f"🔷 {msg}")
+ self.print_step(msg)
self.add_comment_progression(msg)
print(
- "✨ Re-update i18n, purger data, tables (except mail_test and mail_test_full)"
+ f"✨ {t('Re-update i18n, purge the data and the tables')}"
+ f" ({t('except mail_test and mail_test_full')})"
)
- # waiting_input = input("💬print Press any keyboard key to continue...")
+ # waiting_input = self.ask("💬print Press any keyboard key to continue...")
msg = "6 - Migration finished"
- print(f"🔷 {msg}")
+ self.print_step(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 : "
+ print(f"✨ {t('A backup can be created')} :\n{cmd_backup}")
+ status = self.ask(
+ f"💬 {t('Export a backup?')}"
+ f" (Y/n, {t('or type filename.zip')}) : ",
+ default="y",
).strip()
- if status.lower():
- if status.lower() != "y":
- cmd_backup = f"{cmd_backup_template} {status}"
+ cmd_backup = self.backup_command(
+ status, cmd_backup_template, cmd_backup
+ )
+ if cmd_backup:
self.todo_upgrade_execute(cmd_backup)
- status = input("💬 Test the migration, press y/Y : ")
+ status = self.ask(f"💬 {t('Test the migration, press y/Y')} : ")
if status.lower().strip() == "y":
self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade)
+ def _prompt_on_error(self, database_name, defaut, repare):
+ """Le menu proposé quand une commande échoue. Rend (choix, réparé).
+
+ Extrait de `todo_upgrade_execute` : à cinq entrées la méthode
+ appelante passait le seuil de complexité, et une boucle d'invite
+ se relit mieux seule que noyée dans l'exécution d'une commande.
+ """
+ # Une fois, avant la boucle : la détection lit un journal et
+ # interroge la base, et la boucle peut tourner huit fois.
+ themes = self.theme_blamed_by_the_error(database_name)
+ tours = 0
+ while True:
+ # Une borne STRUCTURELLE, et non pas seulement la logique
+ # ci-dessous qui bascule le défaut. Les deux protections
+ # visent la même panne — une invite qui se repropose sans
+ # fin — mais celle-ci tient même si l'autre est cassée un
+ # jour par mégarde. Une boucle infinie dans une migration
+ # lancée sans surveillance coûte une nuit.
+ tours += 1
+ if tours > self.MAX_ERROR_TURNS:
+ print(f"🛑 {t('Too many turns on this prompt: moving on.')}")
+ wait_status = ""
+ break
+ print(f"[1] {t('to redo the command')}")
+ if database_name:
+ print(
+ f"[2] {t('Check the COW views that drifted')}"
+ f" ({database_name})"
+ )
+ print(f"[3] {t('Reset one of them onto its module view')}")
+ print(f"[4] {t('Browse the differences full screen')}")
+ print(
+ f"[5] {t('Fix views whose type contradicts their')}"
+ f" {t('inheritance')}"
+ )
+ if themes:
+ print(
+ f"[6] {t('Uninstall the theme(s) the error names')}"
+ f" : {', '.join(themes)}"
+ )
+ # `self.ask` : une migration automatique s'arrêtait ICI,
+ # sur une invite qui ne demande qu'à continuer, et restait
+ # bloquée sans que rien ne le signale.
+ wait_status = (
+ self.ask(
+ f"💬 {t('Error detected. Choose, or ctrl+c to')}"
+ f" {t('stop')}"
+ f" ({t('Enter')} = {defaut or t('continue')}) : ",
+ default=defaut,
+ )
+ .strip()
+ .lower()
+ )
+
+ # psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
+ # LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
+
+ if wait_status == "2" and database_name:
+ # Le motif d'échec le plus fréquent ici est « Element
+ # cannot be located in parent view » : une copie
+ # COW en retard sur sa vue module. On propose l'outil sur
+ # place, puis on repose le choix pour rejouer.
+ self.check_stale_cow_views(database_name)
+ continue
+ if wait_status == "3" and database_name:
+ repare = self.prompt_reset_stale_cow_views(database_name)
+ if repare:
+ # Quelque chose a changé : la commande mérite un
+ # nouvel essai, et c'est le seul cas où le rejeu
+ # est AUTOMATIQUE.
+ wait_status = "1"
+ break
+ # Rien à réinitialiser. Reproposer « 3 » ferait tourner
+ # en rond — mesuré : « Aucune copie COW n'a dérivé »,
+ # encore et encore, sans fin.
+ defaut = ""
+ continue
+ if wait_status == "6" and themes:
+ # Le thème parti, la commande mérite un nouvel essai —
+ # et `repare` autorise le rejeu automatique borné.
+ for theme in themes:
+ self.run_on_terminal(
+ f"./script/addons/uninstall_addons_theme.sh"
+ f" {database_name} {theme}"
+ )
+ wait_status = "1"
+ repare = True
+ break
+ if wait_status == "5" and database_name:
+ # Rien à voir avec les COW : ici la vue n'est PAS une
+ # copie, c'est son `type` stocké qui ment. Odoo ne le
+ # recalcule jamais — il le pose à la création — donc
+ # aucune mise à jour ne le réparera, et le chargement
+ # échoue avant d'y arriver.
+ repare = self.prompt_fix_view_type(database_name)
+ if repare:
+ wait_status = "1"
+ break
+ defaut = ""
+ continue
+ if wait_status == "4" and database_name:
+ # Sur le VRAI terminal : un plein écran refuse de
+ # s'ouvrir sur un stdout capturé, et retomberait sur le
+ # rapport texte sans que rien ne distingue les deux.
+ self.run_on_terminal(
+ f"{PYTHON_BIN} ./"
+ + os.path.join(
+ PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
+ )
+ + f" -d {database_name} --tui"
+ )
+ continue
+ break
+
+ return wait_status, repare
+
+ def prompt_fix_view_type(self, database):
+ """Corriger les vues dont le `type` contredit leur héritage.
+
+ Rend True si quelque chose a changé — c'est ce qui autorise le
+ rejeu automatique de la commande.
+
+ En SQL, sans l'ORM : le registre ne charge plus, et c'est
+ précisément ce qu'on répare. Un outil qui aurait besoin d'Odoo
+ pour réparer ce qui empêche Odoo de démarrer ne servirait à rien.
+ """
+ outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_view_type.py")
+ # `wait_at_error=False` est OBLIGATOIRE : pour cet outil, 1 veut
+ # dire « des trouvailles », pas « échec ». Sans ce drapeau,
+ # `todo_upgrade_execute` y lit une panne et rouvre SON menu
+ # d'erreur par-dessus le nôtre — mesuré : la question « Les
+ # corriger ? » n'était jamais posée et le menu tournait en rond.
+ status, _cmd = self.todo_upgrade_execute(
+ f"{PYTHON_BIN} ./{outil} -d {database}",
+ wait_at_error=False,
+ )
+ if status != 1:
+ # 0 : rien à corriger. 2 : l'outil a échoué. Ni l'un ni
+ # l'autre ne justifie de rejouer la commande.
+ return False
+ reponse = (
+ self.ask(f"💬 {t('Correct them?')} (Y/n) : ", default="y")
+ .strip()
+ .lower()
+ )
+ if reponse not in ("y", "yes", "o"):
+ return False
+ status, _cmd = self.todo_upgrade_execute(
+ f"{PYTHON_BIN} ./{outil} -d {database} --apply",
+ wait_at_error=False,
+ )
+ return status == 0
+
+ def prompt_purge_dead_attachments(self, database):
+ """Effacer les pièces jointes dont le champ n'existe plus.
+
+ ICI et pas entre les paliers. Mesuré : entre deux versions, deux
+ à onze champs disparaissent puis REVIENNENT — `hr.employee.phone`,
+ `account.move.statement_id`. « Le champ n'existe plus » est donc
+ un état transitoire tant que la migration court, et purger
+ dessus, c'est trancher sur ce qui va se rétablir.
+
+ Le gain d'un nettoyage par palier serait nul de toute façon :
+ mesuré, 1881 lignes apparaissent au palier 13 et le compte ne
+ bouge plus ensuite. Une passe finale les prend toutes.
+
+ Après la sauvegarde ? Non, AVANT : celle qui suit capturera
+ l'état nettoyé, et qui veut garder l'état d'avant n'a qu'à
+ refuser ici puis relancer l'outil depuis le menu Analyse.
+ """
+ from script.analyse import check_filestore as filestore
+
+ try:
+ rapport = filestore.audit(database)
+ except Exception as exc:
+ print(f"⚠ {t('Could not inspect the attachments: ')}{exc}")
+ return
+ if rapport.get("unavailable"):
+ return
+ sql = filestore.purge_dead_sql(rapport)
+ combien = len(rapport.get("dead_ids") or [])
+ if not sql:
+ print(f"✅ {t('No attachment points at a field that is gone.')}")
+ return
+ poids = rapport.get("dead_kept_size", 0) // 1024
+ print()
+ print(
+ f"🕳 {combien}"
+ f" {t('attachment(s) point at a field that no longer exists')}"
+ f" ({poids} ko)"
+ )
+ for texte in filestore.summarise(
+ rapport["groups"]["dead_field"] + (rapport.get("dead_kept") or [])
+ )[:6]:
+ print(f" {texte}")
+ reponse = (
+ self.ask(f"💬 {t('Delete them?')} (Y/n) : ", default="y")
+ .strip()
+ .lower()
+ )
+ if reponse not in ("y", "yes", "o"):
+ print(f"ℹ️ {t('Nothing was deleted.')}")
+ return
+ status, _cmd, sortie = self.todo_upgrade_execute(
+ f'psql -d {database} -c "{sql}"', get_output=True
+ )
+ if status:
+ print(f"❌ {t('The purge failed.')}")
+ return
+ efface = filestore.rows_deleted(sortie)
+ if efface is None:
+ print(f"⚠ {t('The purge ran but said nothing.')}")
+ return
+ print(f"✅ {efface} {t('attachment row(s) deleted.')}")
+
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(
@@ -2353,6 +3181,376 @@ class TodoUpgrade:
wait_at_error=False,
)
+ def print_step(self, msg):
+ """Affiche un en-tête d'étape en traduisant son seul libellé.
+
+ `msg` reste anglais : il part aussi dans le journal, que l'écran de
+ reprise relit. Traduire ce qui est ÉCRIT rendrait un journal
+ illisible pour l'autre langue, et l'étape 4 numérote ses en-têtes
+ (« 4.2.C - Install module ») — seule la partie après le tiret est
+ une phrase.
+ """
+ prefix, sep, label = msg.partition(" - ")
+ # Retenu pour l'écran d'état : un événement sans étape oblige à
+ # relire tout le journal pour savoir OÙ il s'est produit.
+ self.current_step = msg
+ self.open_step_log(msg)
+ print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}")
+
+ def installed_theme(self, database_name):
+ """Thèmes installés, hors theme_default qui EST l'absence de thème."""
+ status, _cmd, output = self.todo_upgrade_execute(
+ f'psql -X -w -d {database_name} -tAc "SELECT name FROM'
+ " ir_module_module WHERE name LIKE 'theme%' AND"
+ " state = 'installed' AND name <> 'theme_default'"
+ ' ORDER BY name;"',
+ get_output=True,
+ wait_at_error=False,
+ quiet=True,
+ )
+ if status:
+ return []
+ return [line.strip() for line in (output or []) if line.strip()]
+
+ def step_log_tail(self, octets=65536):
+ """La fin du journal de l'étape en cours, ou "".
+
+ Bornée, et lue depuis la FIN : une mise à jour de modules écrit
+ des dizaines de milliers de lignes, et charger tout le fichier
+ pour en regarder vingt coûterait plus que l'erreur qu'on cherche.
+ """
+ chemin = self.log_dir()
+ etape = getattr(self, "current_step", "")
+ if not chemin or not etape:
+ return ""
+ fichier = os.path.join(chemin, f"{self.step_slug(etape)}.log")
+ try:
+ with open(fichier, "rb") as handle:
+ handle.seek(0, os.SEEK_END)
+ depart = max(0, handle.tell() - octets)
+ handle.seek(depart)
+ return handle.read().decode("utf-8", errors="replace")
+ except OSError:
+ return ""
+
+ def theme_blamed_by_the_error(self, database_name):
+ """Les thèmes installés que la sortie récente MET EN CAUSE.
+
+ Deux conditions, pas une. Un thème doit être installé — sinon il
+ n'y a rien à retirer — ET son nom doit figurer dans ce que la
+ commande vient d'écrire. Proposer la désinstallation à chaque
+ échec reviendrait à offrir de casser le design du site pour une
+ panne qui n'a rien à voir.
+ """
+ if not database_name:
+ return []
+ installes = self.installed_theme(database_name)
+ if not installes:
+ return []
+ journal = self.step_log_tail()
+ if not journal:
+ return []
+ return [nom for nom in installes if nom in journal]
+
+ def prompt_uninstall_theme(self, database_name):
+ """Proposer de retirer les thèmes AVANT de monter de version.
+
+ Un thème installé traverse la migration : ses copies de vues et ses
+ SCSS suivent chaque palier, et chaque palier peut renommer ce dont
+ ils dépendent. Le retirer d'abord enlève d'un coup une famille
+ entière de pannes, et se refait après.
+
+ « non » par défaut : retirer un thème change l'apparence du site, et
+ ce n'est pas à une migration de le décider à la place de quelqu'un.
+ La question n'est posée que s'il y a un thème à retirer.
+ """
+ lst_theme = self.installed_theme(database_name)
+ if not lst_theme:
+ return
+ print(
+ f"\n✨ {t('Installed theme(s) on')} '{database_name}' :"
+ f" {', '.join(lst_theme)}"
+ )
+ print(
+ f" {t('A theme carries view copies and SCSS through every')}"
+ f" {t('version bump, and a bump can rename what they rely on.')}"
+ )
+ answer = (
+ self.ask_gate(
+ f"💬 {t('Uninstall them properly before migrating?')}"
+ f" (Y/n, {t('(b = go back a step, t = show the migration state)')}) : ",
+ default="y",
+ )
+ .strip()
+ .lower()
+ )
+ if answer != "y":
+ print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}")
+ return
+ for theme in lst_theme:
+ # Sur le VRAI terminal : ce script finit par theme_leftover.py,
+ # qui pose une question. L'exécuteur capture la sortie par un
+ # tube — Python la met alors en tampon par blocs — et l'invite
+ # restait invisible pendant que le processus attendait une
+ # réponse. On tape Entrée à l'aveugle, plusieurs fois, et les
+ # frappes en trop vont à la question suivante.
+ self.run_on_terminal(
+ f"./script/addons/uninstall_addons_theme.sh"
+ f" {database_name} {theme}"
+ )
+
+ def stale_cow_keys(self, database_name):
+ """Les clés des copies COW en retard, sans décor."""
+ script_path = os.path.join(
+ PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
+ )
+ if not os.path.exists(script_path):
+ return []
+ status, _cmd, output = self.todo_upgrade_execute(
+ f"{PYTHON_BIN} ./{script_path} -d {database_name} --list-keys",
+ get_output=True,
+ wait_at_error=False,
+ quiet=True,
+ )
+ if status == 2:
+ return []
+ return [line.strip() for line in (output or []) if line.strip()]
+
+ def prompt_reset_stale_cow_views(self, database_name):
+ """Choisir quoi réinitialiser dans une LISTE, pas de mémoire.
+
+ L'aide disait « --reset --apply » et laissait retrouver la clé
+ dans un diff de mille lignes. On la recopie, on se trompe d'un
+ caractère, et la commande ne fait rien sans le dire — une clé qui ne
+ correspond à rien n'est pas une erreur pour l'outil.
+
+ Rend True si quelque chose a VRAIMENT été réinitialisé. C'est ce qui
+ permet de rejouer la commande derrière : rejouer alors qu'on n'a
+ rien changé donnerait le même échec, indéfiniment.
+ """
+ lst_key = self.stale_cow_keys(database_name)
+ if not lst_key:
+ print(
+ f"✅ -> {t('No COW copy has drifted from its module view.')}"
+ )
+ return False
+ print(f"\n✨ {t('Drifted COW copies')} :")
+ for index, key in enumerate(lst_key, start=1):
+ print(f" [{index}] {key}")
+ print(f" [a] {t('All of the list above')}")
+ answer = (
+ self.ask_gate(
+ f"💬 {t('Which one(s) to reset onto the module view?')}"
+ f" ({t('numbers separated by commas, Enter = all, n =')}"
+ f" {t('nothing')}) : ",
+ default="a",
+ )
+ .strip()
+ .lower()
+ )
+ # « n », et non plus le vide : Entrée vaut « toutes » maintenant, et
+ # une sortie sans mot pour dire non serait une sortie sans issue.
+ if not answer or answer == "n":
+ print(f"ℹ -> {t('Kept. Nothing was reset.')}")
+ return False
+ if answer == "a":
+ lst_chosen = ["all"]
+ else:
+ lst_chosen = []
+ for part in answer.replace(" ", "").split(","):
+ if part.isdigit() and 1 <= int(part) <= len(lst_key):
+ lst_chosen.append(lst_key[int(part) - 1])
+ if not lst_chosen:
+ print(f"⚠️ {t('Unknown choice, nothing was reset.')}")
+ return False
+ args = " ".join(f"--reset {key}" for key in lst_chosen)
+ status = self.run_tool(
+ "reset_stale_cow_views",
+ f"{PYTHON_BIN} ./{os.path.join(PATH_MIGRATION_GLOBAL, 'reset_stale_cow_views.py')}"
+ f" -d {database_name} {args} --apply",
+ )
+ return status == 0
+
+ def prompt_database_cleanup(self, database_name):
+ """Proposer le nettoyage OCA avant d'interroger les pages.
+
+ Une migration laisse derrière elle des modèles, colonnes, tables,
+ données et menus obsolètes. Les interroger ensuite fait chercher des
+ pannes dans des restes qui n'auraient pas dû survivre — et le
+ nettoyage lui-même en répare une partie.
+
+ « non » par défaut : cela écrit en base, et c'est à vous d'en
+ décider.
+ """
+ answer = (
+ self.ask_gate(
+ f"💬 {t('Clean the database before testing the pages?')}"
+ f" (Y/n, {t('(b = go back a step, t = show the migration state)')}) : ",
+ default="y",
+ )
+ .strip()
+ .lower()
+ )
+ if answer != "y":
+ return
+ self.run_tool(
+ "database_cleanup",
+ f"{PYTHON_BIN}"
+ " ./script/odoo/migration/database_cleanup.py"
+ f" -d {database_name}",
+ )
+
+ def prompt_smoke_public_url(self, database_name, baseline=False):
+ """Proposer d'interroger toutes les pages publiques de la base.
+
+ La liste vient du sitemap — celle qu'Odoo publie pour les moteurs de
+ recherche. Une page qui y figure et ne répond pas est une page que
+ les visiteurs n'atteignent pas non plus.
+
+ `baseline` marque la mesure d'AVANT le premier palier. Elle ne sert
+ pas à réparer — elle sert à savoir ce qui cassait déjà. Sans ce point
+ de départ, les mêmes 500 après le palier passent pour un dégât de la
+ migration, et l'on cherche du mauvais côté.
+
+ Le serveur ainsi démarré sert AUSSI au back-office : si la base a
+ été neutralisée, l'outil se connecte avec l'utilisateur `test` que
+ la neutralisation y a posé et ouvre la première page de chaque
+ application. Un deuxième outil avec son propre serveur doublerait
+ l'attente — le démarrage d'Odoo est ce qui coûte, pas les requêtes.
+ """
+ if baseline:
+ print(
+ f"\n✨ {t('Before starting: what already answers, and what')}"
+ f" {t('does not. A page broken now will still be broken')}"
+ f" {t('after — and that is not the migration.')}"
+ )
+ answer = (
+ self.ask_gate(
+ f"💬 {t('Request every public URL of this database now?')}"
+ f" (Y/n, {t('(b = go back a step, t = show the migration state)')}) : ",
+ default="y",
+ )
+ .strip()
+ .lower()
+ )
+ if answer != "y":
+ return
+ # DIRE ce qui sera parcouru, et le dire AVANT. « si la base a été
+ # neutralisée » laissait la question ouverte pendant tout le
+ # parcours, et un saut annoncé en une ligne à la fin d'un long
+ # rapport ne se voit pas : on croit alors le back-office testé.
+ neutralise = "_neutralize" in database_name
+ if neutralise:
+ print(
+ f" {t('Public pages, then the back office and /my as the')}"
+ f" {t('test user (neutralized database).')}"
+ )
+ else:
+ print(
+ f" {t('Public pages only:')} '{database_name}'"
+ f" {t('was not neutralized, so there is no test user to')}"
+ f" {t('sign in with.')}"
+ )
+ self.run_tool(
+ "smoke_public_url",
+ f"{PYTHON_BIN}"
+ " ./script/odoo/migration/smoke_public_url.py"
+ f" -d {database_name}"
+ + (" --internal-required" if neutralise else ""),
+ )
+
+ def show_cow_drift(self, database_name, next_version, mode="diff"):
+ """Montre les copies COW à risque. Ne touche à rien.
+
+ Un seul endroit construit la commande : les deux invites — celle de
+ l'étape 2 et celle du palier — montraient sinon des choses qui
+ pouvaient diverger sans que rien ne le signale.
+ """
+ cmd = (
+ f"{PYTHON_BIN} ./script/odoo/migration/cow_drift.py"
+ f" -d {database_name} -t odoo{next_version}.0"
+ )
+ if mode == "shape":
+ cmd += " --shape"
+ elif mode == "tui":
+ cmd += " --tui"
+ self.run_on_terminal(cmd)
+
+ def run_on_terminal(self, cmd):
+ """Lance une commande en lui laissant le VRAI terminal.
+
+ `todo_upgrade_execute` capture la sortie par un tube. Un plein écran
+ y voit un stdout qui n'est pas un terminal, renonce, et retombe sur
+ son rapport texte : « w » réaffichait mot pour mot ce que « v »
+ venait de montrer, sans rien signaler.
+
+ Rien ne relit cette sortie — elle est REGARDÉE. Et le code de retour
+ d'un afficheur ne veut pas dire « erreur » : 1 signifie « il y a des
+ copies », ce qui est la raison même de l'avoir ouvert. L'annoncer
+ comme un échec inquiétait pour rien.
+ """
+ self.lst_command_executed.append(cmd)
+ self.dct_progression["command_executed"] = self.lst_command_executed
+ self.write_config()
+ print(f"\n🏠 ⬇ {t('Execute command')} :\n")
+ print(cmd)
+ self.note_step_log(f"$ {cmd}")
+ status = subprocess.call(cmd, shell=True, executable="/bin/bash")
+ self.note_step_log(f" -> {status}")
+ return status
+
+ def prompt_cow_prediction(self, database_name, next_version):
+ """Que faire des copies COW annoncées, dès l'étape 2.
+
+ La neutralisation officielle a lieu au palier, sur la base de palier.
+ Mais celle-ci en est la source : ce qu'on neutralise ici vaut pour
+ tous les paliers, et la lecture n'écrit rien de toute façon. Attendre
+ des dizaines de minutes pour seulement REGARDER n'avait pas de raison
+ d'être.
+ """
+ neutralize = (
+ f"{PYTHON_BIN} ./script/odoo/migration/neutralize_cow_views.py"
+ f" -d {database_name} -t odoo{next_version}.0"
+ )
+ while True:
+ # `self.ask`, pas `input` : cette invite boucle, et un `input`
+ # nu ici arrêtait net une migration automatique — sans rien
+ # afficher, puisque la question attendait déjà.
+ answer = (
+ self.ask(
+ f"💬 {t('What do you want to do with these COW copies?')}"
+ f" ({t('Enter = neutralize now, reversible')},"
+ f" v = {t('what each copy holds')},"
+ f" s = {t('why it breaks')},"
+ f" w = {t('full screen')},"
+ f" n = {t('decide at the version bump')}) : ",
+ default="a",
+ )
+ .strip()
+ .lower()
+ )
+ if answer in ("v", "s", "w"):
+ self.show_cow_drift(
+ database_name,
+ next_version,
+ {"v": "diff", "s": "shape", "w": "tui"}[answer],
+ )
+ continue
+ if answer == "a":
+ self.todo_upgrade_execute(
+ f"{neutralize} --apply", wait_at_error=False
+ )
+ print(
+ f"ℹ -> {t('Undo with')} :"
+ f" {neutralize.rsplit(' -t', 1)[0]} --restore"
+ )
+ return
+ break
+ print(
+ f"ℹ -> {t('Nothing decided')} :"
+ f" {t('the migration will ask again at the version bump.')}"
+ )
+
def neutralize_cow_views(self, database_name, next_version):
"""Offer to neutralize the COW views that would break this bump.
@@ -2368,18 +3566,35 @@ class TodoUpgrade:
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 []):
+ # 0 = rien à neutraliser. Lire une phrase anglaise dans la sortie
+ # faisait poser la question dès que l'outil parlait français.
+ if status == 0:
return
- answer = (
- input(
- "💬 Neutralize these copies so the upgrade can proceed?"
- " Their arch is kept and the change is reversible."
- " (Y/n) : "
+ # « v » et « w » avant de répondre : la question demande de renoncer à
+ # une personnalisation sans avoir montré laquelle. Souvent trois lignes
+ # — un id, une largeur de conteneur — mais parfois une page entière, et
+ # rien dans l'avertissement ne permet de les distinguer.
+ while True:
+ answer = (
+ self.ask(
+ "💬 Neutralize these copies so the upgrade can proceed?"
+ " Their arch is kept and the change is reversible."
+ " (Y/n, v = view the differences, w = full screen) : ",
+ default="y",
+ )
+ .strip()
+ .lower()
)
- .strip()
- .lower()
- )
+ if answer == "v":
+ self.show_cow_drift(database_name, next_version, "diff")
+ self.show_cow_drift(database_name, next_version, "shape")
+ continue
+ if answer == "w":
+ self.show_cow_drift(database_name, next_version, "tui")
+ continue
+ break
+
if answer == "n":
print(
"⚠️ -> Skipped. The data migration will very likely stop on"
@@ -2387,6 +3602,11 @@ class TodoUpgrade:
)
return
self.todo_upgrade_execute(f"{cmd} --apply", wait_at_error=False)
+ print(
+ "ℹ -> List them later with:"
+ f" {PYTHON_BIN} ./script/odoo/migration/neutralize_cow_views.py"
+ f" -d {database_name} --list"
+ )
def diff_cow_views(self, database_name, label_before, label_after):
"""Print what the version bump did to the website COW views."""
@@ -2517,7 +3737,9 @@ class TodoUpgrade:
)
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()
+ answer = self.ask(
+ f"💬 {t('Your choice')} ({t('Enter = 1')}) : ", default="1"
+ ).strip()
if answer == "2":
return lst_present + lst_missing
if answer == "3" or not lst_present:
@@ -2671,6 +3893,7 @@ class TodoUpgrade:
get_output=False,
output_is_json=False,
wait_at_error=True,
+ attempt=1,
):
if output_is_json and not get_output:
get_output = True
@@ -2700,45 +3923,44 @@ class TodoUpgrade:
# 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:
+ # AVANT l'invite : si l'on répond ctrl+c, l'échec doit tout de
+ # même figurer dans l'état — c'est précisément celui qu'on
+ # cherchera en revenant.
+ self.record_event("command", cmd, status if status else 1)
database_name = self.database_from_command(cmd)
- while True:
- print("[1] to redo the command")
- if database_name:
- print(
- f"[2] {t('Check the COW views that drifted')}"
- f" ({database_name})"
- )
- 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 == "2" and database_name:
- # Le motif d'échec le plus fréquent ici est « Element
- # cannot be located in parent view » : une copie
- # COW en retard sur sa vue module. On propose l'outil sur
- # place, puis on repose le choix pour rejouer.
- self.check_stale_cow_views(database_name)
- continue
- break
+ # « 3 » par défaut, car le motif d'échec le plus fréquent ici est
+ # une copie COW en retard : la réparer est presque toujours ce
+ # qu'on allait faire. Sans base nommée, les options 2 à 4
+ # n'existent pas et le défaut redevient « continuer ».
+ defaut = "3" if database_name else ""
+ repare = False
+ wait_status, repare = self._prompt_on_error(
+ database_name, defaut, repare
+ )
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,
- )
+ # Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la
+ # main ne l'est pas. Sans cette borne, une réparation qui
+ # n'y suffit pas relancerait la commande indéfiniment — et
+ # une migration lancée en auto-exécution tournerait toute
+ # la nuit sur le même échec.
+ if repare and attempt >= self.MAX_ERROR_RETRY:
+ print(
+ f"🛑 {t('Still failing after')}"
+ f" {self.MAX_ERROR_RETRY}"
+ f" {t('attempts: this one needs a developer.')}"
+ )
+ else:
+ 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,
+ attempt=attempt + 1,
+ )
if get_output:
if output_is_json:
@@ -2908,8 +4130,203 @@ class TodoUpgrade:
return parts[1], remote, remote_branch
return None, None, None
+ MAX_EVENT = 200
+
+ # Le nom de fichier d'une étape est calculé PAR L'ÉCRAN D'ÉTAT, et
+ # importé ici. Deux formules dériveraient, et l'écran chercherait alors
+ # un fichier que personne n'écrit — sans rien signaler, puisqu'un
+ # fichier absent se lit comme une étape sans journal.
+ step_slug = staticmethod(migration_status.step_slug)
+
+ def log_dir(self):
+ """Où vivent les journaux de CETTE migration. Créé à la demande.
+
+ Sous le nom de la base, comme les archives et les instantanés COW :
+ deux migrations menées de front ne doivent pas écrire dans le même
+ fichier, et l'on veut pouvoir tout emporter d'un seul répertoire.
+ """
+ database = (getattr(self, "dct_progression", None) or {}).get(
+ "config_database_name"
+ ) or "sans-nom"
+ chemin = os.path.join(PATH_MIGRATION_PRIVATE, database, STEP_LOG_DIR)
+ try:
+ os.makedirs(chemin, exist_ok=True)
+ except OSError:
+ return None
+ if database != UNNAMED_MIGRATION:
+ self.adopt_unnamed_logs(chemin)
+ return chemin
+
+ def adopt_unnamed_logs(self, chemin):
+ """Rapatrier les journaux écrits AVANT que la base ne soit nommée.
+
+ Les deux premières étapes — inspecter l'archive, la restaurer —
+ tournent avant qu'on ait choisi le nom de la base. Leurs journaux
+ atterrissaient donc sous « sans-nom », c'est-à-dire hors de la
+ migration à laquelle ils appartiennent : mesuré, deux fichiers
+ invisibles depuis l'écran d'état, et l'on cherchait des logs
+ manquants qui étaient simplement à côté.
+
+ En AJOUT si le fichier existe déjà : une reprise peut avoir écrit
+ des deux côtés, et écraser perdrait le premier passage.
+ """
+ if getattr(self, "_unnamed_adopted", False):
+ return
+ self._unnamed_adopted = True
+ source = os.path.join(
+ PATH_MIGRATION_PRIVATE, UNNAMED_MIGRATION, STEP_LOG_DIR
+ )
+ if not os.path.isdir(source) or os.path.abspath(
+ source
+ ) == os.path.abspath(chemin):
+ return
+ for nom in sorted(os.listdir(source)):
+ depart = os.path.join(source, nom)
+ arrivee = os.path.join(chemin, nom)
+ if not os.path.isfile(depart):
+ continue
+ try:
+ with open(
+ depart, "r", encoding="utf-8", errors="replace"
+ ) as f:
+ contenu = f.read()
+ with open(arrivee, "a", encoding="utf-8") as f:
+ f.write(contenu)
+ os.remove(depart)
+ except OSError:
+ continue
+ try:
+ os.rmdir(source)
+ except OSError:
+ pass
+
+ def open_step_log(self, msg):
+ """Rediriger la sortie des commandes vers le journal de cette étape.
+
+ En AJOUT, jamais en écrasement : une étape rejouée après un retour
+ en arrière doit s'ajouter à ce qu'on savait d'elle, pas l'effacer.
+ C'est précisément l'historique qu'on vient relire.
+ """
+ self.close_step_log()
+ chemin = self.log_dir()
+ if not chemin:
+ return
+ fichier = os.path.join(chemin, f"{self.step_slug(msg)}.log")
+ try:
+ handle = open(fichier, "a", encoding="utf-8", buffering=1)
+ except OSError:
+ return
+ handle.write(f"\n===== {datetime.datetime.now()} — {msg} =====\n")
+ self.step_log = handle
+ if getattr(self, "execute", None) is not None:
+ self.execute.log_sink = handle
+
+ def close_step_log(self):
+ handle = getattr(self, "step_log", None)
+ if handle:
+ try:
+ handle.close()
+ except Exception:
+ pass
+ self.step_log = None
+ if getattr(self, "execute", None) is not None:
+ self.execute.log_sink = None
+
+ def note_step_log(self, texte):
+ """Écrire une ligne dans le journal de l'étape en cours.
+
+ Ce qui passe par `run_on_terminal` n'a PAS de sortie capturable —
+ un tube y ferait renoncer les pleins écrans, la leçon a été payée.
+ On garde donc au moins la commande et son verdict.
+ """
+ handle = getattr(self, "step_log", None)
+ if not handle:
+ return
+ try:
+ handle.write(f"[{datetime.datetime.now()}] {texte}\n")
+ except Exception:
+ pass
+
+ def record_event(self, kind, name, status, detail=""):
+ """Garder ce qui s'est MAL passé, et ce que les outils ont conclu.
+
+ Le journal existant (`command_executed`) dit ce qui a été LANCÉ,
+ jamais ce que cela a donné. Après six paliers on lit deux cents
+ lignes de commandes sans savoir laquelle a échoué, ni ce que le
+ test de fumée a trouvé. C'est cette question-là que l'écran d'état
+ doit pouvoir répondre.
+
+ Borné : une migration lance des centaines de commandes, et un
+ fichier de progression qui enfle sans limite finit par coûter plus
+ cher à écrire qu'à lire.
+ """
+ lst = list(self.dct_progression.get("lst_event") or [])
+ lst.append(
+ {
+ "at": str(datetime.datetime.now()),
+ "step": getattr(self, "current_step", "") or "",
+ "kind": kind,
+ "name": name,
+ "status": status,
+ "detail": str(detail)[:500],
+ }
+ )
+ self.dct_progression["lst_event"] = lst[-self.MAX_EVENT :]
+ self.write_config()
+ # ET sur disque, en AJOUT, hors du fichier de progression. Celui-ci
+ # est archivé puis remis à zéro quand on recommence une migration :
+ # tout ce qu'on y avait mis disparaissait alors de l'écran d'état,
+ # au moment précis où l'on cherchait à comprendre pourquoi il avait
+ # fallu recommencer.
+ self.append_event_file(lst[-1])
+ self.note_step_log(f"[{kind}] {name} -> {status}")
+
+ def append_event_file(self, event):
+ """Ajouter l'événement au journal permanent, une ligne de JSON.
+
+ JSONL et non JSON : un fichier qu'on complète ligne à ligne
+ survit à une interruption au milieu d'une écriture, là où un
+ tableau JSON réécrit en entier ne laisserait qu'un fichier
+ tronqué — donc illisible, donc perdu en totalité.
+ """
+ chemin = self.log_dir()
+ if not chemin:
+ return
+ try:
+ with open(
+ os.path.join(chemin, EVENT_FILE), "a", encoding="utf-8"
+ ) as handle:
+ handle.write(json.dumps(event, ensure_ascii=False) + "\n")
+ except OSError:
+ pass
+
+ def run_tool(self, name, cmd):
+ """Lancer un outil de migration et RETENIR sa conclusion.
+
+ Les outils partagent une convention de code de sortie — 0 rien à
+ signaler, 1 des trouvailles, 2 l'outil a échoué — et c'est
+ exactement ce qu'on veut relire plus tard. Sans cela, « le test de
+ fumée est-il passé ? » n'a pas de réponse une heure après.
+ """
+ status = self.run_on_terminal(cmd)
+ self.record_event("test", name, status, cmd)
+ return status
+
def add_comment_progression(self, comment):
+ """Marquer une étape dans le journal, et devenir CETTE étape.
+
+ Vingt en-têtes d'étape passent par ici contre sept par
+ `print_step` : toute la boucle des paliers n'utilise que celle-ci.
+ Ne poser l'étape courante que dans l'autre laissait donc chaque
+ verdict d'outil estampillé d'une étape périmée — et le résumé des
+ tests semblait global alors qu'il aurait dû suivre les paliers.
+
+ C'est le MÊME marqueur que `journal_by_step` découpe : une seule
+ notion d'étape, posée à un seul endroit.
+ """
comment_to_add = f"# {comment}"
self.lst_command_executed.append(comment_to_add)
self.dct_progression["command_executed"] = self.lst_command_executed
+ self.current_step = comment
+ self.open_step_log(comment)
self.write_config()
diff --git a/test/test_auto_execute.py b/test/test_auto_execute.py
new file mode 100755
index 0000000..66c6985
--- /dev/null
+++ b/test/test_auto_execute.py
@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Enchaîner la migration sans rester devant à taper Entrée.
+
+Une migration pose des dizaines de questions dont la réponse est presque
+toujours celle proposée. Le mode auto attend cinq secondes puis prend le
+défaut — assez pour reprendre la main, assez peu pour ne pas immobiliser
+quelqu'un des heures.
+
+La question est posée AVANT le choix de version, donc avant la première
+décision : activée, elle vaut pour toutes les suivantes, celle-là comprise.
+
+Le test qui compte le plus est le dernier : une invite écrite plus tard en
+`input()` nu ne saurait rien du mode auto, et bloquerait la migration sans
+que rien ne le signale.
+"""
+
+import ast
+import inspect
+import io
+import os
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+from script.todo import todo_i18n # noqa: E402
+from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
+
+
+def upgrade(auto, delay=0.2):
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.auto_execute = auto
+ obj.AUTO_DELAY = delay
+ return obj
+
+
+class EnvCase(unittest.TestCase):
+ """Rendre l'environnement comme on l'a trouvé.
+
+ `ask` pose le mode auto dans l'environnement — c'est ainsi qu'il atteint
+ les outils lancés à part. Le laisser posé le ferait fuir dans TOUTE la
+ suite : une invite sans rapport prendrait son défaut au lieu d'attendre,
+ et le test qui échouerait ne serait pas celui qui a fauté.
+ """
+
+ def setUp(self):
+ from script.todo import auto_ask
+
+ avant = {
+ key: os.environ.get(key)
+ for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
+ }
+
+ def remettre():
+ for key, value in avant.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
+ self.addCleanup(remettre)
+
+
+class TestTheTimedRead(EnvCase):
+ def setUp(self):
+ super().setUp()
+ self.original = sys.stdin
+ self.addCleanup(setattr, sys, "stdin", self.original)
+
+ def pipe_stdin(self, payload=b""):
+ """Un vrai descripteur : `select` ne sait pas lire un faux objet."""
+ read_fd, write_fd = os.pipe()
+ if payload:
+ os.write(write_fd, payload)
+ os.close(write_fd)
+ handle = os.fdopen(read_fd)
+ self.addCleanup(handle.close)
+ sys.stdin = handle
+
+ def test_without_auto_it_just_asks(self):
+ import builtins
+
+ original = builtins.input
+ builtins.input = lambda prompt="": "tapé"
+ self.addCleanup(setattr, builtins, "input", original)
+ self.assertEqual(upgrade(False).ask("q : ", default="6"), "tapé")
+
+ def test_with_auto_an_answer_wins(self):
+ self.pipe_stdin(b"2\n")
+ out = io.StringIO()
+ real = sys.stdout
+ sys.stdout = out
+ try:
+ got = upgrade(True).ask("q : ", default="6")
+ finally:
+ sys.stdout = real
+ self.assertEqual(got, "2")
+
+ def test_a_closed_stdin_gives_the_default(self):
+ # Exécution non interactive : le tuyau fermé est lisible TOUT DE
+ # SUITE et rend une ligne vide. Sans traiter le vide comme le
+ # défaut, le mode auto n'y servirait à rien.
+ self.pipe_stdin()
+ out = io.StringIO()
+ real = sys.stdout
+ sys.stdout = out
+ try:
+ got = upgrade(True).ask("q : ", default="6")
+ finally:
+ sys.stdout = real
+ self.assertEqual(got, "6")
+
+ def test_silence_gives_the_default_after_the_delay(self):
+ # Le tuyau reste OUVERT : rien à lire, et select doit rendre la main
+ # au bout du délai plutôt que d'attendre indéfiniment.
+ import time
+
+ read_fd, write_fd = os.pipe()
+ self.addCleanup(os.close, write_fd)
+ handle = os.fdopen(read_fd)
+ self.addCleanup(handle.close)
+ sys.stdin = handle
+ out = io.StringIO()
+ real = sys.stdout
+ sys.stdout = out
+ debut = time.time()
+ try:
+ got = upgrade(True, delay=0.3).ask("q : ", default="6")
+ finally:
+ sys.stdout = real
+ self.assertEqual(got, "6")
+ self.assertGreaterEqual(time.time() - debut, 0.25)
+
+ def test_an_unselectable_stdin_falls_back_to_asking(self):
+ # Ne pas deviner quand on ne peut pas mesurer : mieux vaut demander
+ # que rendre un défaut que personne n'a voulu.
+ import builtins
+
+ class NotSelectable:
+ def fileno(self):
+ raise ValueError("pas de descripteur")
+
+ sys.stdin = NotSelectable()
+ original = builtins.input
+ builtins.input = lambda prompt="": "demandé"
+ self.addCleanup(setattr, builtins, "input", original)
+ out = io.StringIO()
+ real = sys.stdout
+ sys.stdout = out
+ try:
+ got = upgrade(True).ask("q : ", default="6")
+ finally:
+ sys.stdout = real
+ self.assertEqual(got, "demandé")
+
+
+class TestTheQuestionThatEnablesIt(EnvCase):
+ def setUp(self):
+ super().setUp()
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def answer(self, typed):
+ import builtins
+ import contextlib
+
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ original = builtins.input
+ builtins.input = lambda prompt="": typed
+ self.addCleanup(setattr, builtins, "input", original)
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ obj.prompt_auto_execute()
+ return obj.auto_execute, out.getvalue()
+
+ def test_yes_turns_it_on(self):
+ on, text = self.answer("y")
+ self.assertTrue(on)
+ self.assertIn("Auto-run on", text)
+
+ def test_the_default_is_off(self):
+ # Elle prend des décisions à votre place : ce n'est pas le défaut.
+ on, text = self.answer("")
+ self.assertFalse(on)
+ self.assertEqual(text, "")
+
+ def test_anything_else_is_off(self):
+ self.assertFalse(self.answer("peut-être")[0])
+
+ def test_the_delay_is_five_seconds(self):
+ self.assertEqual(TodoUpgrade.AUTO_DELAY, 5)
+
+ def test_yes_also_arms_the_tools_launched_apart(self):
+ # La moitié des invites d'une migration sont posées par d'autres
+ # processus. Sans cette variable, ils attendraient une frappe qui
+ # ne vient jamais — et l'automatisation s'arrêterait là, en
+ # silence, puisque la question a bien été posée.
+ from script.todo import auto_ask
+
+ self.answer("y")
+ self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
+
+ def test_no_leaves_nothing_behind(self):
+ from script.todo import auto_ask
+
+ os.environ[auto_ask.ENV_ENABLED] = "1"
+ self.answer("")
+ self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
+
+
+class TestWhereItIsAsked(unittest.TestCase):
+ def source(self):
+ return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+
+ def test_it_comes_before_the_version_choice(self):
+ # « avant de choisir la version d'Odoo » : activée, elle vaut aussi
+ # pour ce choix-là, qui a désormais un défaut.
+ source = self.source()
+ self.assertLess(
+ source.index("prompt_auto_execute"),
+ source.index("Which version do you want to upgrade to?"),
+ )
+
+ def test_the_version_choice_goes_through_it(self):
+ # click.prompt ne sait pas rendre la main après un délai : le laisser
+ # là aurait fait un mode auto qui s'arrête à la première question.
+ source = self.source()
+ self.assertNotIn("click.prompt(", source)
+
+
+class TestNoPromptEscapesIt(unittest.TestCase):
+ """Une invite en `input()` nu ne saurait rien du mode auto.
+
+ Elle bloquerait la migration sans rien signaler — et c'est précisément
+ le genre de chose qu'on n'ajoute pas exprès, mais par habitude.
+ """
+
+ def test_every_prompt_of_the_migration_uses_ask(self):
+ path = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
+ with open(path) as handle:
+ tree = ast.parse(handle.read())
+ target = [
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.FunctionDef)
+ and node.name == "execute_odoo_upgrade"
+ ]
+ self.assertEqual(len(target), 1, "fonction introuvable")
+ bare = [
+ node.lineno
+ for node in ast.walk(target[0])
+ if isinstance(node, ast.Call)
+ and getattr(node.func, "id", "") == "input"
+ ]
+ self.assertEqual(bare, [], "invites hors du mode auto")
+
+ def test_the_gate_uses_it_too(self):
+ # `ask_gate` porte le retour en arrière : la laisser en input() nu
+ # aurait fait un mode auto qui s'arrête à chaque « b = revenir ».
+ source = inspect.getsource(TodoUpgrade.ask_gate)
+ self.assertIn("self.ask(", source)
+ self.assertNotIn("= input(", source)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_check_hidden_models.py b/test/test_check_hidden_models.py
new file mode 100644
index 0000000..75fe51f
--- /dev/null
+++ b/test/test_check_hidden_models.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Le test qui aurait vu venir la disparition des documents DMS.
+
+Une propriété porte tout : le comptage se fait avec de VRAIS utilisateurs
+internes, jamais avec le super-utilisateur. Les règles ne s'appliquent pas
+à lui ; un comptage fait en son nom déclarerait saine une base que
+personne ne peut lire — exactement l'erreur qui a laissé passer le cas
+DMS pendant six paliers.
+"""
+
+import ast
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.odoo.migration import check_hidden_models as check # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+class TestTheGeneratedScript(unittest.TestCase):
+ def test_it_is_valid_python(self):
+ ast.parse(check.build_script())
+
+ def test_the_superuser_is_excluded(self):
+ # Sans cette exclusion, tout paraît visible et le test ne sert à
+ # rien : c'est le cœur du sujet.
+ code = check.build_script()
+ self.assertIn('("id", "!=", 1)', code)
+
+ def test_only_internal_users_count(self):
+ # Un portail ne voit presque rien par construction : l'inclure
+ # ferait crier au loup sur des modèles parfaitement sains.
+ self.assertIn('("share", "=", False)', check.build_script())
+
+ def test_only_active_users_count(self):
+ self.assertIn('("active", "=", True)', check.build_script())
+
+ def test_it_looks_at_global_rules_only(self):
+ # Une règle non globale ne s'applique qu'à certains groupes :
+ # qu'elle masque tout pour eux est normal.
+ code = check.build_script()
+ self.assertIn('("global", "=", True)', code)
+ self.assertIn('("active", "=", True)', code)
+
+ def test_empty_models_are_skipped(self):
+ # Un modèle sans ligne n'a rien à cacher ; le signaler noierait la
+ # vraie trouvaille.
+ self.assertIn("if not total:", check.build_script())
+
+ def test_it_stops_at_the_first_user_who_sees_something(self):
+ # Sans court-circuit, le coût est modèles × utilisateurs sur une
+ # base où presque tout est visible.
+ self.assertIn("break", check.build_script())
+
+ def test_the_user_limit_is_honoured(self):
+ code = check.build_script(limite=7)
+ self.assertIn("LIMITE = 7", code)
+ self.assertIn("limit=LIMITE", code)
+
+ def test_technical_models_are_excluded_by_name(self):
+ code = check.build_script()
+ for nom in ("ir.rule", "ir.model.access"):
+ self.assertIn(nom, code)
+
+ def test_the_exclusion_list_never_swallows_business_models(self):
+ # Une exclusion trop large rendrait le test muet sans le dire.
+ for nom in check.ATTENDUS:
+ self.assertTrue(
+ nom.startswith(("ir.", "bus.", "res.users.log", "mail.")),
+ f"exclusion suspecte : {nom}",
+ )
+
+ def test_it_reuses_the_shared_sentinels(self):
+ from script.odoo.migration import database_cleanup
+
+ self.assertEqual(check.DEBUT, database_cleanup.START)
+ self.assertEqual(check.FIN, database_cleanup.END)
+
+
+class TestTheReport(unittest.TestCase):
+ def test_a_clean_database_says_so(self):
+ texte = "\n".join(
+ check.render({"models": [], "checked": 85, "users": ["a", "b"]})
+ )
+ self.assertIn(
+ todo_i18n.t("Every one of them is visible to someone."), texte
+ )
+
+ def test_a_finding_names_the_model_and_the_volume(self):
+ rapport = {
+ "models": [
+ {"model": "dms.file", "rows": 69},
+ {"model": "dms.directory", "rows": 16},
+ ],
+ "checked": 85,
+ "users": ["a"],
+ }
+ texte = "\n".join(check.render(rapport))
+ self.assertIn("dms.file", texte)
+ self.assertIn("69", texte)
+ self.assertIn(
+ todo_i18n.t("The data is there; a global rule hides all of it."),
+ texte,
+ )
+
+ def test_the_biggest_loss_comes_first(self):
+ rapport = {
+ "models": [
+ {"model": "aaa.petit", "rows": 3},
+ {"model": "zzz.gros", "rows": 900},
+ ],
+ "checked": 2,
+ "users": ["a"],
+ }
+ texte = "\n".join(check.render(rapport))
+ self.assertLess(texte.index("zzz.gros"), texte.index("aaa.petit"))
+
+ def test_no_internal_user_is_flagged_not_called_clean(self):
+ # Sans utilisateur, on ne SAIT pas. Dire « tout va bien » serait
+ # un mensonge tranquille.
+ texte = "\n".join(check.render({"no_user": True}))
+ self.assertIn(
+ todo_i18n.t("No internal user to test visibility with."), texte
+ )
+ self.assertNotIn(
+ todo_i18n.t("Every one of them is visible to someone."), texte
+ )
+
+ def test_every_translation_key_exists(self):
+ with io.open(check.__file__, encoding="utf-8") as handle:
+ src = handle.read()
+ for node in ast.walk(ast.parse(src)):
+ if (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "t"
+ and node.args
+ and isinstance(node.args[0], ast.Constant)
+ ):
+ cle = node.args[0].value
+ self.assertTrue(
+ cle in todo_i18n.TRANSLATIONS,
+ f"clé sans traduction : {cle!r}",
+ )
+
+
+class TestTheExitCodes(unittest.TestCase):
+ def setUp(self):
+ from script.odoo.migration import database_cleanup
+
+ self.cleanup = database_cleanup
+ self.vraie = database_cleanup.require_matching_version
+ self.vrai_shell = database_cleanup.run_shell
+ database_cleanup.require_matching_version = lambda base: None
+
+ def tearDown(self):
+ self.cleanup.require_matching_version = self.vraie
+ self.cleanup.run_shell = self.vrai_shell
+
+ def lance(self, rapport):
+ self.cleanup.run_shell = lambda *a, **k: rapport
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ code = check.main(["-d", "db"])
+ return code, tampon.getvalue()
+
+ def test_nothing_hidden_exits_zero(self):
+ code, _ = self.lance({"models": [], "checked": 3, "users": ["a"]})
+ self.assertEqual(code, 0)
+
+ def test_something_hidden_exits_one(self):
+ code, _ = self.lance(
+ {
+ "models": [{"model": "dms.file", "rows": 69}],
+ "checked": 3,
+ "users": ["a"],
+ }
+ )
+ self.assertEqual(code, 1)
+
+ def test_a_shell_error_exits_two(self):
+ # 2 dit « l'outil a échoué », pas « rien trouvé » : les confondre
+ # ferait conclure qu'une migration est saine sans l'avoir vérifiée.
+ code, _ = self.lance({"error": "boom"})
+ self.assertEqual(code, 2)
+
+ def test_a_version_mismatch_stops_before_opening_the_database(self):
+ self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
+ appels = []
+ self.cleanup.run_shell = lambda *a, **k: appels.append(a) or {}
+ with redirect_stdout(io.StringIO()):
+ code = check.main(["-d", "db"])
+ self.assertEqual(code, 2)
+ self.assertEqual(appels, [])
+
+
+class TestTheWiring(unittest.TestCase):
+ RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+
+ def source(self):
+ with io.open(
+ os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"),
+ encoding="utf-8",
+ ) as handle:
+ return handle.read()
+
+ def test_the_detector_runs_at_every_bump(self):
+ src = self.source()
+ self.assertIn("check_hidden_models.py", src)
+
+ def test_the_dms_repair_runs_at_the_13_bump_only(self):
+ # MuK devient OCA DMS à ce palier-là et à aucun autre. Le lancer
+ # partout coûterait un démarrage d'Odoo par palier pour rien.
+ src = self.source()
+ self.assertIn(
+ "if next_version == 13:", src, "le garde de palier a disparu"
+ )
+ debut = src.index("if next_version == 13:")
+ fin = src.index("dms_access_repair.py")
+ self.assertLess(debut, fin)
+ self.assertLess(fin - debut, 700, "le garde de palier s'est éloigné")
+
+ def test_the_repair_is_applied_not_only_reported(self):
+ # Sans --apply il n'écrit rien : câblé sans, il ne réparerait
+ # jamais et la migration resterait cassée en silence.
+ src = self.source()
+ debut = src.index("dms_access_repair.py")
+ self.assertIn("--apply", src[debut : debut + 200])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py
new file mode 100644
index 0000000..169d825
--- /dev/null
+++ b/test/test_check_migration_quality.py
@@ -0,0 +1,1334 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier.
+
+La question qu'on se pose après six paliers n'est pas « a-t-elle fini » —
+le journal le dit — mais « qu'est-ce qui a changé en chemin ». Une
+migration laisse une base PAR PALIER, et elles existent toutes encore : on
+les compare côte à côte plutôt que de rejouer quoi que ce soit.
+
+Le point le plus délicat est le rapprochement des tables renommées. Deux
+garde-fous ont été essayés et rejetés SUR UNE VRAIE MIGRATION avant celui
+qui tient, et un faux rapprochement ne serait pas une coquetterie : il
+ferait DISPARAÎTRE une perte réelle du rapport. D'où la règle qui compte
+plus que tout ici — une perte est toujours listée, jamais retirée.
+"""
+
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(REPO, "script", "analyse"))
+
+from script.analyse import check_migration_quality as quality # noqa: E402
+from script.analyse import check_migration_quality_tui as qtui # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+def snapshot(**override):
+ etat = {
+ "database": "db",
+ "exists": True,
+ "odoo": "13.0",
+ "version": 13,
+ "installed": ["account", "sale"],
+ "module": {"account": "installed", "sale": "installed"},
+ "model": ["account.move", "sale.order"],
+ "table": {"account_move": 100, "sale_order": 20},
+ "model_without_table": [],
+ "view": 2000,
+ "view_cow": 40,
+ "menu": 400,
+ "action": 300,
+ "attachment": 5000,
+ "attachment_stored": 4000,
+ "attachment_missing": 0,
+ "language": 2,
+ }
+ etat.update(override)
+ return etat
+
+
+class Base(unittest.TestCase):
+ def setUp(self):
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+
+class TestFindingTheChain(Base):
+ """Les bases se déduisent de ce que la migration a ÉCRIT.
+
+ Le pilote nomme ses bases de palier « _upgrade_ » et
+ retient sa cible. Redemander ces noms serait redemander ce qu'on sait.
+ """
+
+ PROGRESSION = {
+ "config_database_name": "test_neutralize",
+ "target_odoo_version": "18.0",
+ "state_4_upgrade_odoo_lst": [[], [], [], [], [], []],
+ }
+
+ def test_it_walks_from_the_start_to_the_target(self):
+ lst = quality.chain(self.PROGRESSION)
+ self.assertEqual(
+ [version for version, _db in lst], [12, 13, 14, 15, 16, 17, 18]
+ )
+
+ def test_the_first_one_is_the_origin_database(self):
+ # Le palier 12 n'a pas de base « _upgrade_12 » : c'est la base
+ # d'origine, celle qu'on a restaurée.
+ self.assertEqual(
+ quality.chain(self.PROGRESSION)[0][1], "test_neutralize"
+ )
+
+ def test_the_others_follow_the_naming_of_the_driver(self):
+ noms = [db for _v, db in quality.chain(self.PROGRESSION)]
+ self.assertIn("test_neutralize_upgrade_13", noms)
+ self.assertIn("test_neutralize_upgrade_18", noms)
+
+ def test_a_migration_not_started_yields_the_origin_alone(self):
+ lst = quality.chain(
+ {"config_database_name": "db", "target_odoo_version": "18.0"}
+ )
+ self.assertEqual(lst, [(None, "db")])
+
+ def test_no_progression_yields_nothing(self):
+ self.assertEqual(quality.chain({}), [])
+
+
+class TestWhatIsGainedAndLost(Base):
+ def test_a_module_uninstalled_is_a_loss(self):
+ diff = quality.compare(
+ snapshot(installed=["account", "sale"]),
+ snapshot(installed=["account"]),
+ )
+ self.assertEqual(diff["modules_lost"], ["sale"])
+ self.assertEqual(diff["modules_gained"], [])
+
+ def test_a_model_that_appears_is_a_gain(self):
+ diff = quality.compare(
+ snapshot(model=["account.move"]),
+ snapshot(model=["account.move", "account.edi"]),
+ )
+ self.assertEqual(diff["models_gained"], ["account.edi"])
+
+ def test_a_table_that_empties_is_reported(self):
+ # LE signal : un module en moins se voit, une table qui passe de
+ # cent lignes à zéro ne se voit nulle part.
+ # Une table NEUTRE : le mécanisme se teste sans la carte
+ # sémantique, qui a ses propres tests.
+ diff = quality.compare(
+ snapshot(table={"ma_table": 651}),
+ snapshot(table={"ma_table": 0}),
+ )
+ self.assertEqual(diff["rows_lost"], [("ma_table", 651, 0, None)])
+
+ def test_a_table_that_disappears_counts_as_emptied(self):
+ diff = quality.compare(
+ snapshot(table={"ma_table": 651}), snapshot(table={})
+ )
+ self.assertEqual(diff["rows_lost"], [("ma_table", 651, 0, None)])
+
+ def test_an_empty_table_that_disappears_is_not_a_loss(self):
+ # Rien à perdre : le signaler noierait les vraies pertes.
+ diff = quality.compare(snapshot(table={"vide": 0}), snapshot(table={}))
+ self.assertEqual(diff["rows_lost"], [])
+
+ def test_counts_move_with_their_sign(self):
+ diff = quality.compare(snapshot(view=2000), snapshot(view=3733))
+ self.assertEqual(diff["delta"]["view"], 1733)
+
+ def test_a_missing_database_is_not_comparable(self):
+ diff = quality.compare(snapshot(), {"exists": False})
+ self.assertTrue(diff["unavailable"])
+
+
+class TestNotCryingWolfOnRenames(Base):
+ """Deux garde-fous rejetés sur une VRAIE migration avant celui-ci."""
+
+ def test_the_row_count_alone_is_not_enough(self):
+ # Il accouplait `account_account_tag_account_tax_template_rel` à
+ # `dms_directory` : les deux comptaient sept lignes.
+ self.assertFalse(
+ quality.looks_renamed(
+ "account_account_tag_account_tax_template_rel", "dms_directory"
+ )
+ )
+
+ def test_a_shared_word_is_not_enough_either(self):
+ # « cleanup » ne dit rien, « users » non plus.
+ self.assertFalse(
+ quality.looks_renamed(
+ "cleanup_purge_wizard_menu", "cleanup_create_indexes_line"
+ )
+ )
+ self.assertFalse(
+ quality.looks_renamed(
+ "digest_tip_res_users_rel", "project_allowed_portal_users_rel"
+ )
+ )
+
+ def test_real_renames_are_recognised(self):
+ for un, deux in (
+ ("muk_dms_directory", "dms_directory"),
+ ("website_redirect", "website_rewrite"),
+ (
+ "account_invoice_purchase_order_rel",
+ "account_move_purchase_order_rel",
+ ),
+ ):
+ self.assertTrue(quality.looks_renamed(un, deux), (un, deux))
+
+ def test_a_rename_needs_the_same_row_count(self):
+ # Un nom qui se ressemble mais un compte qui change n'est pas un
+ # renommage : c'est un renommage ET une perte, qu'on ne fusionne pas.
+ diff = quality.compare(
+ snapshot(table={"muk_dms_directory": 10}),
+ snapshot(table={"dms_directory": 4}),
+ )
+ self.assertEqual(diff["renamed"], [])
+
+ def test_a_renamed_table_is_STILL_listed_as_lost(self):
+ """La règle qui compte plus que tout ici.
+
+ Retirer une perte parce qu'on croit à un renommage, c'est faire
+ disparaître du rapport ce qu'on est venu y chercher — et le
+ rapprochement s'est déjà trompé.
+ """
+ diff = quality.compare(
+ snapshot(table={"muk_dms_directory": 7}),
+ snapshot(table={"dms_directory": 7}),
+ )
+ self.assertEqual(
+ diff["renamed"], [("muk_dms_directory", "dms_directory", 7)]
+ )
+ self.assertIn(("muk_dms_directory", 7, 0, None), diff["rows_lost"])
+
+ def test_the_report_says_it_is_only_probable(self):
+ diff = quality.compare(
+ snapshot(table={"muk_dms_directory": 7}),
+ snapshot(table={"dms_directory": 7}),
+ )
+ texte = "\n".join(quality.render_compare(diff, colour=False))
+ self.assertIn("muk_dms_directory", texte)
+ self.assertIn("probably renamed", texte)
+
+
+class TestTheSemanticMap(Base):
+ """« 81 tables ont perdu des lignes » noyait les vraies questions.
+
+ La plus grosse d'entre elles — `ir_translation`, 32 984 lignes — est
+ une refonte voulue par Odoo en 16. Mettre les refontes et les pertes
+ réelles sur le même plan est la façon la plus sûre de ne pas voir les
+ secondes.
+ """
+
+ def perte(
+ self,
+ table,
+ avant_n,
+ apres_n,
+ version="18.0",
+ cible=None,
+ cible_avant=0,
+ cible_apres=0,
+ ):
+ tbl_avant = {table: avant_n}
+ tbl_apres = {} if apres_n == 0 else {table: apres_n}
+ if cible:
+ tbl_avant[cible] = cible_avant
+ tbl_apres[cible] = cible_apres
+ return quality.compare(
+ snapshot(odoo="12.0", table=tbl_avant),
+ snapshot(odoo=version, table=tbl_apres),
+ )
+
+ def test_a_known_merge_is_explained(self):
+ diff = self.perte(
+ "account_invoice",
+ 651,
+ 0,
+ cible="account_move",
+ cible_avant=1371,
+ cible_apres=1812,
+ )
+ connu = [x for x in diff["rows_lost"] if x[0] == "account_invoice"][0]
+ self.assertIsNotNone(connu[3])
+ self.assertEqual(connu[3]["into"], "account_move")
+ self.assertEqual(connu[3]["gained"], 441)
+
+ def test_a_retired_table_is_explained_without_a_target(self):
+ diff = self.perte("ir_translation", 32984, 0)
+ connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0]
+ self.assertIsNotNone(connu[3])
+ self.assertIsNone(connu[3]["into"])
+
+ def test_the_map_does_not_apply_BEFORE_its_version(self):
+ """Une refonte de la 16 n'explique rien d'un palier 12 → 13.
+
+ L'accepter ferait taire une vraie perte sous prétexte que la table
+ porte le nom d'une autre, refondue trois versions plus tard.
+ """
+ diff = self.perte("ir_translation", 32984, 0, version="13.0")
+ connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0]
+ self.assertIsNone(connu[3])
+
+ def test_it_applies_AT_its_version(self):
+ diff = self.perte("ir_translation", 32984, 0, version="16.0")
+ connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0]
+ self.assertIsNotNone(connu[3])
+
+ def test_an_explained_loss_is_STILL_in_the_list(self):
+ # La règle qui vaut plus que tout : expliquer n'est pas cacher.
+ diff = self.perte("ir_translation", 32984, 0)
+ self.assertIn("ir_translation", [x[0] for x in diff["rows_lost"]])
+
+ def test_the_partition_loses_nothing(self):
+ diff = quality.compare(
+ snapshot(
+ odoo="12.0", table={"ir_translation": 100, "ma_table": 50}
+ ),
+ snapshot(odoo="18.0", table={}),
+ )
+ perdues = diff["rows_lost"]
+ ouvertes = [x for x in perdues if not x[3]]
+ connues = [x for x in perdues if x[3]]
+ self.assertEqual(len(perdues), len(ouvertes) + len(connues))
+ self.assertEqual(len(perdues), 2)
+
+ def test_a_merge_whose_target_gained_NOTHING_is_flagged(self):
+ """Le cas qui compte : la carte dit où les données sont allées.
+
+ Si elles n'y sont pas, l'explication ne tient pas — et la classer
+ « attendue » puis passer à autre chose serait exactement l'erreur
+ que la carte devait empêcher.
+ """
+ diff = self.perte(
+ "account_invoice",
+ 651,
+ 0,
+ cible="account_move",
+ cible_avant=1371,
+ cible_apres=1371,
+ )
+ connu = [x for x in diff["rows_lost"] if x[0] == "account_invoice"][0]
+ self.assertEqual(connu[3]["gained"], 0)
+ texte = "\n".join(quality.render_compare(diff, colour=False))
+ self.assertIn("gained nothing", texte)
+
+ def test_the_report_puts_the_unexplained_FIRST(self):
+ diff = quality.compare(
+ snapshot(
+ odoo="12.0", table={"ir_translation": 100, "ma_table": 50}
+ ),
+ snapshot(odoo="18.0", table={}),
+ )
+ texte = "\n".join(quality.render_compare(diff, colour=False))
+ self.assertLess(
+ texte.index(todo_i18n.t("table(s) lost rows, unexplained")),
+ texte.index(todo_i18n.t("table(s) explained by an Odoo change")),
+ )
+
+ def test_needaction_became_notifications_in_15(self):
+ """Vérifiée palier par palier, pas déduite d'un nom qui se ressemble.
+
+ 1269 lignes en 12, 13 et 14 ; la table disparaît en 15 et
+ `mail_notification` en compte exactement 1269. Pas une perdue.
+ C'est ce report à l'unité près qui autorise l'entrée — un nom
+ voisin n'aurait rien prouvé.
+ """
+ diff = self.perte(
+ "mail_message_res_partner_needaction_rel",
+ 1269,
+ 0,
+ version="15.0",
+ cible="mail_notification",
+ cible_avant=0,
+ cible_apres=1269,
+ )
+ connu = diff["rows_lost"][0][3]
+ self.assertIsNotNone(connu)
+ self.assertEqual(connu["into"], "mail_notification")
+ self.assertEqual(connu["gained"], 1269)
+
+ def test_it_is_not_explained_at_the_14_bump(self):
+ # La table est encore pleine en 14 : une entrée qui s'appliquerait
+ # plus tôt masquerait une perte survenue avant la refonte.
+ diff = self.perte(
+ "mail_message_res_partner_needaction_rel",
+ 1269,
+ 0,
+ version="14.0",
+ )
+ self.assertIsNone(diff["rows_lost"][0][3])
+
+ def test_an_unknown_table_stays_unexplained(self):
+ diff = self.perte("ma_table_a_moi", 50, 0)
+ self.assertIsNone(diff["rows_lost"][0][3])
+
+ def test_every_entry_of_the_map_is_complete(self):
+ # Une entrée sans « why » expliquerait sans dire pourquoi.
+ for entree in quality.SEMANTIC_MAP:
+ for cle in ("since", "table", "into", "kind", "why"):
+ self.assertIn(cle, entree, entree)
+ self.assertTrue(entree["why"], entree)
+ self.assertIn(
+ entree["kind"], ("merged", "renamed", "retired", "pruned")
+ )
+
+ def test_a_pruned_entry_never_claims_a_destination(self):
+ # « pruned » dit que les lignes ne continuent NULLE PART. Lui donner
+ # un « into » ferait passer une perte réelle pour une fusion.
+ for entree in quality.SEMANTIC_MAP:
+ if entree["kind"] == "pruned":
+ self.assertIsNone(entree["into"], entree)
+
+ def test_properties_became_jsonb_columns_in_18(self):
+ """La table ne se vide pas : elle GROSSIT, puis disparaît d'un coup.
+
+ 211 lignes en 12, 457 en 17, table absente en 18. Les champs
+ qu'elle portait sont des colonnes jsonb en 18 — vérifié sur
+ res_partner.property_payment_term_id.
+ """
+ diff = self.perte("ir_property", 457, 0, version="18.0")
+ connu = diff["rows_lost"][0][3]
+ self.assertIsNotNone(connu)
+ self.assertEqual(connu["kind"], "retired")
+ self.assertIsNone(connu["into"])
+
+ def test_properties_are_not_explained_at_the_17_bump(self):
+ # En 17 la table est à son maximum (457). Dater l'entrée plus tôt
+ # ferait passer pour attendue une perte qui ne l'est pas.
+ diff = self.perte("ir_property", 273, 100, version="17.0")
+ self.assertIsNone(diff["rows_lost"][0][3])
+
+ def test_tracking_values_were_pruned_in_14(self):
+ """Le champ suivi passe de varchar à clé étrangère au palier 14.
+
+ En 14 aucune ligne n'a de clé nulle ni cassée : ce qui ne se
+ résolvait pas a été supprimé. La table SURVIT — 13833 lignes — donc
+ l'explication ne doit pas prétendre qu'elle a disparu.
+ """
+ diff = self.perte("mail_tracking_value", 16169, 13833, version="14.0")
+ table, avant, apres, connu = diff["rows_lost"][0]
+ self.assertEqual((avant, apres), (16169, 13833))
+ self.assertIsNotNone(connu)
+ self.assertEqual(connu["kind"], "pruned")
+
+ def test_tracking_values_are_not_explained_at_the_13_bump(self):
+ # 16167 en 12, 16169 en 13 : rien n'a encore été élagué.
+ diff = self.perte("mail_tracking_value", 16167, 10000, version="13.0")
+ self.assertIsNone(diff["rows_lost"][0][3])
+
+ def test_a_pruned_loss_is_not_rendered_as_retired(self):
+ # « retirée de la base » serait faux : la table est toujours là.
+ diff = self.perte("mail_tracking_value", 16169, 13833, version="14.0")
+ texte = "\n".join(quality.render_compare(diff, False, 8))
+ self.assertIn("mail_tracking_value", texte)
+ self.assertNotIn(todo_i18n.t("retired from the database"), texte)
+ self.assertIn(todo_i18n.t("rows dropped, the table remains"), texte)
+
+ def test_the_column_counts_only_what_needs_an_answer(self):
+ # Afficher 81 quand 14 sont des refontes voulues ferait fuir le
+ # lecteur du seul chiffre qui demande une réponse.
+ lst = [
+ snapshot(
+ odoo="12.0", table={"ir_translation": 100, "ma_table": 50}
+ ),
+ snapshot(odoo="18.0", table={}),
+ ]
+ self.assertEqual(qtui.rows(lst)[1]["detail"], "1")
+
+
+class TestTheOverallReport(Base):
+ def test_it_compares_the_ENDS_not_the_sum_of_steps(self):
+ """Un module retiré en 15 puis remis en 17 n'a rien perdu.
+
+ L'addition des comparaisons deux à deux le compterait deux fois,
+ une en perte et une en gain, et le bilan mentirait dans les deux
+ sens à la fois.
+ """
+ lst = [
+ snapshot(odoo="12.0", installed=["a", "b"]),
+ snapshot(odoo="15.0", installed=["a"]),
+ snapshot(odoo="18.0", installed=["a", "b"]),
+ ]
+ bilan = quality.overall(lst)
+ self.assertEqual(bilan["modules_lost"], [])
+ self.assertEqual(bilan["modules_gained"], [])
+
+ def test_it_needs_two_databases(self):
+ self.assertTrue(quality.overall([snapshot()])["unavailable"])
+
+ def test_missing_databases_are_skipped_not_fatal(self):
+ lst = [
+ snapshot(odoo="12.0", installed=["a"]),
+ {"database": "absente", "exists": False},
+ snapshot(odoo="18.0", installed=["a", "b"]),
+ ]
+ self.assertEqual(quality.overall(lst)["modules_gained"], ["b"])
+
+
+class TestTheReportItself(Base):
+ def test_every_step_is_listed(self):
+ texte = quality.render_text(
+ [
+ snapshot(odoo="12.0", database="a"),
+ snapshot(odoo="13.0", database="b"),
+ ],
+ colour=False,
+ )
+ self.assertIn("12.0", texte)
+ self.assertIn("13.0", texte)
+
+ def test_a_missing_database_is_named_not_hidden(self):
+ texte = quality.render_text(
+ [snapshot(), {"database": "absente", "exists": False}],
+ colour=False,
+ )
+ self.assertIn("absente", texte)
+ self.assertIn("not found", texte)
+
+ def test_missing_attachment_files_are_surfaced(self):
+ # La trouvaille faite à la main sur une vraie migration : 254
+ # fichiers absents du filestore, que rien ne signalait.
+ texte = quality.render_text(
+ [snapshot(attachment_missing=254)], colour=False
+ )
+ self.assertIn("254", texte)
+
+ def test_it_ends_with_the_start_to_finish_comparison(self):
+ texte = quality.render_text(
+ [snapshot(odoo="12.0"), snapshot(odoo="18.0")], colour=False
+ )
+ self.assertIn("From start to finish", texte)
+ self.assertLess(texte.index("12.0"), texte.index("From start"))
+
+
+class TestTheStatisticsCarryTheirDelta(Base):
+ """Un chiffre seul ne dit rien.
+
+ « 2283 vues » est un nombre ; « +61 » est une information. C'est
+ l'écart qu'on lit, pas la valeur.
+ """
+
+ def test_each_figure_gets_its_change(self):
+ lignes = qtui.statistics(
+ snapshot(view=2283, installed=["a"]),
+ snapshot(view=2222, installed=["a", "b"]),
+ )
+ par_nom = {libelle: ecart for libelle, _v, ecart in lignes}
+ self.assertEqual(par_nom["views"], 61)
+ self.assertEqual(par_nom["modules"], -1)
+
+ def test_the_first_step_has_NO_delta(self):
+ # Inventer un écart de zéro laisserait croire à une comparaison
+ # qui n'existe pas : il n'y a rien avant le premier palier.
+ lignes = qtui.statistics(snapshot(), None)
+ self.assertTrue(all(ecart is None for _l, _v, ecart in lignes))
+
+ def test_an_unchanged_figure_shows_zero_not_nothing(self):
+ # Zéro est une réponse : « rien n'a bougé » se distingue de « on
+ # ne sait pas ».
+ lignes = qtui.statistics(snapshot(view=100), snapshot(view=100))
+ par_nom = {libelle: ecart for libelle, _v, ecart in lignes}
+ self.assertEqual(par_nom["views"], 0)
+
+ def test_every_figure_of_the_pane_is_covered(self):
+ libelles = [
+ libelle
+ for libelle, _v, _e in qtui.statistics(snapshot(), snapshot())
+ ]
+ for attendu in ("modules", "models", "views", "menus", "attachments"):
+ self.assertIn(attendu, libelles)
+
+ def test_the_pane_prints_the_sign(self):
+ row = {
+ "kind": "step",
+ "data": snapshot(view=2283),
+ "previous": snapshot(view=2222),
+ "diff": None,
+ }
+ texte = qtui.pane_text([], row)
+ self.assertIn("+61", texte)
+
+
+class TestListingTheMissingFiles(Base):
+ """« 254 fichiers absents » ne dit pas lesquels.
+
+ Le groupement tranche : deux cent trente-quatre drapeaux de pays sont
+ des images livrées par un module, qu'une mise à jour restaure. Une
+ pièce jointe d'événement de 255 ko, non. La liste brute mettait les
+ deux sur le même plan.
+ """
+
+ DETAIL = [
+ {
+ "store_fname": "aa/1",
+ "model": "res.country",
+ "field": "image",
+ "res_id": "1",
+ "mimetype": "image/png",
+ "size": 100,
+ "name": "fr",
+ },
+ {
+ "store_fname": "aa/2",
+ "model": "res.country",
+ "field": "image",
+ "res_id": "2",
+ "mimetype": "image/png",
+ "size": 100,
+ "name": "ca",
+ },
+ {
+ "store_fname": "aa/4",
+ "model": "res.country",
+ "field": "image_128",
+ "res_id": "1",
+ "mimetype": "image/png",
+ "size": 50,
+ "name": "fr128",
+ },
+ {
+ "store_fname": "bb/3",
+ "model": "calendar.event",
+ "field": "-",
+ "res_id": "1",
+ "mimetype": "image/png",
+ "size": 255603,
+ "name": "photo.png",
+ },
+ ]
+
+ def render(self, etat=None, **kw):
+ original = quality.missing_detail
+ quality.missing_detail = lambda db, lst, limit=400: self.DETAIL
+ self.addCleanup(setattr, quality, "missing_detail", original)
+ return quality.render_missing(
+ etat
+ or snapshot(
+ attachment_missing=3,
+ attachment_missing_list=["aa/1", "aa/2", "bb/3"],
+ ),
+ **kw,
+ )
+
+ def test_nothing_missing_says_so_plainly(self):
+ texte = quality.render_missing(snapshot(attachment_missing=0))
+ self.assertIn("every attachment file is present", texte)
+
+ def test_the_grouping_comes_first(self):
+ texte = self.render()
+ self.assertLess(
+ texte.index("by model and field"), texte.index("one by one")
+ )
+
+ def test_the_grouping_counts_by_model_AND_field(self):
+ """Le champ est le renseignement le plus utile du lot.
+
+ Il dit QUEL champ a perdu son image : l'`image_1920` d'un pays n'a
+ pas le même poids qu'une pièce jointe de facture. Grouper sur le
+ seul modèle fondrait `image` et `image_128` en une ligne, et l'on
+ perdrait exactement ce qu'on venait chercher.
+ """
+ entete = self.render().split("one by one")[0]
+ lignes = [
+ ligne for ligne in entete.splitlines() if "res.country" in ligne
+ ]
+ self.assertEqual(len(lignes), 2, entete)
+ self.assertTrue(any("image_128" in ligne for ligne in lignes))
+
+ def test_the_loudest_group_comes_first(self):
+ texte = self.render().split("one by one")[0]
+ self.assertLess(texte.index("res.country"), texte.index("calendar"))
+
+ def test_each_file_names_its_record_and_its_path(self):
+ texte = self.render()
+ self.assertIn("calendar.event#1", texte)
+ self.assertIn("bb/3", texte)
+ self.assertIn("photo.png", texte)
+
+ def test_a_long_list_is_cut_and_SAYS_so(self):
+ texte = self.render(limit=1)
+ self.assertIn("more", texte)
+
+ def test_unreadable_metadata_is_admitted(self):
+ original = quality.missing_detail
+ quality.missing_detail = lambda db, lst, limit=400: []
+ self.addCleanup(setattr, quality, "missing_detail", original)
+ texte = quality.render_missing(
+ snapshot(attachment_missing=3, attachment_missing_list=["a"])
+ )
+ self.assertIn("Could not read", texte)
+
+ def test_the_metadata_is_read_ONLY_on_demand(self):
+ # Une requête de plus par base allongerait un parcours qui tient
+ # en quatre secondes, pour ce qu'on ne regarde qu'en le demandant.
+ import inspect
+
+ self.assertNotIn("missing_detail", inspect.getsource(quality.inspect))
+ self.assertIn(
+ "missing_detail", inspect.getsource(quality.render_missing)
+ )
+
+ def test_the_names_are_kept_but_bounded(self):
+ # Une base peut en aligner des dizaines de milliers ; l'écran n'en
+ # montrera jamais tant, et les garder toutes coûterait pour rien.
+ import inspect
+
+ source = inspect.getsource(quality.inspect)
+ self.assertIn("attachment_missing_list", source)
+ self.assertIn("MAX_MISSING", source)
+
+ def test_a_quote_in_a_filename_cannot_break_the_query(self):
+ vu = {}
+ original = quality.run_psql
+ quality.run_psql = lambda db, sql: vu.setdefault("sql", sql) and []
+ self.addCleanup(setattr, quality, "run_psql", original)
+ quality.missing_detail("db", ["aa/o'brien"])
+ self.assertIn("o''brien", vu["sql"])
+
+
+class TestTheMissingFilesButton(Base):
+ def test_m_is_bound(self):
+ app = qtui.build_app([snapshot()])
+ touches = {
+ touche
+ for entree in app.BINDINGS
+ for touche in entree[0].split(",")
+ }
+ self.assertIn("m", touches)
+
+ def test_the_action_exists(self):
+ app = qtui.build_app([snapshot()])
+ self.assertTrue(hasattr(app, "action_toggle_missing"))
+
+ def test_the_pane_switches_to_the_list(self):
+ original = quality.missing_detail
+ quality.missing_detail = lambda db, lst, limit=400: []
+ self.addCleanup(setattr, quality, "missing_detail", original)
+ row = {
+ "kind": "step",
+ "data": snapshot(
+ attachment_missing=3, attachment_missing_list=["a"]
+ ),
+ "previous": None,
+ "diff": None,
+ }
+ chiffres = qtui.pane_text([], row, mode=None)
+ liste = qtui.pane_text([], row, mode="missing")
+ self.assertIn("modules", chiffres)
+ self.assertNotIn("modules", liste)
+
+ def test_the_pane_tells_you_the_key_exists(self):
+ # Une touche que rien n'annonce est une touche que personne ne
+ # presse.
+ row = {
+ "kind": "step",
+ "data": snapshot(attachment_missing=254),
+ "previous": None,
+ "diff": None,
+ }
+ self.assertIn("press m", qtui.pane_text([], row))
+
+
+class TestTheMenuEntryLooksLikeItsNeighbours(Base):
+ """Une entrée sans icône au milieu d'un menu qui en a partout se lit
+ comme une entrée inachevée."""
+
+ ENTREES = (
+ "Structure",
+ "Tables and database size",
+ "Customisation",
+ "Customised views, website copies included",
+ "Studio and hand-made x_ fields",
+ "Migration",
+ "Quality of a migration, step by step",
+ )
+
+ def libelle(self, cle, langue):
+ from script.todo import todo_i18n
+
+ return todo_i18n.TRANSLATIONS[cle][langue]
+
+ def test_every_entry_carries_an_icon(self):
+ import unicodedata
+
+ for cle in self.ENTREES:
+ for langue in ("fr", "en"):
+ premier = self.libelle(cle, langue)[0]
+ self.assertNotEqual(
+ unicodedata.category(premier)[0],
+ "L",
+ f"{cle} ({langue}) : pas d'icône",
+ )
+
+ def test_the_spacing_follows_the_width_of_the_icon(self):
+ """Convention du menu, découverte en la lisant plutôt qu'écrite.
+
+ Une icône de largeur « neutre » — 🗄, 🖼 — s'affiche sur une
+ colonne dans un terminal et prend DEUX espaces pour s'aligner ;
+ une icône large — 📏, 📐 — en prend un seul. Se tromper décale la
+ ligne, et rien ne le dit avant de l'avoir sous les yeux.
+ """
+ import unicodedata
+
+ for cle in self.ENTREES:
+ for langue in ("fr", "en"):
+ texte = self.libelle(cle, langue)
+ attendu = (
+ 2 if unicodedata.east_asian_width(texte[0]) == "N" else 1
+ )
+ espaces = len(texte[1:]) - len(texte[1:].lstrip(" "))
+ self.assertEqual(
+ espaces, attendu, f"{cle} ({langue}) : {texte!r}"
+ )
+
+ def test_the_quality_entry_wears_the_report_s_own_symbol(self):
+ # Le même symbole pour la même chose. Un libellé de MENU porte
+ # son icône dans la traduction — elle se traduit avec lui.
+ self.assertTrue(
+ self.libelle(
+ "Quality of a migration, step by step", "fr"
+ ).startswith("📐")
+ )
+ # Le titre du rapport, lui, porte son symbole dans le CODE de
+ # rendu, à côté de ses voisins 📍, 🧪 et 🔷 : deux conventions
+ # distinctes, chacune tenable dans son contexte, et l'on vérifie
+ # chacune là où elle vit.
+ import inspect
+
+ self.assertIn("📐", inspect.getsource(quality.render_text))
+
+
+class TestAttackingTheListFromTheTop(Base):
+ """Cinquante-sept pertes se parcourent par le haut, pas par ordre
+ alphabétique : la plus grosse est celle qu'on veut voir en premier."""
+
+ def test_the_biggest_loss_comes_first(self):
+ # Des noms dont l'ordre alphabétique CONTREDIT celui des volumes :
+ # sinon un tri par nom passerait pour un tri par volume, et le
+ # test ne prouverait rien.
+ diff = quality.compare(
+ snapshot(
+ odoo="12.0",
+ table={
+ "aaa_petite": 10,
+ "zzz_enorme": 5000,
+ "mmm_moyenne": 100,
+ },
+ ),
+ snapshot(odoo="18.0", table={}),
+ )
+ self.assertEqual(
+ [x[0] for x in diff["rows_lost"]],
+ ["zzz_enorme", "mmm_moyenne", "aaa_petite"],
+ )
+
+ def test_it_sorts_on_what_was_LOST_not_on_what_was_there(self):
+ # Une table de dix mille lignes qui en perd deux compte moins
+ # qu'une table de mille qui se vide.
+ diff = quality.compare(
+ snapshot(odoo="12.0", table={"grosse": 10000, "vidée": 1000}),
+ snapshot(odoo="18.0", table={"grosse": 9998}),
+ )
+ self.assertEqual(
+ [x[0] for x in diff["rows_lost"]], ["vidée", "grosse"]
+ )
+
+
+class TestTheFullLists(Base):
+ """Le résumé coupe à huit entrées, et il a raison.
+
+ Mais quand on cherche si UN module précis a survécu, la liste
+ tronquée ne répond pas — et c'est justement là qu'on en a besoin.
+ """
+
+ def diff(self):
+ return quality.compare(
+ snapshot(
+ odoo="12.0",
+ installed=["a", "b"],
+ model=["m.un", "m.deux"],
+ field=["m.un.x", "m.un.y"],
+ cow=["site.vue"],
+ table={"t": 10},
+ ),
+ snapshot(
+ odoo="18.0",
+ installed=["a", "c"],
+ model=["m.un", "m.trois"],
+ field=["m.un.x", "m.trois.z"],
+ cow=["site.autre"],
+ table={},
+ ),
+ )
+
+ def test_fields_are_inventoried(self):
+ # Un champ perdu est une colonne de données perdue — plus fin
+ # qu'un modèle, qui peut survivre vidé de la moitié des siens.
+ diff = self.diff()
+ self.assertEqual(diff["fields_lost"], ["m.un.y"])
+ self.assertEqual(diff["fields_gained"], ["m.trois.z"])
+
+ def test_cow_copies_are_inventoried_by_key(self):
+ # C'est la clé qu'on réinitialise, et par elle qu'on les retrouve.
+ diff = self.diff()
+ self.assertEqual(diff["cow_lost"], ["site.vue"])
+ self.assertEqual(diff["cow_gained"], ["site.autre"])
+
+ def test_every_category_renders_in_full(self):
+ for categorie in quality.DETAILS:
+ texte = quality.render_detail(self.diff(), categorie)
+ self.assertTrue(texte.strip(), categorie)
+
+ def test_nothing_is_truncated(self):
+ # C'est tout le propos : le résumé coupe, la liste entière non.
+ diff = quality.compare(
+ snapshot(odoo="12.0", model=[f"m.{i}" for i in range(200)]),
+ snapshot(odoo="18.0", model=[]),
+ )
+ texte = quality.render_detail(diff, "models")
+ self.assertIn("m.199", texte)
+ self.assertNotIn("…", texte)
+
+ def test_the_table_list_keeps_the_volume_order(self):
+ diff = quality.compare(
+ snapshot(odoo="12.0", table={"petite": 1, "enorme": 900}),
+ snapshot(odoo="18.0", table={}),
+ )
+ texte = quality.render_detail(diff, "tables")
+ self.assertLess(texte.index("enorme"), texte.index("petite"))
+
+ def test_an_unavailable_comparison_says_so(self):
+ self.assertIn("not comparable", quality.render_detail(None, "models"))
+
+ def test_the_headings_carry_no_participle(self):
+ """« 28 copies COW perdus » ne s'accorde pas, et ne se traduit pas.
+
+ Le signe porte déjà le sens ; un participe devrait s'accorder avec
+ une catégorie dont le genre change d'une langue à l'autre.
+ """
+ texte = quality.render_detail(self.diff(), "cow")
+ self.assertIn("−", texte)
+ self.assertNotIn("perdus", texte)
+
+
+class TestTheInventoryItself(Base):
+ """La comparaison était testée, la COLLECTE ne l'était pas.
+
+ Vider l'inventaire des champs dans `inspect` passait inaperçu, parce
+ que tous les tests fabriquaient leurs instantanés à la main. Un test
+ qui ne touche jamais le code réel ne garde rien.
+ """
+
+ def repondre(self, sql):
+ if "ir_model_fields" in sql:
+ return [["res.partner.name"], ["res.partner.email"]]
+ if "website_id IS NOT NULL" in sql:
+ return [["site.vue"]]
+ if "FROM ir_model " in sql or sql.strip().endswith("ORDER BY model"):
+ return [["res.partner"]]
+ if "ir_module_module" in sql:
+ return [["base", "installed"]]
+ if "latest_version" in sql:
+ return [["odoo", "18.0.1.3"]]
+ return []
+
+ def inspecter(self):
+ for nom, remplacant in (
+ ("run_psql", lambda db, sql: self.repondre(sql)),
+ ("missing_files", lambda db, lst: []),
+ ("table_counts", lambda db: {}),
+ ):
+ original = getattr(quality, nom)
+ setattr(quality, nom, remplacant)
+ self.addCleanup(setattr, quality, nom, original)
+ return quality.inspect("db")
+
+ def test_the_fields_are_collected(self):
+ self.assertEqual(
+ self.inspecter()["field"],
+ ["res.partner.email", "res.partner.name"],
+ )
+
+ def test_the_cow_copies_are_collected(self):
+ self.assertEqual(self.inspecter()["cow"], ["site.vue"])
+
+ def test_a_cow_copy_without_a_key_is_still_named(self):
+ # Une copie sans clé existe quand même ; la taire ferait un
+ # inventaire qui ment sur son propre compte.
+ import inspect
+
+ self.assertIn("'id:' || id::text", inspect.getsource(quality.inspect))
+
+
+class TestTheDetailButton(Base):
+ def test_d_is_bound(self):
+ app = qtui.build_app([snapshot()])
+ touches = {
+ touche
+ for entree in app.BINDINGS
+ for touche in entree[0].split(",")
+ }
+ self.assertIn("d", touches)
+
+ def test_the_cycle_visits_every_category_and_comes_back(self):
+ suite = (None,) + quality.DETAILS
+ self.assertEqual(len(suite), 6)
+ self.assertEqual(suite[len(suite) % len(suite)], None)
+
+ def test_ONE_mode_not_two_flags(self):
+ """« fichiers absents » et « liste des modèles » ne peuvent pas
+ être vrais en même temps ; deux booléens laissaient écrire cet
+ état impossible."""
+ import inspect
+
+ source = inspect.getsource(qtui.build_app)
+ self.assertIn("self.mode", source)
+ self.assertNotIn("self.show_missing", source)
+
+ def test_every_mode_has_a_name(self):
+ # Un panneau qui change sans dire pourquoi se lit comme un écran
+ # cassé, et avec sept modes on ne devine pas.
+ noms = {
+ qtui.mode_label(m) for m in (None, "missing") + quality.DETAILS
+ }
+ self.assertEqual(len(noms), 7)
+ for nom in noms:
+ self.assertTrue(nom.strip())
+
+ def test_the_name_is_shown_where_it_stays_visible(self):
+ import inspect
+
+ source = inspect.getsource(qtui.build_app)
+ self.assertIn("self.sub_title = mode_label", source)
+
+
+class TestItNeverWrites(Base):
+ """Les bases de palier sont parfois la seule copie d'un état."""
+
+ def test_the_connection_is_read_only_at_the_server(self):
+ import inspect
+
+ source = inspect.getsource(quality.run_psql)
+ self.assertIn("default_transaction_read_only=on", source)
+
+ def test_no_odoo_is_started(self):
+ # Six démarrages coûteraient une heure ET écriraient dans les
+ # bases. L'inspection en SQL prend moins d'une demi-seconde.
+ import inspect
+
+ source = inspect.getsource(quality)
+ for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
+ self.assertNotIn(interdit, source, interdit)
+
+
+class TestTheFullScreen(Base):
+ def test_one_row_per_step_plus_the_overall(self):
+ lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
+ lignes = qtui.rows(lst)
+ self.assertEqual(
+ [x["kind"] for x in lignes], ["step", "step", "overall"]
+ )
+
+ def test_the_overall_comes_LAST(self):
+ # On descend la liste comme on a vécu la migration ; « qu'en
+ # reste-t-il » se pose une fois le chemin vu.
+ lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
+ self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall")
+
+ def test_a_missing_database_gets_its_own_row(self):
+ lignes = qtui.rows(
+ [snapshot(), {"database": "absente", "exists": False}]
+ )
+ self.assertIn("missing", [x["kind"] for x in lignes])
+
+ def test_the_column_counts_the_lost_tables(self):
+ lst = [
+ snapshot(odoo="12.0", table={"t": 100}),
+ snapshot(odoo="13.0", table={"t": 0}),
+ ]
+ self.assertEqual(qtui.rows(lst)[1]["detail"], "1")
+
+ def test_a_step_that_lost_nothing_leaves_it_empty(self):
+ lst = [snapshot(odoo="12.0"), snapshot(odoo="13.0")]
+ self.assertEqual(qtui.rows(lst)[1]["detail"], "")
+
+ def test_the_pane_uses_the_same_comparison_as_the_text(self):
+ import inspect
+
+ self.assertIn(
+ "quality.render_compare", inspect.getsource(qtui.pane_text)
+ )
+
+ def test_a_pipe_is_explained_rather_than_silent(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ self.assertFalse(qtui.run_tui([snapshot()]))
+ self.assertTrue(out.getvalue().strip())
+
+ def test_nothing_to_show_stays_silent(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ self.assertFalse(qtui.run_tui([]))
+ self.assertEqual(out.getvalue(), "")
+
+
+class TestWhereItIsOffered(Base):
+ def test_the_analyse_menu_offers_it(self):
+ chemin = os.path.join(REPO, "script", "todo", "todo.py")
+ with open(chemin) as handle:
+ texte = handle.read()
+ self.assertIn("Quality of a migration, step by step", texte)
+ self.assertIn("execute_analyse_migration_quality", texte)
+
+ def test_the_existing_menu_numbers_did_not_move(self):
+ # Quelqu'un connaît « 1 », « 2 », « 3 » : les décaler pour insérer
+ # une entrée au milieu se paierait à chaque usage.
+ import inspect
+
+ from script.todo.todo import TODO
+
+ source = inspect.getsource(TODO.prompt_execute_analyse)
+ self.assertLess(
+ source.index("Tables and database size"),
+ source.index("Quality of a migration"),
+ )
+
+ def test_the_state_screen_offers_it_too(self):
+ import inspect
+
+ from script.todo import migration_status_tui as stui
+
+ source = inspect.getsource(stui.build_app)
+ self.assertIn('"k", "quality"', source)
+ self.assertIn("check_migration_quality", source)
+
+ def test_it_hands_the_terminal_over(self):
+ # Deux applications Textual ne peuvent pas peindre le même écran.
+ import inspect
+
+ from script.todo import migration_status_tui as stui
+
+ source = inspect.getsource(stui.build_app)
+ debut = source.index("def action_quality")
+ self.assertIn("self.suspend()", source[debut : debut + 1400])
+
+ def test_it_opens_it_in_its_OWN_process(self):
+ """Le défaut que « suspend est appelé » ne suffisait pas à attraper.
+
+ `suspend()` rend le terminal mais n'arrête PAS la boucle asyncio.
+ `app.run()` appelle `asyncio.run()`, qui refuse de tourner dans une
+ boucle déjà en cours : « asyncio.run() cannot be called from a
+ running event loop ». Un sous-processus a sa propre boucle.
+ """
+ import inspect
+
+ from script.todo import migration_status_tui as stui
+
+ source = inspect.getsource(stui.build_app)
+ debut = source.index("def action_quality")
+ fenetre = source[debut : debut + 1400]
+ self.assertIn("subprocess.call", fenetre)
+ self.assertNotIn("run_quality(", fenetre)
+
+
+class TestItRefusesToNestItself(Base):
+ """Le filet de sécurité, pour qui rappellerait `run_tui` de l'intérieur.
+
+ Une trace de quarante lignes n'apprend rien ; une phrase qui dit
+ d'ouvrir un processus à part, si.
+ """
+
+ def test_no_loop_running_is_the_normal_case(self):
+ self.assertFalse(qtui.in_event_loop())
+
+ def test_a_running_loop_is_detected(self):
+ import asyncio
+
+ async def dedans():
+ return qtui.in_event_loop()
+
+ self.assertTrue(asyncio.run(dedans()))
+
+ def test_run_tui_consults_it_before_starting(self):
+ import inspect
+
+ source = inspect.getsource(qtui.run_tui)
+ self.assertLess(
+ source.index("in_event_loop()"), source.index("app.run()")
+ )
+
+ def test_the_refusal_says_what_to_do_instead(self):
+ import inspect
+
+ source = inspect.getsource(qtui.run_tui)
+ self.assertIn("its own", source)
+
+
+class TestTheOpenUpgradeOverlay(unittest.TestCase):
+ """Le théorique posé sur le pratique.
+
+ Le rapport comptait des « + » et des « − ». Un champ devenu calculé
+ n'est ni l'un ni l'autre : il perd sa colonne et garde son existence.
+ Sans troisième état, on alarmait à chaque palier.
+ """
+
+ INDEX = {
+ "models_new": {"product.combo"},
+ "models_obsolete": {"account.unreconcile"},
+ "models_renamed": {"pos.combo": "product.combo"},
+ "fields_new": set(),
+ "fields_del": {"account.journal.secure_sequence_id"},
+ "fields_unstored": {"account.account.code"},
+ "fields_stored": set(),
+ "fields_moved": {"pos.combo.base_price": "product"},
+ "fields_company_dependent": {"account.cash.rounding.loss_account_id"},
+ "fields_other": {},
+ "xml_new": 0,
+ "xml_del": 0,
+ "modules": 402,
+ }
+
+ def setUp(self):
+ quality._CACHE_DECLARE[18] = self.INDEX
+ from script.analyse import openupgrade_analysis as oa
+
+ self.vrai = oa.analysed_modules
+ oa.analysed_modules = lambda version, root=None: {"account", "sale"}
+ self.oa = oa
+
+ def tearDown(self):
+ quality._CACHE_DECLARE.pop(18, None)
+ self.oa.analysed_modules = self.vrai
+
+ def pose(self, modeles=(), champs=(), origines=None):
+ return quality.overlay_declared(
+ 18, list(modeles), list(champs), origines
+ )
+
+ def test_a_declared_obsolete_model_is_not_a_finding(self):
+ d = self.pose(modeles=["account.unreconcile"])
+ self.assertEqual(d["models"]["obsolete"], ["account.unreconcile"])
+ self.assertEqual(d["models"]["undeclared"], [])
+
+ def test_a_renamed_model_names_its_destination(self):
+ d = self.pose(modeles=["pos.combo"])
+ self.assertEqual(
+ d["models"]["renamed"], [("pos.combo", "product.combo")]
+ )
+
+ def test_an_unknown_model_stays_undeclared(self):
+ d = self.pose(modeles=["ma.bebelle"])
+ self.assertEqual(d["models"]["undeclared"], ["ma.bebelle"])
+
+ def test_a_field_that_became_computed_is_its_own_category(self):
+ # NI perte NI gain : le champ demeure, la colonne non. C'est tout
+ # l'objet de l'ajout.
+ d = self.pose(champs=["account.account.code"])
+ self.assertEqual(d["fields"]["unstored"], ["account.account.code"])
+ self.assertEqual(d["fields"]["undeclared"], [])
+
+ def test_declared_removals_and_moves_land_apart(self):
+ d = self.pose(
+ champs=[
+ "account.journal.secure_sequence_id",
+ "pos.combo.base_price",
+ "account.cash.rounding.loss_account_id",
+ ]
+ )
+ self.assertEqual(
+ d["fields"]["del"], ["account.journal.secure_sequence_id"]
+ )
+ self.assertEqual(
+ d["fields"]["moved"], [("pos.combo.base_price", "product")]
+ )
+ self.assertEqual(
+ d["fields"]["company_dependent"],
+ ["account.cash.rounding.loss_account_id"],
+ )
+
+ def test_a_field_whose_model_vanished_is_not_counted_again(self):
+ # 544 champs pour un seul modèle disparu, mesuré sur un vrai
+ # palier : listés un par un, ils cachaient les vingt vraies
+ # trouvailles.
+ d = self.pose(
+ modeles=["account.unreconcile"],
+ champs=["account.unreconcile.name"],
+ )
+ self.assertEqual(
+ d["fields"]["model_gone"], ["account.unreconcile.name"]
+ )
+ self.assertEqual(d["fields"]["undeclared"], [])
+
+ def test_a_field_of_an_unanalysed_module_is_not_accused(self):
+ # OpenUpgrade n'analyse que le cœur : dire « non déclaré » d'un
+ # champ OCA est vrai à la lettre et faux en esprit.
+ d = self.pose(
+ champs=["x.mon_champ"],
+ origines={"x.mon_champ": ["mon_module_oca"]},
+ )
+ self.assertEqual(d["fields"]["not_analysed"], ["x.mon_champ"])
+ self.assertEqual(d["fields"]["undeclared"], [])
+
+ def test_a_core_field_with_no_declaration_SURVIVES_as_undeclared(self):
+ # Le signal ne doit pas se faire absorber par les catégories
+ # rassurantes : c'est la seule ligne qui demande un examen.
+ d = self.pose(
+ champs=["account.account.mystere"],
+ origines={"account.account.mystere": ["account"]},
+ )
+ self.assertEqual(
+ d["fields"]["undeclared"], ["account.account.mystere"]
+ )
+
+ def test_no_analysis_is_said_not_shown_as_zero(self):
+ # « 0 déclaré » et « analyse absente » se ressemblent à l'œil.
+ quality._CACHE_DECLARE[18] = dict(self.INDEX, modules=0)
+ d = self.pose(modeles=["x.y"])
+ self.assertFalse(d["available"])
+ self.assertEqual(d["reason"], "missing")
+ texte = "\n".join(quality.render_declared(d, False))
+ self.assertIn(
+ todo_i18n.t("No OpenUpgrade analysis for this step."), texte
+ )
+
+ def test_an_unknown_version_declares_nothing_quietly(self):
+ d = quality.overlay_declared(None, ["x.y"], [])
+ self.assertFalse(d["available"])
+ self.assertEqual(quality.render_declared(d, False), [])
+
+
+class TestGroupingByFieldName(unittest.TestCase):
+ def test_one_mixin_field_is_one_finding(self):
+ # `__last_update` s'est compté 391 fois sur un vrai palier : c'est
+ # UN changement.
+ groupes = quality.group_by_field_name(
+ [f"modele{i}.zz_last_update" for i in range(391)]
+ + ["account.account.aa_autre"]
+ )
+ self.assertEqual(groupes[0][0], "zz_last_update")
+ self.assertEqual(groupes[0][1], 391)
+
+ def test_the_most_widespread_comes_first(self):
+ groupes = quality.group_by_field_name(
+ ["a.zzz", "b.zzz", "c.zzz", "d.aaa"]
+ )
+ self.assertEqual([nom for nom, _n, _e in groupes], ["zzz", "aaa"])
+
+ def test_a_lone_field_keeps_its_full_key_as_example(self):
+ groupes = quality.group_by_field_name(["account.account.seul"])
+ self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py
new file mode 100755
index 0000000..f301e5a
--- /dev/null
+++ b/test/test_check_stale_scss.py
@@ -0,0 +1,423 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Un SCSS personnalisé est une copie figée, comme une vue COW.
+
+Personnaliser un site écrit du SCSS dans `ir_attachment`. Cette copie est
+figée le jour où elle est écrite et continue d'employer les variables de
+CETTE version. Un module peut les renommer d'un palier à l'autre : mesuré,
+`website/.../primary_variables.scss` déclarait `$o-theme-font-number` en 12.0
+et a remplacé tout le mécanisme en 13.0. Une personnalisation de 2020
+demandait encore l'ancien nom, et le bundle s'arrêtait dessus.
+
+Ce qui rend un tel détecteur utilisable n'est pas de trouver — c'est de ne
+pas crier à tort. Sur le seul fichier mesuré, la première version rapportait
+dix noms dont six n'étaient pas des manques : paramètres de mixin, variables
+de boucle, arguments nommés d'`@include`. Ces tests portent surtout là-dessus.
+"""
+
+import os
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
+
+import check_stale_scss as scss # noqa: E402
+
+
+class TestWhatCountsAsAUse(unittest.TestCase):
+ def test_a_plain_dereference_is_a_use(self):
+ self.assertIn("color", scss.used_names("a { b: $color; }"))
+
+ def test_a_declaration_is_not_a_use(self):
+ self.assertNotIn("color", scss.used_names("$color: red;"))
+
+ def test_a_named_include_argument_is_not_a_use(self):
+ # LE faux positif mesuré : « @include o-position-absolute(
+ # $right: 50%) » se lisait comme l'usage d'un $right inexistant.
+ source = "@include o-position-absolute($right: 50%, $left: 50%);"
+ self.assertEqual(scss.used_names(source), set())
+
+ def test_a_default_value_is_not_a_use(self):
+ self.assertNotIn("size", scss.used_names("$size: 3 !default;"))
+
+ def test_the_name_is_not_truncated_to_dodge_the_colon(self):
+ # Une négation en tête de motif fait rétrograder le nom : le premier
+ # essai rapportait « $botto » au lieu d'écarter « $bottom ».
+ names = scss.used_names("a { b: $bottom-margin; }")
+ self.assertEqual(names, {"bottom-margin"})
+
+ def test_a_use_after_a_declaration_still_counts(self):
+ self.assertEqual(scss.used_names("$a: $b;"), {"b"})
+
+
+class TestWhatTheFileBindsItself(unittest.TestCase):
+ def test_mixin_parameters_are_bound(self):
+ source = "@mixin thing($on, $off: 2) { a: $on; b: $off; }"
+ self.assertEqual(scss.bound_names(source), {"on", "off"})
+
+ def test_function_parameters_are_bound(self):
+ source = "@function f($value) { @return $value; }"
+ self.assertIn("value", scss.bound_names(source))
+
+ def test_each_loop_variables_are_bound(self):
+ source = "@each $key, $val in $map { a: $key; }"
+ bound = scss.bound_names(source)
+ self.assertIn("key", bound)
+ self.assertIn("val", bound)
+
+ def test_for_loop_variables_are_bound(self):
+ source = "@for $counter from 1 through 3 { a: $counter; }"
+ self.assertIn("counter", scss.bound_names(source))
+
+
+class TestTheUrlIsSplitForTheFix(unittest.TestCase):
+ """reset_asset veut le fichier ET le bundle ; l'URL porte les deux."""
+
+ def test_a_frontend_customization(self):
+ base, bundle = scss.split_custom_url(
+ "/website/static/src/scss/website.custom.web.assets_frontend.scss"
+ )
+ self.assertEqual(base, "/website/static/src/scss/website.scss")
+ self.assertEqual(bundle, "web.assets_frontend")
+
+ def test_a_common_customization(self):
+ base, bundle = scss.split_custom_url(
+ "/website/static/src/scss/options/colors/"
+ "user_theme_color_palette.custom.web.assets_common.scss"
+ )
+ self.assertTrue(base.endswith("user_theme_color_palette.scss"))
+ self.assertEqual(bundle, "web.assets_common")
+
+
+class TestTheReport(unittest.TestCase):
+ def setUp(self):
+ from script.todo import todo_i18n
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def finding(self, **override):
+ data = {
+ "id": 1110,
+ "url": "/a/b.custom.web.assets_frontend.scss",
+ "missing": ["o-theme-font-number"],
+ "custom": "a { b: $o-theme-font-number; }",
+ "base_url": "/a/b.scss",
+ "bundle": "web.assets_frontend",
+ "module_path": "odoo13.0/a/b.scss",
+ "module_content": "a { b: 1; }",
+ "version_dir": "odoo13.0",
+ "database": "db",
+ }
+ data.update(override)
+ return data
+
+ def test_nothing_at_risk_says_so(self):
+ text = scss.render([], "db", "odoo13.0")
+ self.assertIn("✅", text)
+
+ def test_a_finding_names_the_attachment_and_the_variable(self):
+ text = scss.render([self.finding()], "db", "odoo13.0")
+ self.assertIn("1110", text)
+ self.assertIn("$o-theme-font-number", text)
+
+ def test_it_prints_a_command_that_can_be_pasted(self):
+ # Un diagnostic sans le geste qui répare oblige à rechercher les deux
+ # arguments de reset_asset au pire moment.
+ text = scss.render([self.finding()], "mydb", "odoo13.0")
+ self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text)
+ self.assertIn("-d mydb", text)
+
+ def test_it_warns_before_dropping(self):
+ text = scss.render([self.finding()], "db", "odoo13.0")
+ self.assertIn("real customization", text)
+
+
+class TestTheDiff(unittest.TestCase):
+ """Ce que la copie a changé : la seule chose que réinitialiser perd."""
+
+ def setUp(self):
+ from script.todo import todo_i18n
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def finding(self, **override):
+ data = {
+ "id": 1110,
+ "url": "/a/b.custom.web.assets_frontend.scss",
+ "missing": ["x"],
+ "custom": "a { b: 2; }",
+ "base_url": "/a/b.scss",
+ "bundle": "web.assets_frontend",
+ "module_path": "odoo13.0/a/b.scss",
+ "module_content": "a { b: 1; }",
+ "version_dir": "odoo13.0",
+ "database": "db",
+ }
+ data.update(override)
+ return data
+
+ def test_it_counts_what_would_be_lost(self):
+ text = scss.render_diff(self.finding())
+ self.assertIn("+1/-1", text)
+
+ def test_an_identical_copy_says_it_loses_nothing(self):
+ text = scss.render_diff(self.finding(custom="a { b: 1; }"))
+ self.assertIn("identical", text)
+
+ def test_a_missing_module_file_is_said_not_guessed(self):
+ # Si la cible ne livre plus le fichier, il n'y a rien sur quoi
+ # retomber : le taire ferait accepter une réinitialisation vide.
+ text = scss.render_diff(self.finding(module_path=None))
+ self.assertIn("no longer ships", text)
+
+
+class TestThePrompt(unittest.TestCase):
+ """Regarder ne doit pas répondre à la question."""
+
+ def setUp(self):
+ from script.todo import todo_i18n
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def finding(self):
+ return {
+ "id": 1,
+ "url": "/a/b.custom.web.assets_frontend.scss",
+ "missing": ["x"],
+ "custom": "a { b: 2; }",
+ "base_url": "/a/b.scss",
+ "bundle": "web.assets_frontend",
+ "module_path": "odoo13.0/a/b.scss",
+ "module_content": "a { b: 1; }",
+ "version_dir": "odoo13.0",
+ "database": "db",
+ }
+
+ def run_prompt(self, answers):
+ import contextlib
+ import io
+
+ seq = iter(answers)
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ wrote = scss.prompt(
+ [self.finding()], "db", ask=lambda prompt: next(seq)
+ )
+ return wrote, out.getvalue()
+
+ def test_enter_writes_nothing(self):
+ wrote, text = self.run_prompt([""])
+ self.assertFalse(wrote)
+ self.assertEqual(text, "")
+
+ def test_v_shows_the_diff_and_asks_again(self):
+ # Sans le « asks again », montrer vaudrait réponse : on aurait vu le
+ # diff et perdu la main sur la décision.
+ wrote, text = self.run_prompt(["v", ""])
+ self.assertFalse(wrote)
+ self.assertIn("+1/-1", text)
+
+ def test_apply_saves_a_copy_before_writing(self):
+ # reset_asset SUPPRIME la pièce jointe : sans sauvegarde préalable,
+ # les lignes personnalisées ne seraient plus nulle part.
+ import os
+ import tempfile
+
+ previous = os.getcwd()
+ self.addCleanup(os.chdir, previous)
+ os.chdir(tempfile.mkdtemp())
+ seen = {}
+ original = scss.apply_reset
+ scss.apply_reset = lambda lst, db, cfg="./config.conf": (
+ seen.update(called=True),
+ (0, "ok"),
+ )[1]
+ self.addCleanup(setattr, scss, "apply_reset", original)
+ wrote, text = self.run_prompt(["a"])
+ self.assertTrue(wrote)
+ self.assertTrue(seen.get("called"))
+ import glob
+
+ saved = glob.glob("private/odoo/migration/db/scss_backup/*")
+ self.assertEqual(len(saved), 1)
+ with open(saved[0]) as handle:
+ self.assertEqual(handle.read(), "a { b: 2; }")
+
+ def test_a_failed_reset_says_nothing_changed(self):
+ import os
+ import tempfile
+
+ previous = os.getcwd()
+ self.addCleanup(os.chdir, previous)
+ os.chdir(tempfile.mkdtemp())
+ original = scss.apply_reset
+ scss.apply_reset = lambda lst, db, cfg="./config.conf": (1, "boom")
+ self.addCleanup(setattr, scss, "apply_reset", original)
+ wrote, text = self.run_prompt(["a"])
+ self.assertFalse(wrote)
+ self.assertIn("nothing was changed", text)
+
+
+class TestTheMigrationRunsIt(unittest.TestCase):
+ def test_it_is_checked_at_the_same_place_as_the_cow_views(self):
+ # Les deux prédictions valent avant le palier, pas après : c'est le
+ # seul moment où l'on peut encore arbitrer sans avoir tout rejoué.
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+ self.assertIn("check_stale_scss.py", source)
+ self.assertLess(
+ source.index("check_stale_scss.py"),
+ source.index("4 - Upgrade version with OpenUpgrade"),
+ )
+
+ def test_it_gets_a_real_terminal(self):
+ # L'outil pose lui-même ses questions et peut ouvrir un plein écran :
+ # un tube les rendrait toutes injoignables, sans rien signaler.
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+ # On ne compare pas à une mise en forme — black la change — mais à
+ # l'appel le plus proche EN AMONT : c'est lui qui exécute.
+ avant = source[: source.index("check_stale_scss.py")]
+ self.assertGreater(
+ avant.rfind("run_on_terminal("),
+ avant.rfind("todo_upgrade_execute("),
+ "l'outil repasse par l'exécuteur à tube",
+ )
+
+
+class TestTheFixCannotRunTooEarly(unittest.TestCase):
+ """Corriger exige la version d'ARRIVÉE, pas celle de départ.
+
+ Mesuré sur une vraie migration : la question a été posée avant le palier,
+ alors que le checkout était encore sur odoo12.0. Répondre « a » a lancé
+ reset_asset dans un shell Odoo 12, qui ne connaît pas `web_editor.assets`
+ — KeyError, rien de modifié, et la migration a continué jusqu'à casser au
+ palier suivant.
+
+ Prédire tôt reste juste. C'est corriger tôt qui ne l'est pas.
+ """
+
+ def setUp(self):
+ from script.todo import todo_i18n
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def test_a_version_without_reset_asset_is_refused(self):
+ import os
+ import tempfile
+
+ root = tempfile.mkdtemp()
+ old = os.path.join(root, "odoo12.0", "addons", "web_editor", "models")
+ os.makedirs(old)
+ with open(os.path.join(old, "assets.py"), "w") as handle:
+ handle.write("class Assets:\n pass\n")
+ self.assertFalse(scss.reset_supported(os.path.join(root, "odoo12.0")))
+
+ def test_a_version_with_it_is_allowed(self):
+ import os
+ import tempfile
+
+ root = tempfile.mkdtemp()
+ new = os.path.join(root, "odoo13.0", "addons", "web_editor", "models")
+ os.makedirs(new)
+ with open(os.path.join(new, "assets.py"), "w") as handle:
+ handle.write("def reset_asset(self, url, bundle):\n pass\n")
+ self.assertTrue(scss.reset_supported(os.path.join(root, "odoo13.0")))
+
+ def test_an_unknown_checkout_does_not_block(self):
+ # Rien pour trancher : refuser sur une supposition empêcherait de
+ # corriger là où c'est possible.
+ self.assertTrue(scss.reset_supported("odoo_no_such_dir_zz"))
+
+ def test_the_prompt_hides_the_fix_when_it_cannot_work(self):
+ # Offrir un choix qui échouera, c'est le faire prendre.
+ import contextlib
+ import io
+
+ original = scss.reset_supported
+ scss.reset_supported = lambda odoo_dir=None: False
+ self.addCleanup(setattr, scss, "reset_supported", original)
+ finding = {
+ "id": 1,
+ "url": "/a/b.custom.web.assets_frontend.scss",
+ "missing": ["x"],
+ "custom": "a",
+ "base_url": "/a/b.scss",
+ "bundle": "web.assets_frontend",
+ "module_path": None,
+ "module_content": "",
+ "version_dir": "odoo13.0",
+ "database": "db",
+ }
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ wrote = scss.prompt([finding], "db", ask=lambda p: "a")
+ self.assertFalse(wrote)
+ self.assertIn("KeyError", out.getvalue())
+ self.assertNotIn("a = reset", out.getvalue())
+
+
+class TestTheMigrationAsksAtTheRightMoment(unittest.TestCase):
+ """Prédire avant le palier, corriger après — jamais l'inverse."""
+
+ def source(self):
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+
+ def test_the_early_call_cannot_offer_the_fix(self):
+ source = self.source()
+ premier = source.index("check_stale_scss.py")
+ fenetre = source[premier : premier + 400]
+ self.assertIn("--report-only", fenetre)
+
+ def test_a_second_call_comes_after_the_bump(self):
+ # Sans elle, la prédiction n'aurait jamais de suite : on saurait ce
+ # qui va casser sans jamais pouvoir le réparer.
+ source = self.source()
+ self.assertEqual(source.count("check_stale_scss.py"), 2)
+ second = source.rindex("check_stale_scss.py")
+ self.assertGreater(second, source.index("state_4_upgrade_odoo_lst"))
+
+ def test_the_second_call_targets_the_upgraded_database(self):
+ source = self.source()
+ second = source.rindex("check_stale_scss.py")
+ fenetre = source[second : second + 300]
+ self.assertIn("database_name_upgrade", fenetre)
+ self.assertNotIn("--report-only", fenetre)
+
+
+class TestThePromptStaysOutOfAPipe(unittest.TestCase):
+ def test_it_only_asks_in_front_of_a_terminal(self):
+ # Une invite dans un tube bloquerait l'appelant sur une question que
+ # personne ne voit.
+ with open(scss.__file__) as handle:
+ source = handle.read()
+ self.assertIn("sys.stdin.isatty()", source)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py
new file mode 100755
index 0000000..b6767a1
--- /dev/null
+++ b/test/test_database_cleanup.py
@@ -0,0 +1,972 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Le nettoyage OCA, en ordre, jusqu'à ce que plus rien ne bouge.
+
+Les huit purges ne sont pas indépendantes : purger un modèle libère les
+colonnes qui le référençaient, purger une table libère les données qui la
+visaient. Une seule passe ne suffit jamais, et l'ordre compte.
+
+Les erreurs sont ATTENDUES — une clé étrangère tient encore, un module
+refuse. Purger la liste d'un bloc perdrait tout au premier refus : chaque
+entrée passe donc dans son propre point de reprise. Ce que la passe n'a pas
+réparé, la suivante le peut, une fois les voisines parties.
+
+Ces tests exécutent le script réellement poussé dans le shell, sur un `env`
+simulé. C'est la seule façon de vérifier l'ordre, l'isolement des refus et
+l'arrêt de la boucle sans lancer Odoo sur une vraie base.
+"""
+
+import os
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
+
+import database_cleanup as cleanup # noqa: E402
+
+
+class FakeLine:
+ def __init__(self, name, fails=0, journal=None):
+ self.name = name
+ self.id = abs(hash(name)) % 10000
+ self.fails = fails # nombre de refus avant de céder
+ self.journal = journal if journal is not None else []
+ self.purged = False
+
+ def purge(self):
+ # Comme le vrai : une ligne déjà purgée ne l'est pas deux fois.
+ # `purge()` d'OCA filtre sur `not x.purged`, et sans cela le repli
+ # ligne à ligne recompterait ce que le lot avait déjà fait.
+ if self.purged:
+ return True
+ self.journal.append(("purge", self.name))
+ if self.fails > 0:
+ self.fails -= 1
+ raise RuntimeError(f"refus sur {self.name}")
+ self.purged = True
+ return True
+
+
+class FakeRecordset(list):
+ """Un `purge_line_ids` qui se purge EN LOT, comme le vrai.
+
+ C'est TOUT l'enjeu du correctif : `purge()` d'un module appelle
+ button_immediate_uninstall(), qui recharge le registre entier. Un appel
+ par ligne en faisait un rechargement par module — mesuré, dix secondes
+ chacun. Un seul appel sur le lot, c'est un seul rechargement.
+ """
+
+ def __init__(self, lines, journal=None):
+ super().__init__(lines)
+ self.journal = journal if journal is not None else []
+
+ def purge(self):
+ self.journal.append(("purge_batch", len(self)))
+ for line in self:
+ line.purge()
+ return True
+
+
+class FakeWizard:
+ def __init__(self, lines, journal=None):
+ self.purge_line_ids = FakeRecordset(lines, journal)
+
+
+class FakeModel:
+ def __init__(self, lines, raise_on_create=None):
+ self._lines = lines
+ self._raise = raise_on_create
+
+ def create(self, values):
+ if self._raise:
+ raise RuntimeError(self._raise)
+ # Les lignes déjà purgées ne reviennent pas : find() les recalcule.
+ reste = [ln for ln in self._lines if ln.fails >= 0 and not ln.purged]
+ journal = reste[0].journal if reste else None
+ return FakeWizard(reste, journal)
+
+
+class FakeCursor:
+ """Volontairement SANS `savepoint` : c'est l'invariant du correctif.
+
+ Le module OCA valide de lui-même — `purge_modules.find()` purge (ligne
+ 91) et `purge_columns.purge()` appelle `cr.commit()` (ligne 57). Un
+ COMMIT détruit tous les points de reprise. En reprendre un ici ferait
+ revivre le défaut sans que rien ne le dise ; l'absence de la méthode
+ le fait échouer tout de suite.
+ """
+
+ def __init__(self, journal):
+ self.journal = journal
+
+ def commit(self):
+ self.journal.append(("commit", None))
+
+ def rollback(self):
+ self.journal.append(("rollback", None))
+
+
+class AbortingCursor(FakeCursor):
+ """Ce que PostgreSQL fait VRAIMENT après une erreur.
+
+ La transaction reste avortée et refuse tout ordre jusqu'au rollback.
+ C'est ce qui changeait une panne sur « modules » en sept catégories
+ mortes, toutes sur « current transaction is aborted ».
+ """
+
+ def __init__(self, journal):
+ super().__init__(journal)
+ self.aborted = False
+
+ def commit(self):
+ if self.aborted:
+ raise RuntimeError(
+ "current transaction is aborted, commands ignored"
+ " until end of transaction block"
+ )
+ super().commit()
+
+ def rollback(self):
+ super().rollback()
+ self.aborted = False
+
+
+class CommittingLine(FakeLine):
+ """Une purge qui valide toute seule, comme `purge_columns` le fait.
+
+ Le COMMIT emporte le point de reprise ; l'ordre suivant meurt sur
+ « savepoint ... does not exist » et laisse la transaction avortée.
+ """
+
+ def __init__(self, name, cursor, journal=None):
+ super().__init__(name, journal=journal)
+ self.cursor = cursor
+
+ def purge(self):
+ self.journal.append(("purge", self.name))
+ self.cursor.commit()
+ self.cursor.aborted = True
+ raise RuntimeError('savepoint "10eb69719a9211f1" does not exist')
+
+
+class FakeEnv(dict):
+ def __init__(self, mapping, journal, cursor=None):
+ super().__init__(mapping)
+ self.cr = cursor if cursor is not None else FakeCursor(journal)
+
+
+class UserError(Exception):
+ """Celle que le script poussé importera : on fournit odoo.exceptions.
+
+ Refaire une classe de son côté ne servirait à rien — « except » compare
+ des identités, pas des noms, et le test passerait à côté.
+ """
+
+
+def install_fake_odoo_exceptions(case):
+ """Rendre `from odoo.exceptions import UserError` possible ici."""
+ import types
+
+ odoo = sys.modules.get("odoo") or types.ModuleType("odoo")
+ exceptions = types.ModuleType("odoo.exceptions")
+ exceptions.UserError = UserError
+ avant_odoo = sys.modules.get("odoo")
+ avant_exc = sys.modules.get("odoo.exceptions")
+ sys.modules["odoo"] = odoo
+ sys.modules["odoo.exceptions"] = exceptions
+
+ def remettre():
+ for nom, valeur in (
+ ("odoo", avant_odoo),
+ ("odoo.exceptions", avant_exc),
+ ):
+ if valeur is None:
+ sys.modules.pop(nom, None)
+ else:
+ sys.modules[nom] = valeur
+
+ case.addCleanup(remettre)
+
+
+def run_script(models, max_round=10, dry_run=False, cursor=None):
+ """Exécuter le script réellement poussé, et rendre (rapport, journal)."""
+ import json
+
+ journal = cursor.journal if cursor is not None else []
+ env = FakeEnv(models, journal, cursor=cursor)
+ namespace = {"env": env}
+ exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102
+ # Le script imprime le rapport entre deux sentinelles ; ici on le relit
+ # dans son espace de noms, ce qui teste la MÊME structure.
+ return json.loads(json.dumps(namespace["report"])), journal
+
+
+class TestTheOrder(unittest.TestCase):
+ def test_the_requested_order_is_kept(self):
+ # Purger un modèle libère des colonnes : l'inverse ne marcherait pas.
+ self.assertEqual(
+ [kind for kind, _model in cleanup.ORDER],
+ [
+ "models",
+ "modules",
+ "columns",
+ "tables",
+ "data",
+ "menus",
+ "indexes",
+ "properties",
+ ],
+ )
+
+ def test_indexes_come_after_the_purges(self):
+ kinds = [kind for kind, _ in cleanup.ORDER]
+ self.assertGreater(kinds.index("indexes"), kinds.index("tables"))
+
+ def test_a_pass_visits_the_kinds_in_that_order(self):
+ models = {
+ model: FakeModel([FakeLine(f"{kind}-1")])
+ for kind, model in cleanup.ORDER
+ }
+ report, _journal = run_script(models, max_round=1)
+ self.assertEqual(
+ [entry["kind"] for entry in report["rounds"][0]],
+ [kind for kind, _ in cleanup.ORDER],
+ )
+
+
+class TestOneEntryCannotSinkThePass(unittest.TestCase):
+ def test_a_healthy_category_is_purged_in_ONE_call(self):
+ # LE point du correctif. `purge()` d'un module appelle
+ # button_immediate_uninstall(), qui recharge le registre ENTIER —
+ # 5984 modules à relire. En purgeant ligne par ligne j'en faisais
+ # un rechargement PAR MODULE : mesuré, dix secondes chacun,
+ # dix-sept minutes pour neuf modules, sans rien afficher.
+ journal = []
+ lines = [
+ FakeLine("a", journal=journal),
+ FakeLine("b", journal=journal),
+ FakeLine("c", journal=journal),
+ ]
+ models = {cleanup.ORDER[0][1]: FakeModel(lines)}
+ report, _got = run_script(models, max_round=1)
+ # `journal` est celui des LIGNES : c'est là qu'atterrit la purge.
+ self.assertEqual(journal.count(("purge_batch", 3)), 1)
+ self.assertEqual(report["rounds"][0][0]["purged"], 3)
+
+ def test_the_healthy_case_commits_once_for_the_batch(self):
+ journal = []
+ lines = [
+ FakeLine("a", journal=journal),
+ FakeLine("b", journal=journal),
+ ]
+ models = {cleanup.ORDER[0][1]: FakeModel(lines)}
+ _report, got = run_script(models, max_round=1)
+ # Une validation après la création — `find()` peut avoir purgé de
+ # lui-même — et une pour le lot. Pas une par ligne.
+ self.assertEqual(got.count(("commit", None)), 2)
+
+ def test_a_refusal_gives_up_only_its_own(self):
+ journal = []
+ lines = [
+ FakeLine("ok1", journal=journal),
+ FakeLine("bad", fails=99, journal=journal),
+ FakeLine("ok2", journal=journal),
+ ]
+ models = {cleanup.ORDER[0][1]: FakeModel(lines)}
+ report, got = run_script(models, max_round=1)
+ entry = report["rounds"][0][0]
+ self.assertEqual(entry["purged"], 2)
+ self.assertEqual([name for name, _msg in entry["errors"]], ["bad"])
+ # Le lot a été tenté, a échoué, et SEULEMENT alors on isole. Le
+ # coût du ligne-à-ligne n'est payé que là où il sert.
+ self.assertEqual(journal.count(("purge_batch", 3)), 1)
+ self.assertGreaterEqual(got.count(("rollback", None)), 1)
+
+ def test_the_isolation_only_happens_after_a_refusal(self):
+ # Une catégorie saine ne doit JAMAIS passer par le repli : c'est
+ # lui qui coûtait dix secondes par module.
+ journal = []
+ models = {
+ cleanup.ORDER[0][1]: FakeModel(
+ [
+ FakeLine("a", journal=journal),
+ FakeLine("b", journal=journal),
+ ]
+ )
+ }
+ _report, got = run_script(models, max_round=1)
+ self.assertEqual(got.count(("rollback", None)), 0)
+
+ def test_the_script_never_takes_a_savepoint(self):
+ # L'invariant du correctif, dit une fois pour toutes : le module OCA
+ # valide de lui-même, et un COMMIT détruit le point de reprise
+ # qu'on aurait pris. Le reprendre serait revenir au défaut.
+ self.assertNotIn("env.cr.savepoint", cleanup.build_script(1, False))
+
+ def test_a_wizard_that_cannot_even_be_created_is_recorded(self):
+ models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")}
+ report, _got = run_script(models, max_round=1)
+ self.assertEqual(report["failed"][0][0], "models")
+ self.assertIn("boom", report["failed"][0][2])
+
+
+class TestTheReportSurvivesAnything(unittest.TestCase):
+ """Sans rapport, on ne sait même pas si la base a été touchée.
+
+ Vécu : `create({})` échouait, l'erreur était notée mais la transaction
+ restait AVORTÉE. La lecture de nom suivante mourait dessus, hors de tout
+ garde, et le script entier s'arrêtait — aucun rapport, juste une trace.
+ """
+
+ def test_reading_the_names_is_inside_the_guard(self):
+ # C'est la lecture des noms qui déclenche la requête, pas la
+ # création : la laisser hors du `try` était le défaut — elle mourait
+ # sur une transaction déjà avortée, sans rien pour la rattraper.
+ source = cleanup.build_script(1, False)
+ creation = source.index("wizard = env[model].create({})")
+ garde = source.rindex("try:", 0, creation)
+ noms = source.index("line.name or str(line.id)")
+ rattrapage = source.index("except UserError:", noms)
+ self.assertLess(garde, creation)
+ self.assertLess(creation, noms)
+ self.assertLess(noms, rattrapage)
+
+ def test_a_failure_on_create_does_not_kill_the_run(self):
+ models = {
+ cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom"),
+ cleanup.ORDER[2][1]: FakeModel([FakeLine("colonne")]),
+ }
+ report, _got = run_script(models, max_round=1)
+ # La catégorie suivante a bien travaillé malgré l'échec de la
+ # première.
+ purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
+ self.assertEqual(purged.get("columns"), 1)
+ self.assertEqual(report["failed"][0][0], "models")
+
+ def test_an_unexpected_failure_still_yields_a_report(self):
+ class Explosive(dict):
+ def __init__(self, journal):
+ super().__init__()
+ self.cr = FakeCursor(journal)
+
+ def __contains__(self, key):
+ raise RuntimeError("registre en miettes")
+
+ import json as _json
+
+ journal = []
+ namespace = {"env": Explosive(journal)}
+ exec(cleanup.build_script(1, False), namespace) # noqa: S102
+ report = _json.loads(_json.dumps(namespace["report"]))
+ self.assertEqual(report["failed"][0][:2], ["*", "fatal"])
+ self.assertIn("miettes", report["failed"][0][2])
+
+
+class TestTheSilenceThatLookedLikeAHang(unittest.TestCase):
+ """Dix-sept minutes sans une ligne, et l'on croit à une boucle infinie.
+
+ Vécu, sur test_neutralize_upgrade_16 : l'outil affichait « ⧖ Nettoyage
+ de … » puis PLUS RIEN. Le processus travaillait — zéro verrou en
+ attente, des requêtes qui changeaient à chaque instantané — mais un
+ travail qui avance et un blocage se ressemblent trait pour trait quand
+ aucun des deux ne parle. On interrompt alors une réparation à moitié
+ faite, ce qui est le pire des deux mondes.
+ """
+
+ def test_the_pushed_script_announces_what_it_does(self):
+ source = cleanup.build_script(1, False)
+ self.assertIn(cleanup.STEP, source)
+ self.assertIn("flush=True", source)
+
+ def test_it_announces_each_pass(self):
+ source = cleanup.build_script(1, False)
+ self.assertIn('step("pass"', source)
+
+ def test_the_parent_relays_each_line_AS_IT_ARRIVES(self):
+ """Le test comportemental, et non plus un mot cherché dans le code.
+
+ `capture_output=True` ne rend la main qu'à la fin : c'était la cause
+ du silence, pas la lenteur elle-même. On vérifie donc qu'une ligne
+ de progression ressort AVANT que le processus n'ait fini de parler.
+ """
+ import io
+
+ lignes = [
+ f"{cleanup.STEP} modules 1/3 vieux_module\n",
+ "un journal Odoo sans rapport\n",
+ f"{cleanup.STEP} columns 2/3 res_partner.x\n",
+ f"{cleanup.START}\n",
+ '{"rounds": [], "missing": [], "failed": []}\n',
+ f"{cleanup.END}\n",
+ ]
+ vu = []
+
+ class FauxProcessus:
+ def __init__(self, lst):
+ self.stdin = io.StringIO()
+ self.pid = -1
+ self._lst = lst
+
+ @property
+ def stdout(self):
+ # Un générateur : chaque ligne n'existe qu'au moment où on
+ # la lit, comme un vrai tube. Rendre la liste entière
+ # laisserait passer une lecture en bloc.
+ for rang, ligne in enumerate(self._lst):
+ vu.append(("lu", rang))
+ yield ligne
+
+ def wait(self, timeout=None):
+ return 0
+
+ def poll(self):
+ return 0
+
+ original = cleanup.subprocess.Popen
+ cleanup.subprocess.Popen = lambda *a, **kw: FauxProcessus(lignes)
+ self.addCleanup(setattr, cleanup.subprocess, "Popen", original)
+ report = cleanup.run_shell(
+ "db",
+ "./config.conf",
+ "script",
+ echo=lambda ligne: vu.append(("relayé", ligne)),
+ )
+ self.assertEqual(report["rounds"], [])
+ relayes = [x for x in vu if x[0] == "relayé"]
+ self.assertEqual(
+ [x[1] for x in relayes],
+ ["modules 1/3 vieux_module", "columns 2/3 res_partner.x"],
+ )
+ # ET au fil de l'eau : le relais suit IMMÉDIATEMENT la lecture de
+ # sa ligne. « quelque part avant la fin » ne suffirait pas — une
+ # lecture en bloc suivie d'une boucle de relais passerait aussi.
+ self.assertEqual(vu[vu.index(("lu", 0)) + 1], relayes[0])
+ self.assertEqual(vu[vu.index(("lu", 2)) + 1], relayes[1])
+
+ def test_the_relay_shows_the_elapsed_time(self):
+ # Ce qui distingue « ça avance lentement » de « ça ne bouge plus ».
+ import io
+ from contextlib import redirect_stdout
+
+ import time as _time
+
+ echo = cleanup.make_echo(_time.monotonic() - 42)
+ out = io.StringIO()
+ with redirect_stdout(out):
+ echo("modules 2/9 stock_deposit")
+ self.assertIn("42", out.getvalue())
+ self.assertIn("stock_deposit", out.getvalue())
+
+ def test_the_slowness_is_announced_up_front(self):
+ import inspect
+
+ source = inspect.getsource(cleanup.main)
+ self.assertIn("reloads the whole registry", source)
+
+ def test_the_timeout_cannot_be_starved_by_silence(self):
+ # Un délai vérifié DANS la boucle de lecture ne se déclencherait
+ # jamais : cette boucle bloque tant qu'aucune ligne n'arrive, et
+ # c'est exactement le cas qu'il faut couvrir.
+ import inspect
+
+ source = inspect.getsource(cleanup.run_shell)
+ self.assertIn("threading.Timer", source)
+
+ def test_stopping_kills_the_whole_group(self):
+ # « ./odoo_bin.sh » est un script bash : un terminate() sur lui tue
+ # le script et laisse odoo-bin vivant, sur la base, avec ses
+ # verrous. La leçon a déjà été payée en serveurs orphelins.
+ import inspect
+
+ source = inspect.getsource(cleanup.kill_group)
+ self.assertIn("killpg", source)
+ self.assertIn(
+ "start_new_session", inspect.getsource(cleanup.run_shell)
+ )
+
+ def test_a_timeout_is_reported_as_such(self):
+ # Sans cela, un arrêt à l'expiration se lisait « le nettoyage n'a
+ # produit aucun rapport » — un diagnostic faux.
+ import inspect
+
+ source = inspect.getsource(cleanup.run_shell)
+ self.assertIn("was still running after", source)
+ self.assertLess(
+ source.index("was still running after"),
+ source.index("produced no report"),
+ )
+
+
+class TestTheCascadeThatKilledEverything(unittest.TestCase):
+ """Vécu, sur test_neutralize_upgrade_13 : sept catégories mortes d'une.
+
+ passe 1 : 0 purgés
+ ⚠️ 0 purgés ; 7 n'ont pas pu l'être :
+ - [modules] - : savepoint "10eb6971..." does not exist
+ - [columns] - : current transaction is aborted, commands ignored
+ ... et ainsi de suite jusqu'à la dernière.
+
+ Une seule panne, six victimes. La cause n'était pas dans OCA mais chez
+ nous : on n'a jamais remis la transaction d'aplomb après l'échec.
+ """
+
+ def build(self):
+ journal = []
+ cursor = AbortingCursor(journal)
+ coupable = CommittingLine("colonne_morte", cursor, journal=journal)
+ models = {
+ cleanup.ORDER[1][1]: FakeModel([coupable]),
+ cleanup.ORDER[2][1]: FakeModel(
+ [FakeLine("suivante", journal=journal)]
+ ),
+ cleanup.ORDER[3][1]: FakeModel(
+ [FakeLine("encore", journal=journal)]
+ ),
+ }
+ return run_script(models, max_round=1, cursor=cursor)
+
+ def test_the_categories_after_it_still_work(self):
+ report, _got = self.build()
+ purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
+ self.assertEqual(purged.get("columns"), 1)
+ self.assertEqual(purged.get("tables"), 1)
+
+ def test_nobody_else_reports_an_aborted_transaction(self):
+ # C'est la SIGNATURE de la cascade : six lignes qui ne disent rien
+ # de leur propre catégorie, seulement qu'une autre a échoué avant.
+ report, _got = self.build()
+ contamines = [
+ entry
+ for round_ in report["rounds"]
+ for entry in round_
+ for _name, message in entry["errors"]
+ if "transaction is aborted" in message
+ ]
+ self.assertEqual(contamines, [])
+ self.assertEqual(report["failed"], [])
+
+ def test_the_real_failure_is_still_reported(self):
+ # Rattraper ne veut pas dire taire : la colonne n'a PAS été purgée.
+ report, _got = self.build()
+ entry = [e for e in report["rounds"][0] if e["kind"] == "modules"][0]
+ self.assertEqual(entry["purged"], 0)
+ self.assertIn("savepoint", entry["errors"][0][1])
+
+ def test_the_transaction_is_put_back_on_its_feet(self):
+ _report, got = self.build()
+ self.assertIn(("rollback", None), got)
+
+
+class TestADeadCreateDoesNotContaminate(unittest.TestCase):
+ def test_the_next_category_is_not_dragged_down(self):
+ # `create({})` qui échoue laisse la transaction avortée : sans
+ # rollback, la catégorie suivante mourait sur l'erreur d'une autre.
+ journal = []
+ cursor = AbortingCursor(journal)
+
+ class MortAuDepart(FakeModel):
+ def create(self, values):
+ cursor.aborted = True
+ raise RuntimeError("registre indisponible")
+
+ models = {
+ cleanup.ORDER[0][1]: MortAuDepart([]),
+ cleanup.ORDER[2][1]: FakeModel(
+ [FakeLine("colonne", journal=journal)]
+ ),
+ }
+ report, _got = run_script(models, max_round=1, cursor=cursor)
+ purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
+ self.assertEqual(purged.get("columns"), 1)
+ self.assertEqual(report["failed"][0][0], "models")
+ self.assertIn("registre", report["failed"][0][2])
+
+
+class TestGoingRoundAgain(unittest.TestCase):
+ def test_what_one_pass_refused_the_next_may_take(self):
+ # LE point, et la raison même de boucler : une entrée refuse TANT QUE
+ # sa voisine est là. Une ligne qui guérirait toute seule n'existe pas
+ # — ce qui existe, c'est une dépendance qui tombe.
+ etat = {"table_partie": False}
+
+ class Dependante(FakeLine):
+ def purge(self):
+ if not etat["table_partie"]:
+ raise RuntimeError("la table la retient encore")
+ self.fails = -1
+
+ class Liberatrice(FakeLine):
+ def purge(self):
+ etat["table_partie"] = True
+ self.fails = -1
+
+ modele_col, modele_tab = cleanup.ORDER[2][1], cleanup.ORDER[3][1]
+ models = {
+ modele_col: FakeModel([Dependante("colonne_liee")]),
+ modele_tab: FakeModel([Liberatrice("vieille_table")]),
+ }
+ report, _got = run_script(models, max_round=5)
+ # Passe 1 : la colonne refuse, la table part → du progrès, on continue.
+ self.assertEqual(report["rounds"][0][0]["purged"], 0)
+ self.assertEqual(report["rounds"][0][1]["purged"], 1)
+ # Passe 2 : la colonne cède, sa dépendance étant partie.
+ self.assertEqual(report["rounds"][1][0]["purged"], 1)
+
+ def test_a_pass_that_repairs_nothing_ends_it(self):
+ # Rien n'a changé dans la base : une passe de plus rendrait le même
+ # refus. Boucler serait des minutes brûlées pour rien.
+ models = {
+ cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
+ }
+ report, _got = run_script(models, max_round=8)
+ self.assertEqual(len(report["rounds"]), 1)
+ self.assertEqual(len(report["rounds"][0][0]["errors"]), 1)
+
+ def test_the_loop_stops_when_a_pass_repairs_nothing(self):
+ # Ce qui résistait au tour d'avant résistera encore : boucler
+ # jusqu'à max_round brûlerait des minutes pour rien.
+ models = {
+ cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
+ }
+ report, _got = run_script(models, max_round=8)
+ self.assertEqual(len(report["rounds"]), 1)
+
+ def test_nothing_to_do_is_one_pass(self):
+ models = {cleanup.ORDER[0][1]: FakeModel([])}
+ report, _got = run_script(models, max_round=8)
+ self.assertEqual(len(report["rounds"]), 1)
+
+
+class TestWhatIsAbsentIsSkipped(unittest.TestCase):
+ def test_a_missing_wizard_is_noted_not_fatal(self):
+ # `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property
+ # par une colonne jsonb. Échouer dessus arrêterait le nettoyage sur
+ # une version où il n'a plus lieu d'être.
+ models = {cleanup.ORDER[0][1]: FakeModel([])}
+ report, _got = run_script(models, max_round=1)
+ self.assertIn("properties", report["missing"])
+ self.assertIn("indexes", report["missing"])
+
+ def test_a_kind_is_noted_once(self):
+ models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("x", fails=1)])}
+ report, _got = run_script(models, max_round=5)
+ self.assertEqual(report["missing"].count("properties"), 1)
+
+
+class TestTheDryRun(unittest.TestCase):
+ def test_it_purges_nothing(self):
+ journal = []
+ lines = [FakeLine("a", journal=journal)]
+ models = {cleanup.ORDER[0][1]: FakeModel(lines)}
+ report, got = run_script(models, max_round=5, dry_run=True)
+ self.assertEqual(journal, [])
+ self.assertNotIn(("commit", None), got)
+ self.assertEqual(report["rounds"][0][0]["would"], ["a"])
+
+ def test_it_undoes_what_merely_looking_caused(self):
+ # `purge_modules.find()` PURGE de lui-même : une simulation écrivait
+ # donc pour de bon, ce qui lui retire tout son sens. On défait ce
+ # que la lecture a provoqué — possible ici, justement parce qu'on
+ # n'a validé aucune entrée.
+ journal = []
+ models = {
+ cleanup.ORDER[0][1]: FakeModel([FakeLine("a", journal=journal)])
+ }
+ _report, got = run_script(models, max_round=5, dry_run=True)
+ self.assertIn(("rollback", None), got)
+ self.assertNotIn(("commit", None), got)
+
+ def test_it_does_a_single_pass(self):
+ # Rien ne change, donc rien ne se libère : boucler serait du vent.
+ models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])}
+ report, _got = run_script(models, max_round=9, dry_run=True)
+ self.assertEqual(len(report["rounds"]), 1)
+
+
+class TestTheReport(unittest.TestCase):
+ def setUp(self):
+ from script.todo import todo_i18n
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def test_leftovers_are_a_warning_not_a_failure(self):
+ report = {
+ "rounds": [
+ [
+ {
+ "kind": "models",
+ "purged": 3,
+ "errors": [["x", "held"]],
+ "would": [],
+ }
+ ]
+ ],
+ "missing": [],
+ "failed": [],
+ }
+ text = cleanup.render(report, "db")
+ self.assertIn("⚠️", text)
+ self.assertIn("not a failure", text)
+
+ def test_all_clean_says_so(self):
+ report = {
+ "rounds": [
+ [{"kind": "models", "purged": 3, "errors": [], "would": []}]
+ ],
+ "missing": [],
+ "failed": [],
+ }
+ self.assertIn("✅", cleanup.render(report, "db"))
+
+ def test_a_dry_run_is_not_read_as_refusals(self):
+ # 586 candidats ne sont pas 586 refus : la première version les
+ # affichait comme des échecs.
+ report = {
+ "rounds": [
+ [
+ {
+ "kind": "columns",
+ "purged": 0,
+ "errors": [],
+ "would": ["a", "b"],
+ }
+ ]
+ ],
+ "missing": [],
+ "failed": [],
+ }
+ text = cleanup.render(report, "db")
+ self.assertIn("would be purged", text)
+ self.assertNotIn("⚠️", text)
+
+
+class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
+ """Interroger les pages sur une base encombrée fait chercher à côté."""
+
+ def source(self):
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+
+ def test_it_runs_before_both_smoke_tests(self):
+ source = self.source()
+ self.assertEqual(source.count("prompt_database_cleanup"), 2)
+ for _ in range(2):
+ nettoyage = source.index("prompt_database_cleanup")
+ mesure = source.index("prompt_smoke_public_url")
+ self.assertLess(nettoyage, mesure)
+ source = source[mesure + 1 :]
+
+ def test_it_gets_a_real_terminal(self):
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.prompt_database_cleanup)
+ # `run_tool` retient le verdict PUIS délègue à `run_on_terminal` :
+ # les deux propriétés comptent, et vérifier la seconde à la source
+ # évite qu'un raccourci futur reprenne l'exécuteur qui capture.
+ self.assertIn("run_tool(", source)
+ self.assertNotIn("todo_upgrade_execute", source)
+ self.assertIn(
+ "self.run_on_terminal(",
+ inspect.getsource(TodoUpgrade.run_tool),
+ )
+
+ def test_saying_no_cleans_nothing(self):
+ # Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
+ import contextlib
+ import io
+
+ from script.todo import todo_i18n
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+ upgrade = TodoUpgrade.__new__(TodoUpgrade)
+ upgrade.dct_progression = {}
+ upgrade.lst_command_executed = []
+ upgrade.write_config = lambda: None
+ lst_cmd = []
+ upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
+ upgrade.ask_gate = lambda prompt, default="": "n" or default
+ with contextlib.redirect_stdout(io.StringIO()):
+ upgrade.prompt_database_cleanup("db")
+ self.assertEqual(lst_cmd, [])
+
+ def test_the_default_cleans(self):
+ # Entrée nettoie : le nettoyage précède les tests de fumée, et une
+ # base encombrée de tables mortes fait échouer des pages pour une
+ # raison qui n'a rien à voir avec la migration.
+ import contextlib
+ import io
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ upgrade = TodoUpgrade.__new__(TodoUpgrade)
+ upgrade.dct_progression = {}
+ upgrade.lst_command_executed = []
+ upgrade.write_config = lambda: None
+ lst_cmd = []
+ upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
+ upgrade.ask_gate = lambda prompt, default="": "" or default
+ with contextlib.redirect_stdout(io.StringIO()):
+ upgrade.prompt_database_cleanup("db")
+ self.assertEqual(len(lst_cmd), 1)
+ self.assertIn("database_cleanup.py", lst_cmd[0])
+
+ def test_yes_runs_it_on_that_database(self):
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ upgrade = TodoUpgrade.__new__(TodoUpgrade)
+ upgrade.dct_progression = {}
+ upgrade.lst_command_executed = []
+ upgrade.write_config = lambda: None
+ lst_cmd = []
+ upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
+ upgrade.ask_gate = lambda prompt, default="": "y" or default
+ upgrade.prompt_database_cleanup("db_upgrade_18")
+ self.assertEqual(len(lst_cmd), 1)
+ self.assertIn("database_cleanup.py", lst_cmd[0])
+ self.assertIn("-d db_upgrade_18", lst_cmd[0])
+
+
+class TestNothingToPurgeIsNotAFailure(unittest.TestCase):
+ """Le module signale le VIDE par une exception.
+
+ `raise UserError("No orphaned models found")` : le compter comme un
+ échec faisait passer une base saine pour une base cassée, avec quatre
+ avertissements sur cinq catégories.
+ """
+
+ def test_the_pushed_script_separates_it(self):
+ source = cleanup.build_script(1, False)
+ self.assertIn("except UserError:", source)
+ vide = source.index("except UserError:")
+ echec = source.index('note(label, "-", exc)')
+ self.assertLess(vide, echec, "l'ordre des except décide")
+
+ def test_it_is_reported_as_zero_not_as_an_error(self):
+ install_fake_odoo_exceptions(self)
+
+ class Empty(FakeModel):
+ def create(self, values):
+ raise UserError("No orphaned models found")
+
+ models = {cleanup.ORDER[0][1]: Empty([])}
+ report, _got = run_script(models, max_round=1)
+ entry = report["rounds"][0][0]
+ self.assertEqual(entry["purged"], 0)
+ self.assertEqual(entry["errors"], [])
+ self.assertEqual(report["failed"], [])
+
+
+class TestTheModuleIsInstalledFirst(unittest.TestCase):
+ """Sans le module, aucun assistant n'existe et l'outil dit « rien ».
+
+ Ce silence se lit comme un succès. La migration l'installait à l'étape
+ 3, donc APRÈS le nettoyage de l'étape 2 : l'ordre rendait l'outil
+ inutile au premier passage.
+ """
+
+ def test_it_checks_the_state_before_cleaning(self):
+ import inspect
+
+ source = inspect.getsource(cleanup.main)
+ self.assertIn("module_state(", source)
+ self.assertLess(
+ source.index("module_state("), source.index("run_shell(")
+ )
+
+ def test_it_installs_when_absent(self):
+ import inspect
+
+ source = inspect.getsource(cleanup.main)
+ self.assertIn('state != "installed"', source)
+ self.assertIn("install_module(", source)
+
+ def test_a_failed_install_stops_there(self):
+ # Nettoyer sans le module rendrait « rien à faire » sur une base qui
+ # en avait besoin.
+ import inspect
+
+ source = inspect.getsource(cleanup.main)
+ install = source.index("install_module(")
+ self.assertIn("return 2", source[install : install + 400])
+
+
+class TestTheMigrationNoLongerAsksTwice(unittest.TestCase):
+ def test_the_manual_cleanup_prompt_is_gone(self):
+ # Le faire à la main après l'avoir fait automatiquement.
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+ self.assertNotIn("Did you finish to clean database", source)
+ self.assertNotIn("Go to Settings / Technical / Cleanup", source)
+
+ def test_the_late_install_is_gone_too(self):
+ # Elle arrivait à l'étape 3, après l'usage de l'étape 2 : l'outil
+ # pose désormais le module lui-même, au bon moment.
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
+ self.assertNotIn(
+ "install_addons.sh {database_name} database_cleanup", source
+ )
+
+
+class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
+ """Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer."""
+
+ def test_a_mismatch_is_refused(self):
+ original_checkout = cleanup.checkout_version
+ original_db = cleanup.database_version
+ cleanup.checkout_version = lambda: "14.0"
+ cleanup.database_version = lambda database: "17.0"
+ self.addCleanup(
+ setattr, cleanup, "checkout_version", original_checkout
+ )
+ self.addCleanup(setattr, cleanup, "database_version", original_db)
+ message = cleanup.require_matching_version("db")
+ self.assertIsNotNone(message)
+ self.assertIn("14.0", message)
+ self.assertIn("17.0", message)
+
+ def test_a_match_passes(self):
+ original_checkout = cleanup.checkout_version
+ original_db = cleanup.database_version
+ cleanup.checkout_version = lambda: "17.0"
+ cleanup.database_version = lambda database: "17.0"
+ self.addCleanup(
+ setattr, cleanup, "checkout_version", original_checkout
+ )
+ self.addCleanup(setattr, cleanup, "database_version", original_db)
+ self.assertIsNone(cleanup.require_matching_version("db"))
+
+ def test_not_knowing_does_not_block(self):
+ # Refuser sur une supposition empêcherait de nettoyer là où c'est
+ # possible.
+ original_checkout = cleanup.checkout_version
+ cleanup.checkout_version = lambda: None
+ self.addCleanup(
+ setattr, cleanup, "checkout_version", original_checkout
+ )
+ self.assertIsNone(cleanup.require_matching_version("db"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_dms_access_repair.py b/test/test_dms_access_repair.py
new file mode 100644
index 0000000..e5742e9
--- /dev/null
+++ b/test/test_dms_access_repair.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qui doit tenir avant de toucher à une base de production.
+
+Deux propriétés portent tout le reste. La première : sans `--apply`,
+RIEN ne s'écrit — un outil de diagnostic qui répare tout seul est un
+piège. La seconde : le compte de visibilité doit être fait avec un vrai
+utilisateur, jamais en sudo, car la règle globale d'OCA DMS ne s'applique
+pas au super-utilisateur et un compte en sudo dirait « tout va bien »
+alors que personne ne voit rien.
+"""
+
+import ast
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.odoo.migration import dms_access_repair as repair # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+class TestTheGeneratedScript(unittest.TestCase):
+ def test_the_dry_run_never_creates_anything(self):
+ # Le garde vit dans le script POUSSÉ : c'est lui qui décide, pas
+ # l'appelant. Un `DRY = True` qui n'entoure pas le `create` ne
+ # protège de rien.
+ code = repair.build_script(dry_run=True)
+ self.assertIn("DRY = True", code)
+ arbre = ast.parse(code)
+ creations = [
+ n
+ for n in ast.walk(arbre)
+ if isinstance(n, ast.Call)
+ and isinstance(n.func, ast.Attribute)
+ and n.func.attr == "create"
+ ]
+ self.assertTrue(creations, "aucune création dans le script ?")
+ for noeud in creations:
+ self.assertTrue(
+ self._under_dry_guard(arbre, noeud),
+ "une création hors du garde `not DRY`",
+ )
+
+ @staticmethod
+ def _under_dry_guard(arbre, cible):
+ """La création est-elle sous un `if not DRY ...` ?"""
+ for noeud in ast.walk(arbre):
+ if not isinstance(noeud, ast.If):
+ continue
+ texte = ast.dump(noeud.test)
+ if "DRY" not in texte:
+ continue
+ for enfant in ast.walk(noeud):
+ if enfant is cible:
+ return True
+ return False
+
+ def test_apply_lifts_the_guard(self):
+ self.assertIn("DRY = False", repair.build_script(dry_run=False))
+
+ def test_the_script_is_valid_python(self):
+ for dry in (True, False):
+ ast.parse(repair.build_script(dry_run=dry))
+
+ def test_visibility_is_measured_with_a_real_user(self):
+ # `with_user` est le point tout entier : en sudo, la règle globale
+ # ne s'applique pas et le rapport serait rassurant à tort.
+ code = repair.build_script(dry_run=True)
+ self.assertIn("with_user(temoin)", code)
+ self.assertIn("u.id != 1", code)
+
+ def test_it_reuses_the_shared_sentinels(self):
+ from script.odoo.migration import database_cleanup
+
+ self.assertEqual(repair.DEBUT, database_cleanup.START)
+ self.assertEqual(repair.FIN, database_cleanup.END)
+
+
+class TestTheReport(unittest.TestCase):
+ PLEIN = {
+ "files": 69,
+ "directories": 16,
+ "access_groups_before": 0,
+ "witness": "marie@example.org",
+ "before": {"directories": 0, "files": 0},
+ "roots": ["CNESST", "Contrat"],
+ "already_repaired": False,
+ }
+
+ def test_it_shows_what_exists_and_what_is_seen(self):
+ texte = "\n".join(repair.render(self.PLEIN, dry_run=True))
+ self.assertIn("69", texte)
+ self.assertIn("marie@example.org", texte)
+
+ def test_a_dry_run_says_nothing_was_written(self):
+ texte = "\n".join(repair.render(self.PLEIN, dry_run=True))
+ self.assertIn(
+ todo_i18n.t("Nothing written. Re-run with --apply."), texte
+ )
+
+ def test_an_applied_run_reports_the_new_visibility(self):
+ rapport = dict(self.PLEIN, after={"directories": 16, "files": 69})
+ texte = "\n".join(repair.render(rapport, dry_run=False))
+ self.assertIn(todo_i18n.t("Now visible to"), texte)
+
+ def test_an_already_repaired_database_says_so_and_stops(self):
+ rapport = dict(self.PLEIN, already_repaired=True)
+ texte = "\n".join(repair.render(rapport, dry_run=True))
+ self.assertIn(
+ todo_i18n.t("Already repaired: the group exists."), texte
+ )
+ self.assertNotIn(
+ todo_i18n.t("Would create one access group over"), texte
+ )
+
+ def test_no_witness_is_flagged_not_silently_fine(self):
+ # Sans utilisateur témoin on ne SAIT pas : le taire ferait passer
+ # une base muette pour une base saine.
+ rapport = dict(self.PLEIN, before=None, witness=None)
+ texte = "\n".join(repair.render(rapport, dry_run=True))
+ self.assertIn(
+ todo_i18n.t("No DMS user to test visibility with."), texte
+ )
+
+ def test_every_translation_key_exists(self):
+ with io.open(
+ repair.__file__.replace(".pyc", ".py"), encoding="utf-8"
+ ) as handle:
+ src = handle.read()
+ for node in ast.walk(ast.parse(src)):
+ if (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "t"
+ and node.args
+ and isinstance(node.args[0], ast.Constant)
+ ):
+ cle = node.args[0].value
+ self.assertTrue(
+ cle in todo_i18n.TRANSLATIONS,
+ f"clé sans traduction : {cle!r}",
+ )
+
+
+class TestTheVersionGuard(unittest.TestCase):
+ def setUp(self):
+ from script.odoo.migration import database_cleanup
+
+ self.cleanup = database_cleanup
+ self.vraie = database_cleanup.require_matching_version
+ self.vrai_shell = database_cleanup.run_shell
+ self.appels = []
+ database_cleanup.run_shell = (
+ lambda *a, **k: self.appels.append(a) or {}
+ )
+
+ def tearDown(self):
+ self.cleanup.require_matching_version = self.vraie
+ self.cleanup.run_shell = self.vrai_shell
+
+ def test_a_mismatch_stops_before_opening_the_database(self):
+ # Un Odoo d'une autre version ÉCRIT avant d'échouer : le refus
+ # doit précéder toute ouverture, pas la suivre.
+ self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ code = repair.main(["-d", "vieille_base"])
+ self.assertEqual(code, 2)
+ self.assertEqual(self.appels, [])
+ self.assertIn("18.0 vs 12.0", tampon.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py
new file mode 100644
index 0000000..4729f13
--- /dev/null
+++ b/test/test_error_retry_loop.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Réparer puis rejouer — et savoir s'arrêter.
+
+Le motif d'échec le plus fréquent d'une migration est une copie COW en
+retard sur sa vue module : « Element cannot be located in parent
+view ». La réparation est connue, et c'est presque toujours ce qu'on allait
+faire. D'où « 3 » par défaut, et le rejeu automatique derrière.
+
+Mais un défaut qui agit doit savoir s'arrêter, et à deux titres :
+
+- réinitialiser quand il n'y a RIEN à réinitialiser puis reproposer la même
+ chose est une boucle sans fin. Vécu : « Aucune copie COW n'a dérivé »,
+ encore et encore ;
+- une réparation qui ne suffit pas relancerait la commande indéfiniment.
+ Trois tentatives, puis on rend la main : une migration lancée en
+ auto-exécution tournerait sinon toute la nuit sur le même échec.
+
+Ces tests comptent les tours. C'est la seule façon de prouver qu'une
+boucle se termine.
+"""
+
+import io
+import os
+import unittest
+from contextlib import redirect_stdout
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+from script.todo import todo_i18n # noqa: E402
+from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
+
+
+class Harness(unittest.TestCase):
+ """Un pilote dont la commande échoue toujours, et qu'on observe."""
+
+ def setUp(self):
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def upgrade(self, lst_answer=None, resets=None, echec=True):
+ """`resets` : ce que la réinitialisation rend, tour après tour."""
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.dct_progression = {}
+ obj.lst_command_executed = []
+ obj.write_config = lambda: None
+ obj.database_from_command = lambda cmd: "db"
+ self.lst_run = []
+ self.lst_reset = []
+
+ class FauxExecute:
+ def exec_command_live(_self, cmd, **kw):
+ # Une interrogation de service — la détection de thème —
+ # n'est pas un rejeu de la commande : l'inscrire dans
+ # `lst_run` doublerait tous les comptes de cette suite.
+ if "ir_module_module" in cmd:
+ if kw.get("return_status_and_output_and_command"):
+ return 0, cmd, []
+ return 0, cmd
+ self.lst_run.append(cmd)
+ # Toujours en échec : c'est le cas qu'on veut borner.
+ statut = 1 if echec else 0
+ # La FORME du retour suit les drapeaux, comme le vrai :
+ # un faux qui rend toujours deux valeurs casse dès qu'un
+ # appelant demande la sortie.
+ if kw.get("return_status_and_output_and_command"):
+ return statut, cmd, []
+ if kw.get("return_status_and_output"):
+ return statut, []
+ return statut, cmd
+
+ obj.execute = FauxExecute()
+ suite = iter(resets if resets is not None else [])
+
+ def faux_reset(database):
+ self.lst_reset.append(database)
+ try:
+ return next(suite)
+ except StopIteration:
+ return False
+
+ obj.prompt_reset_stale_cow_views = faux_reset
+ obj.check_stale_cow_views = lambda db: None
+ obj.run_on_terminal = lambda cmd: 0
+ reponses = iter(lst_answer or [])
+
+ def faux_ask(prompt, default=""):
+ try:
+ return next(reponses)
+ except StopIteration:
+ # Personne ne répond : c'est le mode auto, et c'est
+ # précisément là qu'une boucle sans fin se déclenche.
+ return default
+
+ obj.ask = faux_ask
+ return obj
+
+ def executer(self, obj):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ obj.todo_upgrade_execute("./script/addons/update_addons_all.sh db")
+ return out.getvalue()
+
+
+class TestRepairingThenReplaying(Harness):
+ def test_the_default_repairs(self):
+ # Sans réponse, on répare : c'est le geste qu'on allait faire.
+ obj = self.upgrade(resets=[True, False])
+ self.executer(obj)
+ self.assertTrue(self.lst_reset)
+
+ def test_a_successful_repair_replays_the_command(self):
+ obj = self.upgrade(resets=[True, False])
+ self.executer(obj)
+ self.assertGreaterEqual(len(self.lst_run), 2)
+
+ def test_a_repair_that_changed_nothing_does_NOT_replay(self):
+ # Rejouer sans avoir rien changé donnerait le même échec.
+ obj = self.upgrade(resets=[False])
+ self.executer(obj)
+ self.assertEqual(len(self.lst_run), 1)
+
+
+class TestItAlwaysStops(Harness):
+ """La propriété qui compte le plus : la boucle se termine."""
+
+ def test_nothing_to_reset_does_not_loop_forever(self):
+ # Vécu : « Aucune copie COW n'a dérivé », reproposé sans fin parce
+ # que le défaut restait « 3 ».
+ obj = self.upgrade(resets=[False, False, False, False, False])
+ self.executer(obj)
+ self.assertLessEqual(len(self.lst_reset), 2)
+
+ def test_a_repair_that_never_helps_stops_after_three(self):
+ obj = self.upgrade(resets=[True] * 20)
+ self.executer(obj)
+ self.assertEqual(len(self.lst_run), TodoUpgrade.MAX_ERROR_RETRY)
+
+ def test_giving_up_says_so_out_loud(self):
+ # S'arrêter en silence ferait croire que c'est réparé.
+ obj = self.upgrade(resets=[True] * 20)
+ text = self.executer(obj)
+ self.assertIn("needs a developer", text)
+
+ def test_the_loop_is_bounded_STRUCTURALLY(self):
+ """Même si la bascule du défaut disparaissait un jour.
+
+ Deux protections pour la même panne, et c'est délibéré : celle-ci
+ ne dépend d'aucune logique métier. Une boucle infinie dans une
+ migration lancée sans surveillance coûte une nuit, et la seule
+ preuve qu'une boucle se termine est de compter ses tours.
+ """
+ obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
+ self.executer(obj)
+ self.assertLessEqual(len(self.lst_run), 2)
+
+ def test_the_turn_bound_says_so(self):
+ obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
+ text = self.executer(obj)
+ self.assertIn("Too many turns", text)
+
+ def test_the_bound_is_three(self):
+ self.assertEqual(TodoUpgrade.MAX_ERROR_RETRY, 3)
+
+ def test_a_command_that_succeeds_never_asks(self):
+ obj = self.upgrade(echec=False)
+ self.executer(obj)
+ self.assertEqual(self.lst_reset, [])
+ self.assertEqual(len(self.lst_run), 1)
+
+
+class TestTheHumanKeepsTheWheel(Harness):
+ def test_typing_one_replays_without_consuming_the_budget(self):
+ # Le plafond borne le rejeu AUTOMATIQUE. Quelqu'un qui tape « 1 »
+ # sait ce qu'il fait, et se voir refuser un quatrième essai serait
+ # une surprise désagréable.
+ obj = self.upgrade(lst_answer=["1", "1", "1", "1", ""])
+ self.executer(obj)
+ self.assertEqual(len(self.lst_run), 5)
+
+ def test_typing_n_continues_without_repairing(self):
+ obj = self.upgrade(lst_answer=[""] * 0 + ["x"])
+ self.executer(obj)
+ self.assertEqual(self.lst_reset, [])
+ self.assertEqual(len(self.lst_run), 1)
+
+ def test_the_prompt_says_what_enter_does(self):
+ import inspect
+
+ # Le menu d'erreur vit dans `_prompt_on_error`, extrait de
+ # `todo_upgrade_execute` quand celui-ci a passé le seuil de
+ # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
+ # éprouve, pas une méthode en particulier.
+ source = inspect.getsource(
+ TodoUpgrade.todo_upgrade_execute
+ ) + inspect.getsource(TodoUpgrade._prompt_on_error)
+ self.assertIn('defaut = "3" if database_name else ""', source)
+ self.assertIn("default=defaut", source)
+
+ def test_without_a_database_the_default_is_to_continue(self):
+ # Les options 2 à 4 ne s'affichent pas : proposer « 3 » viserait
+ # une commande qui n'existe pas.
+ obj = self.upgrade()
+ obj.database_from_command = lambda cmd: None
+ self.executer(obj)
+ self.assertEqual(self.lst_reset, [])
+ self.assertEqual(len(self.lst_run), 1)
+
+
+class TestTheResetReportsWhatItDid(unittest.TestCase):
+ """Sans verdict, on ne peut pas décider de rejouer."""
+
+ def test_nothing_drifted_is_False(self):
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.stale_cow_keys = lambda db: []
+ with redirect_stdout(io.StringIO()):
+ self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
+
+ def test_saying_no_is_False(self):
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.stale_cow_keys = lambda db: ["web.layout"]
+ obj.ask_gate = lambda prompt, default="": "n"
+ with redirect_stdout(io.StringIO()):
+ self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
+
+ def test_a_reset_that_ran_is_True(self):
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.dct_progression = {}
+ obj.write_config = lambda: None
+ obj.stale_cow_keys = lambda db: ["web.layout"]
+ obj.ask_gate = lambda prompt, default="": default
+ obj.run_on_terminal = lambda cmd: 0
+ with redirect_stdout(io.StringIO()):
+ self.assertTrue(obj.prompt_reset_stale_cow_views("db"))
+
+ def test_a_reset_that_FAILED_is_False(self):
+ # Rejouer derrière une réinitialisation qui a échoué, c'est brûler
+ # une tentative sur un état inchangé.
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.dct_progression = {}
+ obj.write_config = lambda: None
+ obj.stale_cow_keys = lambda db: ["web.layout"]
+ obj.ask_gate = lambda prompt, default="": default
+ obj.run_on_terminal = lambda cmd: 2
+ with redirect_stdout(io.StringIO()):
+ self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_fix_view_type.py b/test/test_fix_view_type.py
new file mode 100644
index 0000000..0fb1ec3
--- /dev/null
+++ b/test/test_fix_view_type.py
@@ -0,0 +1,604 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Une vue dont le `type` stocké contredit son héritage.
+
+Deux propriétés portent l'outil. La première : il travaille en SQL, sans
+l'ORM — le registre ne charge plus, et c'est justement ce qu'on répare.
+La seconde : il ne corrige QUE ce qu'Odoo lui-même déclare faux — une
+vue héritée prend le type de son ancêtre, règle citée dans `create`.
+Vérifié sur une installation neuve et quatre bases migrées : zéro écart.
+"""
+
+import ast
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.odoo.migration import fix_view_type as fvt # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+def vue(vid=637, actuel="tree", attendu="search", xmlid="event.x"):
+ return {
+ "id": vid,
+ "type": actuel,
+ "expected": attendu,
+ "mode": "primary",
+ "xmlid": xmlid,
+ "model": "event.registration",
+ }
+
+
+class TestTheDetectionQuery(unittest.TestCase):
+ def test_it_only_looks_at_inherited_views(self):
+ # Une vue racine tient son type de sa balise : la comparer à
+ # elle-même n'apprendrait rien.
+ self.assertIn("v.inherit_id IS NOT NULL", fvt.DETECTION)
+
+ def test_it_compares_against_the_ROOT_ancestor(self):
+ # Pas le parent direct : une chaîne de trois vues doit remonter
+ # jusqu'à celle qui porte la balise.
+ self.assertIn("WITH RECURSIVE", fvt.DETECTION)
+ self.assertIn("v.type <> r.type", fvt.DETECTION)
+
+ def test_the_recursion_is_bounded(self):
+ # Un `inherit_id` circulaire ferait tourner PostgreSQL sans fin.
+ self.assertIn(f"r.prof < {fvt.PROFONDEUR_MAX}", fvt.DETECTION)
+ self.assertGreater(fvt.PROFONDEUR_MAX, 5)
+
+ def test_it_never_writes(self):
+ for mot in ("UPDATE", "DELETE", "INSERT", "DROP"):
+ self.assertNotIn(mot, fvt.DETECTION.upper().replace("UPDATED", ""))
+
+
+class TestTheRepairSql(unittest.TestCase):
+ def test_it_sets_each_view_to_its_ancestor_type(self):
+ sql = fvt.repair_sql([vue(637, "tree", "search")])
+ self.assertIn("WHEN 637 THEN 'search'", sql)
+ self.assertIn("WHERE id IN (637)", sql)
+
+ def test_several_views_travel_in_ONE_statement(self):
+ # Une seule instruction, donc une seule transaction : couper au
+ # milieu ne laisse pas une base à moitié réparée.
+ sql = fvt.repair_sql(
+ [vue(1, "tree", "search"), vue(2, "form", "kanban")]
+ )
+ self.assertEqual(sql.count("UPDATE"), 1)
+ self.assertIn("WHEN 1 THEN 'search'", sql)
+ self.assertIn("WHEN 2 THEN 'kanban'", sql)
+
+ def test_nothing_to_fix_gives_no_sql(self):
+ self.assertEqual(fvt.repair_sql([]), "")
+
+ def test_it_targets_the_id_never_a_rebuilt_domain(self):
+ sql = fvt.repair_sql([vue()])
+ self.assertNotIn("inherit_id", sql)
+ self.assertNotIn("arch_db", sql)
+ self.assertNotIn("arch =", sql)
+ self.assertEqual(sql.count("SET "), 1)
+ self.assertIn("SET type =", sql)
+
+
+class TestTheReport(unittest.TestCase):
+ def test_a_clean_check_renders_nothing(self):
+ self.assertEqual(fvt.render([]), [])
+
+ def test_a_finding_names_the_view_and_both_types(self):
+ texte = "\n".join(fvt.render([vue(637, "tree", "search")]))
+ self.assertIn("637", texte)
+ self.assertIn("tree → search", texte)
+ self.assertIn("event.x", texte)
+
+ def test_it_says_no_module_update_will_help(self):
+ # C'est le renseignement qui évite de perdre une heure à
+ # relancer `-u module` en boucle.
+ texte = "\n".join(fvt.render([vue()]))
+ self.assertIn(
+ todo_i18n.t(
+ "No module update will fix this: the type is set once,"
+ ),
+ texte,
+ )
+
+ def test_an_applied_run_does_not_repeat_the_advice(self):
+ texte = "\n".join(fvt.render([vue()], applique=True))
+ self.assertIn(todo_i18n.t("Corrected."), texte)
+ self.assertNotIn(
+ todo_i18n.t("at creation. Re-run with --apply."), texte
+ )
+
+
+class TestTheExitCodes(unittest.TestCase):
+ def setUp(self):
+ self.vrai = fvt.run_psql
+ self.ecritures = []
+
+ def tearDown(self):
+ fvt.run_psql = self.vrai
+
+ def branche(self, avant, apres=None):
+ """`avant` puis `apres` : ce que la détection rend, avant/après."""
+ etat = {"tour": 0}
+
+ def faux(database, sql, read_only=True):
+ if not read_only:
+ self.ecritures.append(sql)
+ return []
+ etat["tour"] += 1
+ lot = avant if etat["tour"] == 1 or apres is None else apres
+ if lot is None:
+ return None
+ return [
+ [
+ str(v["id"]),
+ v["type"],
+ v["expected"],
+ v["mode"],
+ v["xmlid"],
+ v["model"],
+ ]
+ for v in lot
+ ]
+
+ fvt.run_psql = faux
+
+ def lance(self, argv):
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ code = fvt.main(argv)
+ return code, tampon.getvalue()
+
+ def test_nothing_wrong_exits_zero(self):
+ self.branche([])
+ code, _ = self.lance(["-d", "db"])
+ self.assertEqual(code, 0)
+ self.assertEqual(self.ecritures, [])
+
+ def test_a_finding_exits_one_and_writes_nothing(self):
+ # Sans --apply, l'outil DIAGNOSTIQUE. Réparer tout seul serait un
+ # piège dans un outil qu'on lance pour comprendre.
+ self.branche([vue()])
+ code, _ = self.lance(["-d", "db"])
+ self.assertEqual(code, 1)
+ self.assertEqual(self.ecritures, [])
+
+ def test_apply_fixes_and_exits_zero(self):
+ self.branche([vue()], apres=[])
+ code, sortie = self.lance(["-d", "db", "--apply"])
+ self.assertEqual(code, 0)
+ self.assertEqual(len(self.ecritures), 1)
+ self.assertIn(todo_i18n.t("Corrected."), sortie)
+
+ def test_a_correction_that_did_not_take_exits_two(self):
+ # Relire APRÈS : annoncer « corrigé » sans vérifier ferait
+ # relancer la migration sur le même mur.
+ self.branche([vue()], apres=[vue()])
+ code, _ = self.lance(["-d", "db", "--apply"])
+ self.assertEqual(code, 2)
+
+ def test_an_unreadable_database_exits_two(self):
+ self.branche(None)
+ code, _ = self.lance(["-d", "db"])
+ self.assertEqual(code, 2)
+
+
+def arbre(vid=3931, origine="custom", type_="list", xmlid="-"):
+ return {
+ "id": vid,
+ "type": type_,
+ "xmlid": xmlid,
+ "model": "res.partner",
+ "origin": origine,
+ }
+
+
+class TestTheTreeTag(unittest.TestCase):
+ """Odoo 18 a renommé `tree` en `list`, balise comprise.
+
+ OpenUpgrade convertit la COLONNE `type` et jamais l'ARCH. Une vue de
+ module s'en remet à la première mise à jour ; une vue sans xmlid
+ n'est réécrite par rien.
+ """
+
+ def test_the_replacement_covers_open_AND_close(self):
+ sql = fvt.repair_tree_sql([arbre()], jsonb=True)
+ self.assertIn("'', '
'", sql)
+
+ def test_it_replaces_EVERY_occurrence_not_just_the_root(self):
+ # Un `` imbriqué dans un formulaire — une liste one2many —
+ # est refusé autant que celui de la racine. `replace` en SQL
+ # remplace tout ; un ancrage `^` n'aurait pris que le premier.
+ sql = fvt.repair_tree_sql([arbre()], jsonb=True)
+ self.assertNotIn("^", sql)
+ self.assertNotIn("regexp", sql.lower())
+
+ def test_jsonb_is_rebuilt_language_by_language(self):
+ # Une traduction oubliée laisserait la vue cassée dans cette
+ # langue seulement — une panne qui ne se montre qu'à certains.
+ sql = fvt.repair_tree_sql([arbre()], jsonb=True)
+ self.assertIn("jsonb_each_text", sql)
+ self.assertIn("jsonb_object_agg", sql)
+
+ def test_a_text_column_is_handled_too(self):
+ sql = fvt.repair_tree_sql([arbre()], jsonb=False)
+ self.assertNotIn("jsonb", sql)
+ self.assertIn("replace(replace(arch_db", sql)
+
+ def test_nothing_to_fix_gives_no_sql(self):
+ self.assertEqual(fvt.repair_tree_sql([], jsonb=True), "")
+
+ def test_it_targets_the_listed_ids_only(self):
+ sql = fvt.repair_tree_sql([arbre(11), arbre(22)], jsonb=True)
+ self.assertIn("WHERE id IN (11, 22)", sql)
+
+ def test_the_report_tells_custom_from_module(self):
+ # C'est LE renseignement utile : l'une se répare d'une mise à
+ # jour, l'autre ne se répare jamais toute seule.
+ texte = "\n".join(
+ fvt.render_tree([arbre(1, "custom"), arbre(2, "module")])
+ )
+ self.assertIn(
+ todo_i18n.t("custom — nothing will ever rewrite it"), texte
+ )
+ self.assertIn(
+ todo_i18n.t("from a module — a module update also fixes it"), texte
+ )
+
+ def test_no_tree_renders_nothing(self):
+ self.assertEqual(fvt.render_tree([]), [])
+
+
+class TestTheSqlAgainstARealPostgres(unittest.TestCase):
+ """Le SQL, exécuté pour de vrai.
+
+ Une assertion sur le TEXTE d'une requête ne voit pas ce qu'elle
+ fait : ajouter un `LIMIT 1` la laisserait passer alors qu'une seule
+ langue serait convertie — une vue cassée dans les autres, une panne
+ qui ne se montre qu'à certains.
+ """
+
+ BASE = "tmp_fix_view_type_test"
+
+ @classmethod
+ def setUpClass(cls):
+ import shutil as _shutil
+ import subprocess as _sub
+
+ if not _shutil.which("psql") or not _shutil.which("createdb"):
+ raise unittest.SkipTest("PostgreSQL absent")
+ _sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True)
+ fait = _sub.run(["createdb", cls.BASE], capture_output=True)
+ if fait.returncode:
+ raise unittest.SkipTest("createdb impossible")
+
+ @classmethod
+ def tearDownClass(cls):
+ import shutil as _shutil
+ import subprocess as _sub
+
+ if _shutil.which("dropdb"):
+ _sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True)
+
+ def prepare(self, colonne, valeur):
+ fvt.run_psql(
+ self.BASE, "DROP TABLE IF EXISTS ir_ui_view", read_only=False
+ )
+ fvt.run_psql(
+ self.BASE,
+ f"CREATE TABLE ir_ui_view (id serial PRIMARY KEY,"
+ f" arch_db {colonne})",
+ read_only=False,
+ )
+ fvt.run_psql(
+ self.BASE,
+ f"INSERT INTO ir_ui_view (arch_db) VALUES ({valeur})",
+ read_only=False,
+ )
+
+ def arch(self):
+ lignes = fvt.run_psql(
+ self.BASE, "SELECT arch_db::text FROM ir_ui_view"
+ )
+ return lignes[0][0] if lignes else ""
+
+ def test_every_language_is_converted(self):
+ self.prepare(
+ "jsonb",
+ '\'{"en_US": "",'
+ ' "fr_CA": ""}\'::jsonb',
+ )
+ fvt.run_psql(
+ self.BASE,
+ fvt.repair_tree_sql([arbre(1)], jsonb=True),
+ read_only=False,
+ )
+ obtenu = self.arch()
+ self.assertNotIn(""), 2)
+ self.assertEqual(obtenu.count("
"), 2)
+
+ def test_a_nested_tree_is_converted_too(self):
+ # Une liste one2many dans un formulaire : refusée autant que la
+ # racine, et invisible pour un contrôle qui ne verrait que celle-ci.
+ self.prepare(
+ "jsonb",
+ '\'{"en_US": ""}\'::jsonb',
+ )
+ fvt.run_psql(
+ self.BASE,
+ fvt.repair_tree_sql([arbre(1)], jsonb=True),
+ read_only=False,
+ )
+ self.assertNotIn("", self.arch())
+
+ def test_a_text_column_works_too(self):
+ self.prepare("text", "''")
+ fvt.run_psql(
+ self.BASE,
+ fvt.repair_tree_sql([arbre(1)], jsonb=False),
+ read_only=False,
+ )
+ self.assertEqual(self.arch(), "
")
+
+ def test_the_detection_query_runs(self):
+ # Une requête qui ne compile pas rendrait None, et l'outil
+ # dirait « base illisible » au lieu de « rien à corriger ».
+ self.prepare("jsonb", '\'{"en_US": ""}\'::jsonb')
+ fvt.run_psql(
+ self.BASE,
+ "CREATE TABLE IF NOT EXISTS ir_model_data"
+ " (id serial, model text, res_id integer, module text, name text)",
+ read_only=False,
+ )
+ fvt.run_psql(
+ self.BASE,
+ "ALTER TABLE ir_ui_view ADD COLUMN IF NOT EXISTS type text,"
+ " ADD COLUMN IF NOT EXISTS model text",
+ read_only=False,
+ )
+ self.assertEqual(fvt.find_tree(self.BASE, jsonb=True), [])
+
+
+class TestTheVersionGate(unittest.TestCase):
+ """Avant la 18, `` est parfaitement légitime."""
+
+ def setUp(self):
+ self.vrai = fvt.run_psql
+ self.ecritures = []
+
+ def tearDown(self):
+ fvt.run_psql = self.vrai
+
+ def branche(self, majeure, arbres=True):
+ def faux(database, sql, read_only=True):
+ if not read_only:
+ self.ecritures.append(sql)
+ return []
+ if "latest_version" in sql:
+ return [[f"{majeure}.0.1.3"]]
+ if "information_schema" in sql:
+ return [["jsonb"]]
+ if "WITH RECURSIVE" in sql:
+ return []
+ if " légitime casserait une vue saine.
+ self.branche(17)
+ code, sortie = self.lance(["-d", "db"])
+ self.assertEqual(code, 0)
+ self.assertEqual(self.ecritures, [])
+ self.assertIn(
+ todo_i18n.t("Every view agrees with what Odoo expects."), sortie
+ )
+
+ def test_an_18_database_reports_it(self):
+ self.branche(18)
+ code, sortie = self.lance(["-d", "db"])
+ self.assertEqual(code, 1)
+ self.assertIn("3931", sortie)
+ self.assertEqual(self.ecritures, [])
+
+ def test_an_unknown_version_stays_on_the_safe_side(self):
+ # Sans version on ne SAIT pas : ne rien toucher.
+ self.branche(None)
+ code, _ = self.lance(["-d", "db"])
+ self.assertEqual(code, 0)
+
+ def test_apply_writes_once_per_repair(self):
+ appels = {"n": 0}
+
+ def faux(database, sql, read_only=True):
+ if not read_only:
+ self.ecritures.append(sql)
+ return []
+ if "latest_version" in sql:
+ return [["18.0.1.3"]]
+ if "information_schema" in sql:
+ return [["jsonb"]]
+ if "WITH RECURSIVE" in sql:
+ return []
+ if " 0", texte)
+
+ def test_a_step_never_run_has_no_file(self):
+ self.assertIsNone(
+ status.step_log_path(
+ {"config_database_name": "essai_db"}, "9 - jamais"
+ )
+ )
+
+ def test_the_name_is_computed_in_ONE_place(self):
+ # Deux formules dériveraient, et l'écran chercherait un fichier que
+ # personne n'écrit — sans rien signaler, puisqu'un fichier absent
+ # se lit comme une étape sans journal.
+ self.assertIs(TodoUpgrade.step_slug, status.step_slug)
+
+
+class TestTheCommandOutputItself(DiskCase):
+ """Ce qui manquait vraiment : ce que les commandes ont RÉPONDU."""
+
+ def test_the_lines_land_in_the_step_log(self):
+ from script.execute import execute as ex
+
+ obj = self.upgrade()
+ obj.execute = ex.Execute()
+ obj.print_step("2 - Succeed update all addons")
+ with redirect_stdout(io.StringIO()):
+ obj.todo_upgrade_execute(
+ "echo première && echo seconde >&2", wait_at_error=False
+ )
+ obj.close_step_log()
+ texte = "\n".join(
+ status.step_log_tail(
+ {"config_database_name": "essai_db"},
+ "2 - Succeed update all addons",
+ )[0]
+ )
+ self.assertIn("première", texte)
+ # stderr aussi : c'est là que les erreurs d'Odoo se trouvent.
+ self.assertIn("seconde", texte)
+
+ def test_a_broken_sink_never_breaks_the_command(self):
+ # Journaliser est un service rendu, pas une condition de marche.
+ from script.execute import execute as ex
+
+ class PuitsCasse:
+ def write(self, texte):
+ raise OSError("disque plein")
+
+ moteur = ex.Execute()
+ moteur.log_sink = PuitsCasse()
+ with redirect_stdout(io.StringIO()):
+ status_code = moteur.exec_command_live(
+ "echo bonjour", source_erplibre=False, quiet=True
+ )
+ self.assertEqual(status_code, 0)
+
+ def test_nothing_is_logged_without_a_step(self):
+ from script.execute import execute as ex
+
+ moteur = ex.Execute()
+ self.assertIsNone(getattr(moteur, "log_sink", None))
+
+
+class TestColour(Base):
+ """Distinguer d'un coup d'œil ce qui a été LANCÉ du reste.
+
+ Une liste de commandes en texte plat se confond avec ses titres dès
+ qu'elle dépasse l'écran, et c'est justement quand elle le dépasse qu'on
+ la lit.
+ """
+
+ def rapport(self, colour):
+ dct = progression()
+ return status.render_text(dct, colour=colour)
+
+ def test_the_commands_are_coloured(self):
+ texte = self.rapport(True)
+ self.assertIn(status.ANSI["cmd"], texte)
+
+ def test_a_verdict_wears_the_colour_of_its_meaning(self):
+ # 1 n'est pas une panne : c'est « il y a quelque chose à regarder ».
+ # Le peindre en rouge inquiéterait pour rien.
+ self.assertEqual(status.VERDICT_COLOUR[0], "ok")
+ self.assertEqual(status.VERDICT_COLOUR[1], "warn")
+ self.assertEqual(status.VERDICT_COLOUR[2], "fail")
+
+ def test_a_failed_command_is_red(self):
+ self.assertIn(status.ANSI["fail"], self.rapport(True))
+
+ def test_every_colour_is_closed(self):
+ # Une séquence ouverte teinte tout ce qui suit, y compris l'invite
+ # du terminal une fois l'outil terminé.
+ texte = self.rapport(True)
+ ouvertures = sum(texte.count(code) for code in status.ANSI.values())
+ self.assertEqual(texte.count(status.RESET), ouvertures)
+
+ def test_plain_output_carries_no_escape_at_all(self):
+ self.assertNotIn("\033", self.rapport(False))
+
+ def test_paint_returns_the_text_untouched_when_off(self):
+ self.assertEqual(status.paint("make", "cmd", False), "make")
+
+ def test_an_unknown_colour_never_invents_one(self):
+ self.assertEqual(status.paint("make", "mauve", True), "make")
+
+
+class TestWhenColourWouldBeAMistake(Base):
+ """Un tube n'est pas un écran, et chacun de ces refus a coûté.
+
+ Un fichier de journal truffé de codes d'échappement, un `grep` qui ne
+ trouve plus rien, un terminal qui les affiche en clair.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.avant = {cle: os.environ.get(cle) for cle in ("NO_COLOR", "TERM")}
+
+ def remettre():
+ for cle, valeur in self.avant.items():
+ if valeur is None:
+ os.environ.pop(cle, None)
+ else:
+ os.environ[cle] = valeur
+
+ self.addCleanup(remettre)
+
+ class FauxEcran:
+ def __init__(self, tty):
+ self.tty = tty
+
+ def isatty(self):
+ return self.tty
+
+ def test_a_pipe_gets_no_colour(self):
+ os.environ["TERM"] = "xterm"
+ os.environ.pop("NO_COLOR", None)
+ self.assertFalse(status.supports_colour(self.FauxEcran(False)))
+
+ def test_a_terminal_does(self):
+ os.environ["TERM"] = "xterm"
+ os.environ.pop("NO_COLOR", None)
+ self.assertTrue(status.supports_colour(self.FauxEcran(True)))
+
+ def test_NO_COLOR_is_honoured(self):
+ # Convention respectée par la plupart des outils : la contredire
+ # oblige à nettoyer une sortie à la main.
+ os.environ["TERM"] = "xterm"
+ os.environ["NO_COLOR"] = "1"
+ self.assertFalse(status.supports_colour(self.FauxEcran(True)))
+
+ def test_a_dumb_terminal_gets_none(self):
+ os.environ.pop("NO_COLOR", None)
+ os.environ["TERM"] = "dumb"
+ self.assertFalse(status.supports_colour(self.FauxEcran(True)))
+
+ def test_a_stream_that_cannot_answer_gets_none(self):
+ os.environ["TERM"] = "xterm"
+ os.environ.pop("NO_COLOR", None)
+
+ class Muet:
+ def isatty(self):
+ raise OSError("fermé")
+
+ self.assertFalse(status.supports_colour(Muet()))
+
+ def test_the_report_decides_by_itself_when_not_told(self):
+ import inspect
+
+ source = inspect.getsource(status.render_text)
+ self.assertIn("colour = supports_colour()", source)
+
+
+class TestTheFullScreenColoursToo(Base):
+ def test_the_pane_can_be_coloured(self):
+ lst = tui.rows(progression())
+ etape = [x for x in lst if x["kind"] == "step"][0]
+ self.assertIn(
+ status.ANSI["cmd"],
+ tui.pane_text(progression(), etape, colour=True),
+ )
+
+ def test_it_stays_plain_by_default(self):
+ # `pane_text` sert aussi aux tests et au repli texte.
+ lst = tui.rows(progression())
+ etape = [x for x in lst if x["kind"] == "step"][0]
+ self.assertNotIn("\033", tui.pane_text(progression(), etape))
+
+ def test_rich_decodes_the_colours_instead_of_showing_them(self):
+ from rich.text import Text
+
+ lst = tui.rows(progression())
+ etape = [x for x in lst if x["kind"] == "step"][0]
+ texte = Text.from_ansi(
+ tui.pane_text(progression(), etape, colour=True)
+ )
+ self.assertNotIn("\033", texte.plain)
+ self.assertTrue(texte.spans)
+
+ def test_a_bracket_in_a_command_is_NOT_eaten_as_markup(self):
+ # Piège latent d'avant la couleur : Textual interprète le balisage
+ # Rich, et « [1] » disparaissait sans que rien ne le signale.
+ from rich.text import Text
+
+ dct = progression(
+ command_executed=["# 2 - Update", "make config [1] et [/bold]"]
+ )
+ lst = tui.rows(dct)
+ etape = [x for x in lst if x["kind"] == "step"][0]
+ texte = Text.from_ansi(tui.pane_text(dct, etape, colour=True))
+ self.assertIn("[1]", texte.plain)
+ self.assertIn("[/bold]", texte.plain)
+
+ def test_the_screen_goes_through_from_ansi(self):
+ import inspect
+
+ # Le rendu est sorti de la classe pour la rendre testable : c'est
+ # `show_pane` qui porte maintenant les deux propriétés.
+ source = inspect.getsource(tui.show_pane)
+ self.assertIn("Text.from_ansi", source)
+ self.assertIn("colour=True", source)
+
+
+class TestHowLongItTook(Base):
+ """Du premier écrit à la dernière écriture du journal.
+
+ La progression est réécrite après chaque geste, donc sa date de mise à
+ jour EST la fin — ou l'instant présent si la migration tourne encore.
+ """
+
+ def dct(self, debut, fin):
+ return progression(date_create=debut, date_update=fin)
+
+ def test_it_counts_from_the_start_to_the_last_write(self):
+ info = status.overview(
+ self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18")
+ )
+ self.assertEqual(info["elapsed"], "1 h 12 min")
+
+ def test_a_short_run_is_given_in_minutes(self):
+ info = status.overview(
+ self.dct("2026-08-19 09:06:00", "2026-08-19 09:41:00")
+ )
+ self.assertEqual(info["elapsed"], "35 min")
+
+ def test_a_long_run_is_given_in_days(self):
+ info = status.overview(
+ self.dct("2026-08-17 09:00:00", "2026-08-19 11:30:00")
+ )
+ self.assertIn("j", info["elapsed"])
+
+ def test_a_missing_date_never_invents_a_duration(self):
+ self.assertEqual(status.overview(self.dct(None, None))["elapsed"], "?")
+
+ def test_the_report_shows_it(self):
+ texte = status.render_text(
+ self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18"),
+ colour=False,
+ )
+ self.assertIn("1 h 12 min", texte)
+
+ def test_the_full_screen_shows_it_too(self):
+ texte = tui.head_text(
+ self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18")
+ )
+ self.assertIn("1 h 12 min", texte)
+
+ def test_it_is_computed_in_ONE_place(self):
+ # L'écran de statistiques porte déjà ce calcul. Deux formules
+ # donneraient deux durées pour la même migration selon l'écran
+ # qu'on ouvre.
+ import inspect
+
+ source = inspect.getsource(status.elapsed)
+ self.assertIn(
+ "from script.todo.migration_stats import fmt_delay", source
+ )
+
+
+class TestReadingTheLogForTheUser(DiskCase):
+ """Compter les erreurs à la place de quelqu'un.
+
+ Un journal d'étape atteint treize mégaoctets — mesuré. Personne ne le
+ lit, et la question qu'on se pose devant lui tient en deux mots : où
+ est-ce que ça a mal tourné ? C'est cette question-là que l'écran doit
+ savoir répondre sans qu'on ouvre le fichier.
+ """
+
+ JOURNAL = (
+ "2026-08-19 09:21:07,074 132948 ERROR base13"
+ " odoo.tools.translate: couldn't read translation file\n"
+ "Traceback (most recent call last):\n"
+ "2026-08-19 09:34:27,088 133771 ERROR base14"
+ " odoo.modules.registry: Model account.bank.statement.import"
+ " has no table.\n"
+ "2026-08-19 09:34:28,088 133771 ERROR base14"
+ " odoo.modules.registry: Model account.bank.statement.import"
+ " has no table.\n"
+ "2026-08-19 09:44:51,959 134592 CRITICAL base15"
+ " odoo.service.server: Failed to initialize database.\n"
+ "2026-08-19 09:44:52,000 134592 WARNING base15"
+ " odoo.schema: unable to add constraint\n"
+ "2026-08-19 09:44:53,000 134592 INFO base15"
+ " odoo.modules.loading: Modules loaded.\n"
+ )
+
+ def poser(self, contenu=None, etape="4 - Upgrade"):
+ obj = self.upgrade()
+ obj.add_comment_progression(etape)
+ obj.close_step_log()
+ chemin = os.path.join(
+ "private",
+ "odoo",
+ "migration",
+ "essai_db",
+ "step_log",
+ status.step_slug(etape) + ".log",
+ )
+ with open(chemin, "w") as handle:
+ handle.write(contenu if contenu is not None else self.JOURNAL)
+ # La progression de l'objet, pas un dict nu : c'est elle qui porte
+ # `command_executed`, donc le découpage par étape.
+ return obj.dct_progression
+
+ def test_it_counts_the_severities(self):
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ self.assertEqual(scan["count"]["ERROR"], 3)
+ self.assertEqual(scan["count"]["CRITICAL"], 1)
+ self.assertEqual(scan["count"]["WARNING"], 1)
+ self.assertEqual(scan["count"]["TRACEBACK"], 1)
+
+ def test_the_severe_ones_are_summed(self):
+ # ERROR et CRITICAL, pas WARNING : une migration en produit des
+ # milliers, et un compte qui les inclut ne veut plus rien dire.
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ self.assertEqual(status.severe_count(scan), 4)
+
+ def test_the_same_message_is_counted_ONCE(self):
+ # Quarante-huit fois « Model X has no table » est UN problème vu
+ # quarante-huit fois, pas quarante-huit problèmes.
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ modele = [e for e in scan["errors"] if "has no table" in e["message"]]
+ self.assertEqual(len(modele), 1)
+ self.assertEqual(modele[0]["times"], 2)
+
+ def test_the_loudest_comes_first(self):
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ self.assertEqual(scan["errors"][0]["times"], 2)
+
+ def test_each_error_carries_its_database(self):
+ # Six paliers ont pu écrire dans le même fichier : sans la base,
+ # on ne sait pas lequel a souffert.
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ bases = {e["database"] for e in scan["errors"]}
+ self.assertEqual(bases, {"base13", "base14", "base15"})
+
+ def test_it_carries_the_logger_too(self):
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ loggers = {e["logger"] for e in scan["errors"]}
+ self.assertIn("odoo.tools.translate", loggers)
+
+ def test_warnings_are_never_listed_as_errors(self):
+ scan = status.step_log_scan(self.poser(), "4 - Upgrade")
+ self.assertNotIn(
+ "unable to add constraint",
+ " ".join(e["message"] for e in scan["errors"]),
+ )
+
+ def test_a_step_without_a_log_counts_zero(self):
+ self.assertEqual(
+ status.severe_count(
+ status.step_log_scan(
+ {"config_database_name": "essai_db"}, "9 - rien"
+ )
+ ),
+ 0,
+ )
+
+ def test_a_healthy_log_shows_no_alarm(self):
+ dct = self.poser(
+ "2026-08-19 09:00:00,000 1 INFO db odoo.modules: Modules loaded.\n"
+ )
+ self.assertEqual(
+ status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 0
+ )
+ # La LIGNE d'étape, pas tout le rapport : celui-ci porte toujours
+ # l'en-tête « Commandes en échec », qui a le même symbole.
+ etapes = status.render_text(dct, colour=False).split("step by step")[1]
+ self.assertNotIn("❌", etapes)
+
+ def test_the_report_names_the_count_and_the_top_errors(self):
+ texte = status.render_text(self.poser(), colour=False)
+ self.assertIn("❌ 4", texte)
+ self.assertIn("has no table", texte)
+
+ def test_the_full_screen_lists_them_with_their_source(self):
+ dct = self.poser()
+ etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0]
+ texte = tui.pane_text(dct, etape, show_log=False)
+ self.assertIn("×2", texte)
+ self.assertIn("odoo.modules.registry", texte)
+ self.assertIn("base14", texte)
+
+ def test_the_left_column_shows_the_count(self):
+ dct = self.poser()
+ etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0]
+ self.assertEqual(etape["severe"], "4")
+
+ def test_a_clean_step_leaves_the_column_empty(self):
+ # Un « 0 » dans chaque ligne n'apprend rien et occupe la place.
+ dct = self.poser(
+ "2026-08-19 09:00:00,000 1 INFO db odoo.modules: ok\n"
+ )
+ etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0]
+ self.assertEqual(etape["severe"], "")
+
+
+class TestReadingItTwiceIsFree(DiskCase):
+ """L'écran se redessine à chaque touche.
+
+ Relire treize mégaoctets à chaque frappe rendrait l'écran inutilisable.
+ """
+
+ def test_the_second_read_does_not_touch_the_disk(self):
+ obj = self.upgrade()
+ obj.add_comment_progression("4 - Upgrade")
+ obj.close_step_log()
+ chemin = os.path.join(
+ "private",
+ "odoo",
+ "migration",
+ "essai_db",
+ "step_log",
+ status.step_slug("4 - Upgrade") + ".log",
+ )
+ with open(chemin, "w") as handle:
+ handle.write("2026-08-19 09:00:00,000 1 ERROR db odoo.x: boum\n")
+ dct = {"config_database_name": "essai_db"}
+ premier = status.step_log_scan(dct, "4 - Upgrade")
+ lectures = []
+ vrai_open = open
+
+ def compter(*args, **kwargs):
+ lectures.append(args[0])
+ return vrai_open(*args, **kwargs)
+
+ import builtins
+
+ builtins.open = compter
+ self.addCleanup(setattr, builtins, "open", vrai_open)
+ second = status.step_log_scan(dct, "4 - Upgrade")
+ self.assertEqual(premier, second)
+ self.assertEqual([x for x in lectures if str(x).endswith(".log")], [])
+
+ def test_a_changed_file_IS_read_again(self):
+ # Sinon « r » ne rafraîchirait rien : la migration écrit pendant
+ # qu'on regarde.
+ obj = self.upgrade()
+ obj.add_comment_progression("4 - Upgrade")
+ obj.close_step_log()
+ chemin = os.path.join(
+ "private",
+ "odoo",
+ "migration",
+ "essai_db",
+ "step_log",
+ status.step_slug("4 - Upgrade") + ".log",
+ )
+ dct = {"config_database_name": "essai_db"}
+ with open(chemin, "w") as handle:
+ handle.write("2026-08-19 09:00:00,000 1 ERROR db odoo.x: un\n")
+ self.assertEqual(
+ status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 1
+ )
+ with open(chemin, "a") as handle:
+ handle.write("2026-08-19 09:00:01,000 1 ERROR db odoo.y: deux\n")
+ self.assertEqual(
+ status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 2
+ )
+
+
+class TestSeparatingCommandsFromLogs(Base):
+ """Les deux mélangés dans un même panneau se confondent.
+
+ La liste des commandes dit ce qui a été LANCÉ ; le journal dit ce que
+ cela a RÉPONDU. Ce sont deux lectures différentes, et l'une noie
+ l'autre : un `update_addons_all` écrit des dizaines de milliers de
+ lignes au-dessus desquelles trois commandes disparaissent.
+ """
+
+ def dct(self):
+ return progression(command_executed=["# 2 - Update", "./run.sh -d db"])
+
+ def etape(self, dct):
+ return [x for x in tui.rows(dct) if x["kind"] == "step"][0]
+
+ def test_hiding_the_log_keeps_the_commands(self):
+ dct = self.dct()
+ texte = tui.pane_text(dct, self.etape(dct), show_log=False)
+ self.assertIn("./run.sh -d db", texte)
+
+ def test_hiding_it_never_hides_it_SILENTLY(self):
+ # Un panneau qui se vide sans un mot se lit comme « il n'y a rien »,
+ # ce qui est exactement le contraire de ce qui vient de se passer.
+ import tempfile
+
+ dossier = tempfile.mkdtemp(prefix="erplibre_essai_")
+ avant = os.getcwd()
+ os.chdir(dossier)
+ self.addCleanup(shutil.rmtree, dossier, True)
+ self.addCleanup(os.chdir, avant)
+ chemin = os.path.join("private", "odoo", "migration", "db", "step_log")
+ os.makedirs(chemin)
+ with open(
+ os.path.join(chemin, status.step_slug("2 - Update") + ".log"), "w"
+ ) as handle:
+ handle.write("\n".join(f"ligne {i}" for i in range(50)))
+ dct = self.dct()
+ dct["config_database_name"] = "db"
+ texte = tui.pane_text(dct, self.etape(dct), show_log=False)
+ self.assertIn("50", texte)
+ self.assertNotIn("ligne 49", texte)
+
+ def test_showing_it_says_how_much_is_cut(self):
+ # « il manque des logs » venait de là : on montrait la fin sans
+ # dire qu'on cachait le début.
+ lst, total = ([f"l{i}" for i in range(400)], 12843)
+ self.assertLess(len(lst), total)
+
+ def test_the_tail_reports_the_total(self):
+ import tempfile
+
+ dossier = tempfile.mkdtemp(prefix="erplibre_essai_")
+ avant = os.getcwd()
+ os.chdir(dossier)
+ self.addCleanup(shutil.rmtree, dossier, True)
+ self.addCleanup(os.chdir, avant)
+ chemin = os.path.join("private", "odoo", "migration", "db", "step_log")
+ os.makedirs(chemin)
+ with open(
+ os.path.join(chemin, status.step_slug("2 - Update") + ".log"), "w"
+ ) as handle:
+ handle.write("\n".join(f"ligne {i}" for i in range(1000)))
+ lst, total = status.step_log_tail(
+ {"config_database_name": "db"}, "2 - Update", lines=400
+ )
+ self.assertEqual(total, 1000)
+ self.assertEqual(len(lst), 400)
+ self.assertEqual(lst[-1], "ligne 999")
+
+ def test_a_step_without_a_log_reports_zero(self):
+ self.assertEqual(
+ status.step_log_tail({"config_database_name": "db"}, "9 - rien"),
+ ([], 0),
+ )
+
+
+class TestTheThreePanelStates(Base):
+ """Un simple bascule ne libérait que quatre lignes.
+
+ Ce qui prend la place, c'est la COLONNE de gauche — et sur un journal
+ de serveur, chaque caractère gagné en largeur compte. D'où trois états,
+ du plus complet au plus dépouillé.
+ """
+
+ def test_the_first_state_shows_everything(self):
+ self.assertEqual(tui.panel_visibility(0), (True, True))
+
+ def test_the_second_drops_the_summary(self):
+ self.assertEqual(tui.panel_visibility(1), (False, True))
+
+ def test_the_third_leaves_the_detail_alone(self):
+ self.assertEqual(tui.panel_visibility(2), (False, False))
+
+ def test_it_comes_back_round(self):
+ # Sans retour, le troisième état serait un cul-de-sac : la liste
+ # cachée, on ne peut plus changer d'entrée.
+ self.assertEqual(tui.panel_visibility(3), tui.panel_visibility(0))
+
+ def test_there_are_exactly_three(self):
+ self.assertEqual(len(tui.PANELS), 3)
+
+ def test_each_state_has_a_name(self):
+ # Un panneau qui disparaît sans un mot se lit comme un écran cassé.
+ noms = {tui.panel_label(i) for i in range(3)}
+ self.assertEqual(len(noms), 3)
+ for nom in noms:
+ self.assertTrue(nom.strip())
+
+ def test_p_is_bound_to_the_cycle(self):
+ app = tui.build_app(progression())
+ action = [
+ entree[1] for entree in app.BINDINGS if "p" in entree[0].split(",")
+ ]
+ self.assertEqual(action, ["cycle_panels"])
+
+ def test_the_name_is_shown_where_it_stays_visible(self):
+ # Le sous-titre est dans l'en-tête de Textual, qui reste affiché
+ # dans les trois états — contrairement au panneau de résumé.
+ import inspect
+
+ source = inspect.getsource(tui.apply_panels)
+ self.assertIn("app.sub_title", source)
+
+
+class TestTheGuardsThatCameOutOfTheClass(Base):
+ def test_no_row_selected_is_not_an_error(self):
+ self.assertIsNone(tui.current_row([], 0))
+
+ def test_an_index_past_the_end_is_not_an_error(self):
+ # Un rafraîchissement peut raccourcir la liste sous le curseur.
+ self.assertIsNone(tui.current_row([{"a": 1}], 5))
+
+ def test_rereading_without_a_path_yields_nothing(self):
+ self.assertIsNone(tui.reread(None))
+
+ def test_the_width_stays_between_its_bounds(self):
+ app = tui.build_app(progression())
+ self.assertLess(app.LEFT_MIN, app.LEFT_MAX)
+ self.assertGreaterEqual(app.left_width, app.LEFT_MIN)
+ self.assertLessEqual(app.left_width, app.LEFT_MAX)
+
+
+class TestTheKeyboard(Base):
+ """Ce que l'écran promet dans son pied de page doit exister."""
+
+ def app(self):
+ return tui.build_app(progression(), path="/un/chemin.json")
+
+ def touches(self):
+ return {
+ touche
+ for entree in self.app().BINDINGS
+ for touche in entree[0].split(",")
+ }
+
+ def test_every_promised_key_is_bound(self):
+ attendues = {"q", "escape", "r", "l", "p", "plus", "minus"}
+ self.assertTrue(attendues <= self.touches(), self.touches())
+
+ def test_each_binding_has_an_action_that_exists(self):
+ # Un raccourci annoncé dont l'action manque échoue à la frappe,
+ # c'est-à-dire au pire moment.
+ app = self.app()
+ for _touches, action, _libelle in app.BINDINGS:
+ self.assertTrue(hasattr(app, f"action_{action}"), action)
+
+ def test_the_width_is_bounded_on_both_sides(self):
+ # Une colonne de zéro ne se retrouve plus ; une qui mange tout
+ # l'écran ne laisse rien à lire.
+ app = self.app()
+ for _ in range(50):
+ app.left_width = max(
+ app.LEFT_MIN, min(app.LEFT_MAX, app.left_width - app.LEFT_STEP)
+ )
+ self.assertEqual(app.left_width, app.LEFT_MIN)
+ for _ in range(50):
+ app.left_width = max(
+ app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + app.LEFT_STEP)
+ )
+ self.assertEqual(app.left_width, app.LEFT_MAX)
+
+ def test_refreshing_rereads_the_disk(self):
+ """La migration ÉCRIT pendant qu'on regarde.
+
+ Sans cela il fallait fermer et rouvrir l'écran pour voir le palier
+ suivant — sur une migration de plusieurs heures, on le fait.
+ """
+ import inspect
+
+ # La relecture est sortie de la classe pour la rendre testable :
+ # on vérifie donc les DEUX moitiés, la lecture et le remontage.
+ self.assertIn("status.read(path)", inspect.getsource(tui.reread))
+ self.assertIn(
+ "self.lst_row = rows(self.dct)", inspect.getsource(tui.build_app)
+ )
+
+ def test_refreshing_without_a_path_does_nothing(self):
+ app = tui.build_app(progression(), path=None)
+ app.action_refresh() # ne doit pas lever
+
+ def test_the_log_toggle_flips(self):
+ app = self.app()
+ self.assertTrue(app.show_log)
+
+
+class TestTheStatisticsScreenOffersIt(Base):
+ """L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ».
+
+ L'état, lui, répond « où en est-on, et qu'est-ce qui a cassé ». Deux
+ questions voisines, posées au même moment : les séparer par un menu
+ plutôt que par deux commandes à retenir est ce qui les rend
+ utilisables.
+ """
+
+ def stats(self, lst_answer):
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ self.vu = []
+ obj.show_migration_status = lambda: self.vu.append("ouvert")
+ reponses = iter(lst_answer)
+ obj.ask = lambda prompt, default="": next(reponses)
+ obj.print_stats = staticmethod(lambda ctx, stats: None)
+ obj.read_progression = staticmethod(
+ lambda: {"config_database_name": "db"}
+ )
+ obj.resume_context = lambda dct: {"steps": [], "versions": []}
+ return obj
+
+ def test_t_opens_the_full_screen(self):
+ import script.todo.todo_upgrade as tu
+
+ obj = self.stats(["t", "0"])
+ original_exists = tu.os.path.exists
+ tu.os.path.exists = lambda path: True
+ self.addCleanup(setattr, tu.os.path, "exists", original_exists)
+ import script.todo.migration_stats as ms
+
+ original = ms.compute
+ ms.compute = lambda *a, **kw: {
+ "uninstall": {},
+ "journal": {"comments": [], "commands": []},
+ "origin_count": 0,
+ "evolution": [],
+ "removed_total": 0,
+ "missing": [],
+ "duplicate": [],
+ "fixes": [],
+ "cow": [],
+ "delay": "0s",
+ }
+ self.addCleanup(setattr, ms, "compute", original)
+ with redirect_stdout(io.StringIO()):
+ obj.show_stats()
+ self.assertEqual(self.vu, ["ouvert"])
+
+ def test_the_menu_announces_it(self):
+ import inspect
+
+ source = inspect.getsource(TodoUpgrade.show_stats)
+ self.assertIn("Migration state (full screen)", source)
+ self.assertIn('answer == "t"', source)
+
+ def test_it_is_the_SAME_letter_as_the_prompts(self):
+ # Une lettre qui change de sens d'un écran à l'autre ne s'apprend
+ # pas. Les deux endroits doivent tester la même.
+ import inspect
+
+ self.assertIn(
+ 'reponse == "t"', inspect.getsource(TodoUpgrade.ask_gate)
+ )
+ self.assertIn(
+ 'answer == "t"', inspect.getsource(TodoUpgrade.show_stats)
+ )
+
+ def test_looking_returns_to_the_statistics(self):
+ # Comme dans les invites : regarder n'est pas répondre.
+ import inspect
+
+ source = inspect.getsource(TodoUpgrade.show_stats)
+ debut = source.index('answer == "t"')
+ self.assertIn("continue", source[debut : debut + 400])
+
+
+class TestNothingOnThatScreenCanHang(Base):
+ """L'écran est atteint APRÈS la question d'auto-exécution.
+
+ `ask_ui` est la toute première question posée ensuite, et elle était un
+ `input()` nu : une migration automatique s'arrêtait là, avant même
+ d'avoir commencé, sans que rien ne le signale.
+ """
+
+ def test_the_interface_question_goes_through_the_timer(self):
+ import inspect
+
+ source = inspect.getsource(TodoUpgrade.ask_ui)
+ self.assertIn("auto_ask.ask(", source)
+ # L'APPEL, pas le mot : le commentaire du correctif nomme lui-même
+ # ce qu'il remplace, et le chercher à l'aveugle se déclenche sur la
+ # prose. Deuxième fois que ce piège se referme sur moi.
+ self.assertNotIn("= input(", source)
+
+ def test_the_statistics_menu_too(self):
+ import inspect
+
+ source = inspect.getsource(TodoUpgrade.show_stats)
+ self.assertIn("self.ask(", source)
+ self.assertNotIn("= input(", source)
+
+ def test_the_default_interface_is_still_the_form(self):
+ # Le défaut ne doit pas changer en passant par le lecteur temporisé.
+ import script.todo.todo_prefs as prefs
+
+ original = prefs.get
+ prefs.get = lambda key: "ask"
+ self.addCleanup(setattr, prefs, "get", original)
+ from script.todo import auto_ask
+
+ avant = os.environ.pop(auto_ask.ENV_ENABLED, None)
+ if avant is not None:
+ self.addCleanup(
+ os.environ.__setitem__, auto_ask.ENV_ENABLED, avant
+ )
+ import builtins
+
+ original_input = builtins.input
+ builtins.input = lambda prompt="": ""
+ self.addCleanup(setattr, builtins, "input", original_input)
+ with redirect_stdout(io.StringIO()):
+ self.assertEqual(TodoUpgrade.ask_ui(), "tui")
+
+
+class TestWhatGetsRecorded(Base):
+ def upgrade(self):
+ obj = TodoUpgrade.__new__(TodoUpgrade)
+ obj.dct_progression = {}
+ obj.lst_command_executed = []
+ obj.write_config = lambda: None
+ obj.current_step = "4.2.I - Migrate database"
+ return obj
+
+ def test_a_tool_run_keeps_its_verdict(self):
+ obj = self.upgrade()
+ obj.run_on_terminal = lambda cmd: 1
+ self.assertEqual(obj.run_tool("smoke_public_url", "cmd"), 1)
+ event = obj.dct_progression["lst_event"][0]
+ self.assertEqual(event["kind"], "test")
+ self.assertEqual(event["status"], 1)
+ self.assertEqual(event["step"], "4.2.I - Migrate database")
+
+ def test_the_list_is_bounded(self):
+ # Une migration lance des centaines de commandes ; un fichier qui
+ # enfle sans limite coûte plus cher à écrire qu'à lire.
+ obj = self.upgrade()
+ for index in range(TodoUpgrade.MAX_EVENT + 25):
+ obj.record_event("test", f"outil{index}", 0)
+ self.assertEqual(
+ len(obj.dct_progression["lst_event"]), TodoUpgrade.MAX_EVENT
+ )
+
+ def test_the_newest_survive_the_trimming(self):
+ obj = self.upgrade()
+ for index in range(TodoUpgrade.MAX_EVENT + 3):
+ obj.record_event("test", f"outil{index}", 0)
+ dernier = obj.dct_progression["lst_event"][-1]
+ self.assertEqual(dernier["name"], f"outil{TodoUpgrade.MAX_EVENT + 2}")
+
+ def test_the_step_is_captured_when_it_is_printed(self):
+ import inspect
+
+ source = inspect.getsource(TodoUpgrade.print_step)
+ self.assertIn("self.current_step = msg", source)
+
+ def test_a_failing_command_is_recorded_before_the_prompt(self):
+ # Si l'on répond ctrl+c, l'échec doit tout de même figurer dans
+ # l'état : c'est précisément celui qu'on cherchera en revenant.
+ import inspect
+
+ # Le menu d'erreur vit dans `_prompt_on_error`, extrait de
+ # `todo_upgrade_execute` quand celui-ci a passé le seuil de
+ # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
+ # éprouve, pas une méthode en particulier.
+ source = inspect.getsource(
+ TodoUpgrade.todo_upgrade_execute
+ ) + inspect.getsource(TodoUpgrade._prompt_on_error)
+ self.assertLess(
+ source.index('record_event("command"'),
+ source.index("Error detected"),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_migration_tools_i18n.py b/test/test_migration_tools_i18n.py
new file mode 100644
index 0000000..6d07c4a
--- /dev/null
+++ b/test/test_migration_tools_i18n.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Les outils de migration parlaient anglais sur un système en français.
+
+Ils tournent en sous-processus depuis le pilote, et n'avaient aucun i18n : la
+migration alternait donc les deux langues d'une ligne à l'autre.
+
+Deux choses à tenir, et la seconde est un piège : le pilote CHERCHAIT des
+phrases anglaises dans leur sortie pour décider s'il devait poser une question
+(« will break when moving to », « No website COW view to neutralize »).
+Traduire ces phrases l'aurait rendu muet — sans erreur, sans trace. Le lien
+passe désormais par le code de sortie, que la langue ne touche pas.
+"""
+
+import ast
+import os
+import subprocess
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# Les outils COW, ceux que la migration lance et dont la sortie est lue par un
+# humain au milieu d'une migration de plusieurs heures.
+TOOLS = (
+ "check_cow_views.py",
+ "cow_drift.py",
+ "neutralize_cow_views.py",
+ "reset_stale_cow_views.py",
+ "snapshot_cow_views.py",
+)
+
+# Ce qui n'est pas de la prose : du SQL, des chemins, des drapeaux.
+NOT_PROSE = ("SELECT ", "UPDATE ", "./script/", "--", "id=", "