diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f920d86..fa29818 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3886,6 +3886,862 @@ TRANSLATIONS = { "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", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b6bfbba..5c6e72f 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -8,6 +8,7 @@ import logging import os import re import shutil +import subprocess import sys import zipfile from uuid import uuid4 @@ -1608,16 +1609,13 @@ class TodoUpgrade: get_output=True, wait_at_error=False, ) - # Cet avertissement n'est qu'un avertissement, et rien ne le disait : - # il annonce un problème, propose d'arbitrer, et aucune question ne - # suit. On en conclut que la question est passée — alors qu'elle vient - # au moment du palier, des dizaines de minutes plus tard. + # 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. if output and "will break when moving to" in "\n".join(output): - print( - f"ℹ -> {t('Nothing to decide yet')} :" - f" {t('the migration will offer to neutralize these copies')}" - f" {t('at the version bump itself, showing what each one holds.')}" - ) + self.prompt_cow_prediction(database_name, start_version + 1) msg = "3 - Clean up database before data migration" print(f"🔷 {msg}") @@ -2621,6 +2619,91 @@ class TodoUpgrade: wait_at_error=False, ) + 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) + return subprocess.call(cmd, shell=True, executable="/bin/bash") + + 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: + answer = ( + input( + f"💬 {t('What do you want to do with these COW copies?')}" + f" ({t('Enter = decide at the version bump')}," + f" v = {t('what each copy holds')}," + f" s = {t('why it breaks')}," + f" w = {t('full screen')}," + f" a = {t('neutralize now, reversible')}) : " + ) + .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. @@ -2643,10 +2726,6 @@ class TodoUpgrade: # 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. - show = ( - f"{PYTHON_BIN} ./script/odoo/migration/cow_drift.py" - f" -d {database_name} -t odoo{next_version}.0" - ) while True: answer = ( input( @@ -2658,13 +2737,11 @@ class TodoUpgrade: .lower() ) if answer == "v": - self.todo_upgrade_execute(show, wait_at_error=False) - self.todo_upgrade_execute( - f"{show} --shape", wait_at_error=False - ) + self.show_cow_drift(database_name, next_version, "diff") + self.show_cow_drift(database_name, next_version, "shape") continue if answer == "w": - self.todo_upgrade_execute(f"{show} --tui", wait_at_error=False) + self.show_cow_drift(database_name, next_version, "tui") continue break diff --git a/test/test_todo_upgrade_cow_prompt.py b/test/test_todo_upgrade_cow_prompt.py new file mode 100644 index 0000000..d73426c --- /dev/null +++ b/test/test_todo_upgrade_cow_prompt.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'étape 2 annonce des copies COW cassées, et ne demandait rien. + +Le message disait le problème, invitait à arbitrer, puis passait à la suite : +« la migration proposera de neutraliser au palier ». Des dizaines de minutes +plus tard, donc — alors que REGARDER n'écrit rien, et que neutraliser ici vaut +pour tous les paliers, chaque base de palier étant un clone de celle-ci. + +Ces tests portent sur ce que chaque réponse déclenche réellement, pas sur le +texte de l'invite. +""" + +import builtins +import contextlib +import io +import os +import unittest + +from script.todo import todo_i18n +from script.todo.todo_upgrade import TodoUpgrade + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class PromptCase(unittest.TestCase): + def setUp(self): + # PAS set_lang() : il persiste la langue dans env_var.sh, suivi par + # git. On écrit la mémoïsation, et on la rend. + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def run_prompt(self, answers, database="db", version=13): + """(commandes lancées, texte affiché) après ces réponses.""" + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + lst_cmd = [] + upgrade.todo_upgrade_execute = lambda cmd, **kw: ( + lst_cmd.append(cmd), + (False, cmd), + )[1] + # Les afficheurs ne passent PLUS par todo_upgrade_execute : il capture + # la sortie par un tube, et un plein écran refuse de s'ouvrir sur un + # stdout qui n'est pas un terminal. Sans ce bouchon, ces tests + # lanceraient les vraies commandes. + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + seq = iter(answers) + original = builtins.input + builtins.input = lambda *a: next(seq) + self.addCleanup(setattr, builtins, "input", original) + out = io.StringIO() + with contextlib.redirect_stdout(out): + upgrade.prompt_cow_prediction(database, version) + return lst_cmd, out.getvalue() + + +class TestWhatEachAnswerRuns(PromptCase): + def test_enter_runs_nothing_and_says_so(self): + lst_cmd, text = self.run_prompt([""]) + self.assertEqual(lst_cmd, []) + self.assertIn("version bump", text) + + def test_v_shows_what_the_copies_hold(self): + lst_cmd, _ = self.run_prompt(["v", ""]) + self.assertEqual(len(lst_cmd), 1) + self.assertIn("cow_drift.py", lst_cmd[0]) + self.assertIn("-d db -t odoo13.0", lst_cmd[0]) + self.assertNotIn("--shape", lst_cmd[0]) + + def test_s_shows_why_it_breaks(self): + lst_cmd, _ = self.run_prompt(["s", ""]) + self.assertTrue(lst_cmd[0].endswith("--shape")) + + def test_w_opens_the_full_screen_view(self): + lst_cmd, _ = self.run_prompt(["w", ""]) + self.assertTrue(lst_cmd[0].endswith("--tui")) + + def test_looking_does_not_answer_the_question(self): + # LE piège de l'invite précédente : montrer puis passer à la suite. + # Après avoir regardé, on doit pouvoir encore choisir. + lst_cmd, _ = self.run_prompt(["v", "s", "w", "a"]) + self.assertEqual(len(lst_cmd), 4) + self.assertIn("--apply", lst_cmd[-1]) + + +class TestNeutralizingNow(PromptCase): + def test_a_applies_and_stops_asking(self): + lst_cmd, _ = self.run_prompt(["a"]) + self.assertEqual(len(lst_cmd), 1) + self.assertIn("neutralize_cow_views.py", lst_cmd[0]) + self.assertIn("-d db -t odoo13.0 --apply", lst_cmd[0]) + + def test_the_way_back_is_printed_and_is_valid(self): + # `--restore` refuse `-t` : une commande d'annulation qu'on ne peut + # pas coller est une commande d'annulation qui n'existe pas. + _, text = self.run_prompt(["a"]) + self.assertIn("--restore", text) + ligne = [x for x in text.splitlines() if "--restore" in x][0] + self.assertIn("-d db", ligne) + self.assertNotIn("-t odoo", ligne) + + def test_applying_says_nothing_about_deciding_later(self): + _, text = self.run_prompt(["a"]) + self.assertNotIn("ask again", text) + + +class TestTheFullScreenViewGetsARealTerminal(PromptCase): + """« w » réaffichait le rapport texte au lieu d'ouvrir le plein écran. + + `todo_upgrade_execute` capture la sortie par un tube pour que le pilote + puisse la relire. La TUI y voit un stdout qui n'est PAS un terminal, + renonce — c'est son repli délibéré — et retombe sur le même rapport que + « v » venait d'afficher. Aucune erreur, aucune trace : deux touches + différentes donnant le même écran. + """ + + def test_the_viewers_do_not_go_through_the_piped_executor(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.show_cow_drift) + self.assertNotIn("todo_upgrade_execute", source) + self.assertIn("self.run_on_terminal(", source) + + def test_run_on_terminal_does_not_capture_anything(self): + # Capturer, c'est justement ce qui referme le plein écran. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.run_on_terminal) + self.assertNotIn("PIPE", source) + self.assertNotIn("capture_output", source) + self.assertIn("subprocess.call(", source) + + def test_the_tui_refuses_a_pipe_and_accepts_a_terminal(self): + # La raison même du défaut, vérifiée sur le code qui décide. + import sys + + sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + self.addCleanup(sys.path.remove, sys.path[0]) + from cow_drift_tui import run_tui + + finding = { + "id": 1, + "key": "k", + "website_id": 1, + "reason": "r", + "module_id": 2, + "module_arch": "a", + "copy_arch": "b", + "decl_current": None, + "decl_target": None, + "current_version": "odoo12.0", + "target_version": "odoo13.0", + } + + class FakeStdout: + def __init__(self, tty): + self.tty = tty + + def isatty(self): + return self.tty + + real = sys.stdout + self.addCleanup(setattr, sys, "stdout", real) + sys.stdout = FakeStdout(False) + self.assertFalse(run_tui([finding], run_app=False)) + sys.stdout = FakeStdout(True) + self.assertTrue(run_tui([finding], run_app=False)) + + +class TestTheBumpPromptSharesTheSameView(PromptCase): + """Les deux invites doivent montrer la même chose, sans se dupliquer.""" + + def test_show_cow_drift_builds_the_three_modes(self): + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + for mode in ("diff", "shape", "tui"): + upgrade.show_cow_drift("db", 13, mode) + self.assertNotIn("--", lst_cmd[0].split("odoo13.0")[1]) + self.assertTrue(lst_cmd[1].endswith("--shape")) + self.assertTrue(lst_cmd[2].endswith("--tui")) + + def test_the_bump_prompt_no_longer_builds_its_own_command(self): + # Sans cela les deux invites peuvent diverger en silence. + import inspect + + source = inspect.getsource(TodoUpgrade.neutralize_cow_views) + self.assertNotIn("cow_drift.py", source) + self.assertIn("self.show_cow_drift(", source) + + +if __name__ == "__main__": + unittest.main()