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]*?", " ", 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=", "