From 068022db82f634ef64fda371bc476e6e2a7a0a64 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 00:46:42 -0400 Subject: [PATCH] [ADD] migration: repair views whose stored type contradicts their parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo refused to load a database: it validated a arch with tree rules. The view is not a COW copy -- the COW tools were right to say they had nothing to reset -- its stored `type` simply lies. Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in `create`, and only when absent; `write` never recomputes it. A wrong value stays wrong, and `-u module` fails on the validation it causes before it could rewrite anything. Plain SQL, no ORM: the registry is what will not load, and a repair that needed Odoo to fix what stops Odoo would be useless. The rule is Odoo's own -- an inherited view takes its parent's type -- and it holds: zero disagreement on a pristine 18 install and on four migrated databases, exactly one on the database that refused to load. --- FR --- Odoo refusait de charger une base : il validait un arch avec les règles d'un tree. La vue n'est pas une copie COW — les outils COW avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son `type` stocké qui ment. Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli dans `create`, et seulement s'il est absent ; `write` ne le recalcule jamais. Une valeur fausse le reste, et `-u module` échoue sur la validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit. En SQL, sans ORM : c'est le registre qui ne charge plus, et une réparation qui aurait besoin d'Odoo ne servirait à rien. La règle est celle d'Odoo — une vue héritée prend le type de son parent — et elle tient : zéro écart sur une 18 neuve et sur quatre bases migrées, exactement un sur celle qui refusait de charger. Assisted-by: Claude Opus 5 --- script/odoo/migration/fix_view_type.py | 203 +++++++++++++++++++ script/todo/todo_i18n.py | 216 ++++++++++++++++++++ script/todo/todo_upgrade.py | 265 ++++++++++++++++++------- test/test_error_retry_loop.py | 8 +- test/test_fix_view_type.py | 264 ++++++++++++++++++++++++ test/test_migration_status.py | 8 +- test/test_reset_stale_cow_prompt.py | 8 +- test/test_reset_stale_cow_tui.py | 16 +- 8 files changed, 910 insertions(+), 78 deletions(-) create mode 100755 script/odoo/migration/fix_view_type.py create mode 100644 test/test_fix_view_type.py diff --git a/script/odoo/migration/fix_view_type.py b/script/odoo/migration/fix_view_type.py new file mode 100755 index 0000000..5022cea --- /dev/null +++ b/script/odoo/migration/fix_view_type.py @@ -0,0 +1,203 @@ +#!/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. + +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. + +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 +""" + + +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 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 [f"✅ {t('Every view type agrees with its inheritance.')}"] + 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 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 + if not vues: + print("\n".join(render(vues))) + return 0 + if not config.apply: + print("\n".join(render(vues))) + return 1 + sql = repair_sql(vues) + if run_psql(config.database, sql, read_only=False) is None: + print(f"❌ {t('The correction failed.')}") + return 2 + reste = find(config.database) + if reste: + print(f"❌ {t('The correction failed.')}") + return 2 + print("\n".join(render(vues, applique=True))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a7ff7cf..db7d39b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5955,6 +5955,222 @@ TRANSLATIONS = { "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.", + }, + "Every view type agrees with its inheritance.": { + "fr": "Tous les types de vue s'accordent avec leur héritage.", + "en": "Every view type agrees with its inheritance.", + }, + "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?", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 6f68877..2ace1bc 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2848,6 +2848,195 @@ class TodoUpgrade: 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. + """ + 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')}" + ) + # `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 == "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") + status, _cmd = self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{outil} -d {database}" + ) + 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" + ) + 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( @@ -3679,79 +3868,9 @@ class TodoUpgrade: # n'existent pas et le défaut redevient « continuer ». defaut = "3" if database_name else "" repare = False - 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')}") - # `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 == "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 + wait_status, repare = self._prompt_on_error( + database_name, defaut, repare + ) if wait_status == "1": # Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py index d2554be..437ee62 100644 --- a/test/test_error_retry_loop.py +++ b/test/test_error_retry_loop.py @@ -176,7 +176,13 @@ class TestTheHumanKeepsTheWheel(Harness): def test_the_prompt_says_what_enter_does(self): import inspect - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # 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) diff --git a/test/test_fix_view_type.py b/test/test_fix_view_type.py new file mode 100644 index 0000000..43263b5 --- /dev/null +++ b/test/test_fix_view_type.py @@ -0,0 +1,264 @@ +#!/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_database_says_so(self): + texte = "\n".join(fvt.render([])) + self.assertIn( + todo_i18n.t("Every view type agrees with its inheritance."), texte + ) + + 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) + + +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_error_menu_offers_the_entry(self): + src = self.source() + self.assertIn("Fix views whose type contradicts their", src) + self.assertIn('if wait_status == "5" and database_name:', src) + self.assertIn("def prompt_fix_view_type", src) + + def test_it_runs_the_tool_twice_report_then_apply(self): + src = self.source() + debut = src.index("def prompt_fix_view_type") + fin = src.index("def prompt_purge_dead_attachments") + bloc = src[debut:fin] + self.assertEqual(bloc.count("fix_view_type.py"), 1) + self.assertIn("--apply", bloc) + + def test_only_a_finding_leads_to_the_question(self): + # 0 « rien à corriger » et 2 « l'outil a échoué » ne justifient + # ni la question ni le rejeu de la commande. + src = self.source() + debut = src.index("def prompt_fix_view_type") + fin = src.index("def prompt_purge_dead_attachments") + self.assertIn("if status != 1:", src[debut:fin]) + + def test_every_menu_entry_has_its_branch(self): + src = self.source() + debut = src.index("def _prompt_on_error") + fin = src.index("def prompt_fix_view_type") + bloc = src[debut:fin] + for rang in ("1", "2", "3", "4", "5"): + self.assertTrue( + f'f"[{rang}] ' in bloc, f"entrée [{rang}] absente du menu" + ) + for rang in ("2", "3", "4", "5"): + self.assertTrue( + f'wait_status == "{rang}"' in bloc, + f"aiguillage [{rang}] absent", + ) + + +class TestTranslations(unittest.TestCase): + def test_every_key_exists(self): + with io.open(fvt.__file__, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + for node in ast.walk(arbre): + 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}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 53ccef3..62c486c 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1594,7 +1594,13 @@ class TestWhatGetsRecorded(Base): # l'état : c'est précisément celui qu'on cherchera en revenant. import inspect - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # 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"), diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py index 8833489..c12fd8f 100755 --- a/test/test_reset_stale_cow_prompt.py +++ b/test/test_reset_stale_cow_prompt.py @@ -141,7 +141,13 @@ class TestTheKeysComeFromTheTool(unittest.TestCase): def test_the_error_prompt_offers_it(self): import inspect - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # 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("prompt_reset_stale_cow_views", source) self.assertIn("Reset one of them onto its module view", source) diff --git a/test/test_reset_stale_cow_tui.py b/test/test_reset_stale_cow_tui.py index 2b0296b..629a272 100755 --- a/test/test_reset_stale_cow_tui.py +++ b/test/test_reset_stale_cow_tui.py @@ -141,7 +141,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # 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("Browse the differences full screen", source) self.assertIn('wait_status == "4"', source) @@ -152,7 +158,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # 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) start = source.index('wait_status == "4"') window = source[start : start + 620] self.assertIn("run_on_terminal", window)