[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:
Mathieu Benoit 2026-08-22 00:46:42 -04:00
parent ff8b6bcaf9
commit 068022db82
8 changed files with 910 additions and 78 deletions

View 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())

View file

@ -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",

View file

@ -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
# <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):
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
# <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
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

View file

@ -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)

264
test/test_fix_view_type.py Normal file
View 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()

View file

@ -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"),

View file

@ -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)

View file

@ -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)