[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> 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 <search> 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
This commit is contained in:
parent
ff8b6bcaf9
commit
068022db82
8 changed files with 910 additions and 78 deletions
203
script/odoo/migration/fix_view_type.py
Executable file
203
script/odoo/migration/fix_view_type.py
Executable file
|
|
@ -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 <tree>, et non <search>
|
||||||
|
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 `<search>` avec les règles d'un `<tree>`, 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())
|
||||||
|
|
@ -5955,6 +5955,222 @@ TRANSLATIONS = {
|
||||||
"fr": "autre(s) nom(s) de champ",
|
"fr": "autre(s) nom(s) de champ",
|
||||||
"en": "other field name(s)",
|
"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": {
|
"Census": {
|
||||||
"fr": "Recensement",
|
"fr": "Recensement",
|
||||||
"en": "Census",
|
"en": "Census",
|
||||||
|
|
|
||||||
|
|
@ -2848,6 +2848,195 @@ class TodoUpgrade:
|
||||||
if status.lower().strip() == "y":
|
if status.lower().strip() == "y":
|
||||||
self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade)
|
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
|
||||||
|
# <xpath …> 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):
|
def get_rename_module(self, lst_module, next_version):
|
||||||
path_search = f"odoo{next_version}.0/OCA_OpenUpgrade/"
|
path_search = f"odoo{next_version}.0/OCA_OpenUpgrade/"
|
||||||
status, cmd_executed, lst_output = self.todo_upgrade_execute(
|
status, cmd_executed, lst_output = self.todo_upgrade_execute(
|
||||||
|
|
@ -3679,79 +3868,9 @@ class TodoUpgrade:
|
||||||
# n'existent pas et le défaut redevient « continuer ».
|
# n'existent pas et le défaut redevient « continuer ».
|
||||||
defaut = "3" if database_name else ""
|
defaut = "3" if database_name else ""
|
||||||
repare = False
|
repare = False
|
||||||
tours = 0
|
wait_status, repare = self._prompt_on_error(
|
||||||
while True:
|
database_name, defaut, repare
|
||||||
# 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
|
|
||||||
# <xpath …> 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
|
|
||||||
|
|
||||||
if wait_status == "1":
|
if wait_status == "1":
|
||||||
# Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la
|
# Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,13 @@ class TestTheHumanKeepsTheWheel(Harness):
|
||||||
def test_the_prompt_says_what_enter_does(self):
|
def test_the_prompt_says_what_enter_does(self):
|
||||||
import inspect
|
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('defaut = "3" if database_name else ""', source)
|
||||||
self.assertIn("default=defaut", source)
|
self.assertIn("default=defaut", source)
|
||||||
|
|
||||||
|
|
|
||||||
264
test/test_fix_view_type.py
Normal file
264
test/test_fix_view_type.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -1594,7 +1594,13 @@ class TestWhatGetsRecorded(Base):
|
||||||
# l'état : c'est précisément celui qu'on cherchera en revenant.
|
# l'état : c'est précisément celui qu'on cherchera en revenant.
|
||||||
import inspect
|
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(
|
self.assertLess(
|
||||||
source.index('record_event("command"'),
|
source.index('record_event("command"'),
|
||||||
source.index("Error detected"),
|
source.index("Error detected"),
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,13 @@ class TestTheKeysComeFromTheTool(unittest.TestCase):
|
||||||
def test_the_error_prompt_offers_it(self):
|
def test_the_error_prompt_offers_it(self):
|
||||||
import inspect
|
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("prompt_reset_stale_cow_views", source)
|
||||||
self.assertIn("Reset one of them onto its module view", source)
|
self.assertIn("Reset one of them onto its module view", source)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
|
||||||
|
|
||||||
from script.todo.todo_upgrade import TodoUpgrade
|
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("Browse the differences full screen", source)
|
||||||
self.assertIn('wait_status == "4"', source)
|
self.assertIn('wait_status == "4"', source)
|
||||||
|
|
||||||
|
|
@ -152,7 +158,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
|
||||||
|
|
||||||
from script.todo.todo_upgrade import TodoUpgrade
|
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"')
|
start = source.index('wait_status == "4"')
|
||||||
window = source[start : start + 620]
|
window = source[start : start + 620]
|
||||||
self.assertIn("run_on_terminal", window)
|
self.assertIn("run_on_terminal", window)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue