diff --git a/script/odoo/migration/fix_view_type.py b/script/odoo/migration/fix_view_type.py index 5022cea..adb24ec 100755 --- a/script/odoo/migration/fix_view_type.py +++ b/script/odoo/migration/fix_view_type.py @@ -2,7 +2,10 @@ # © 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 données de vue qu'Odoo refuse, et qu'aucune mise à jour ne répare. + +A — le `type` stocké contredit l'héritage +========================================= Le symptôme est brutal : Odoo refuse de charger la base. @@ -37,6 +40,29 @@ lui-même, citée plus haut. Vérifié : zéro écart sur une installation 18 neuve et sur quatre bases migrées, un seul sur celle qui refusait de charger. Le détecteur ne crie pas au loup. +B — un `` survit dans une base Odoo 18 +============================================ +La 18 a renommé le type `tree` en `list`, balise comprise. OpenUpgrade +convertit la COLONNE : + + def _fix_list_view_type(cr): + '''Former tree views have view type list now.''' + cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'") + +…et jamais l'ARCH. Le chargement meurt alors sur + + Le nœud racine d'une vue list devrait être , et non + +Une vue de module s'en remet à la première mise à jour du module, qui +réécrit son arch depuis le XML. Une vue SANS xmlid — une liste faite à +la main, une copie de site — n'est réécrite par rien : elle reste +cassée pour toujours. + +Le remplacement porte sur TOUTES les occurrences, pas seulement la +racine : un `` imbriqué dans un formulaire (une liste one2many) +est tout aussi refusé. Vérifié : les addons d'Odoo 18 n'en contiennent +plus une seule, donc tout `` d'une base 18 est un reste. + Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. """ @@ -85,6 +111,10 @@ ORDER BY v.id """ +# La 18 renomme `tree` en `list`. Avant, la balise est légitime. +PREMIERE_VERSION_LIST = 18 + + def run_psql(database, sql, read_only=True): """Interroger la base. En lecture seule sauf demande explicite.""" env = os.environ.copy() @@ -121,6 +151,97 @@ def find(database): ] +def db_major(database): + """La version majeure d'Odoo de cette base, ou None. + + Celle qu'Odoo inscrit, pas celle du checkout : on répare une base, + pas un répertoire. + """ + lignes = run_psql( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base'", + ) + if not lignes or not lignes[0][0]: + return None + try: + return int(lignes[0][0].split(".")[0]) + except ValueError: + return None + + +def arch_is_jsonb(database): + """`arch_db` est un jsonb depuis la 16, un texte avant.""" + lignes = run_psql( + database, + "SELECT data_type FROM information_schema.columns" + " WHERE table_name = 'ir_ui_view' AND column_name = 'arch_db'", + ) + return bool(lignes) and lignes[0][0] == "jsonb" + + +def arch_as_text(jsonb): + """L'expression SQL qui rend l'arch en texte, quelle que soit sa forme.""" + return "arch_db::text" if jsonb else "coalesce(arch_db, '')" + + +def find_tree(database, jsonb): + """Les vues qui portent encore un ``. [] hors des bases 18+.""" + texte = arch_as_text(jsonb) + lignes = run_psql( + database, + f"SELECT v.id::text, v.type," + f" coalesce(d.module || '.' || d.name, '-')," + f" coalesce(v.model, '-')," + f" CASE WHEN d.id IS NULL THEN 'custom' ELSE 'module' END" + f" FROM ir_ui_view v" + f" LEFT JOIN ir_model_data d" + f" ON d.model = 'ir.ui.view' AND d.res_id = v.id" + f" WHERE {texte} LIKE '%= 5 + ] + + +def repair_tree_sql(vues, jsonb): + """Le SQL qui remplace `` par ``, ou "". + + TOUTES les occurrences : un `` imbriqué dans un formulaire est + refusé autant que celui de la racine. Les addons d'Odoo 18 n'en + contiennent plus une seule, donc il n'y a rien de légitime à épargner. + """ + if not vues: + return "" + liste = ", ".join(str(vue["id"]) for vue in vues) + if jsonb: + # jsonb : reconstruire l'objet, langue par langue. Une traduction + # oubliée laisserait la vue cassée dans cette langue seulement — + # une panne qui ne se montre qu'à certains. + remplace = ( + "(SELECT jsonb_object_agg(cle," + " replace(replace(valeur, '', ''))" + " FROM jsonb_each_text(arch_db) AS paires(cle, valeur))" + ) + else: + remplace = ( + "replace(replace(arch_db, '', '')" + ) + return f"UPDATE ir_ui_view SET arch_db = {remplace} WHERE id IN ({liste})" + + def repair_sql(vues): """Le SQL de correction, ou "" s'il n'y a rien à corriger. @@ -142,7 +263,7 @@ def repair_sql(vues): def render(vues, applique=False): if not vues: - return [f"✅ {t('Every view type agrees with its inheritance.')}"] + return [] lignes = [ f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}" f" {t('their inheritance')} :" @@ -162,6 +283,31 @@ def render(vues, applique=False): return lignes +def render_tree(vues, applique=False): + if not vues: + return [] + lignes = [ + f"🩹 {len(vues)} {t('view(s) still carry a tag Odoo 18')}" + f" {t('renamed to ')} :" + ] + for vue in vues: + # L'ORIGINE est le renseignement utile : une vue de module se + # répare d'une mise à jour, une vue sans xmlid n'est réécrite + # par rien et reste cassée pour toujours. + marque = ( + t("custom — nothing will ever rewrite it") + if vue["origin"] == "custom" + else t("from a module — a module update also fixes it") + ) + lignes.append( + f" {vue['id']:<7} {vue['xmlid'][:40]:<42}" + f" {vue['type']:<8} {vue['model'][:22]:<24} {marque}" + ) + if applique: + lignes.append(f" ✅ {t('Corrected.')}") + return lignes + + def main(argv=None): parser = argparse.ArgumentParser( description=( @@ -181,21 +327,36 @@ def main(argv=None): if vues is None: print(f"❌ {t('Cannot read the database: ')}{config.database}") return 2 - if not vues: - print("\n".join(render(vues))) + # Le renommage `tree` → `list` n'a de sens qu'à partir de la 18 : + # avant, la balise est parfaitement légitime et la « corriger » + # casserait des vues saines. + jsonb = arch_is_jsonb(config.database) + majeure = db_major(config.database) + arbres = [] + if majeure and majeure >= PREMIERE_VERSION_LIST: + arbres = find_tree(config.database, jsonb) or [] + + if not vues and not arbres: + print(f"✅ {t('Every view agrees with what Odoo expects.')}") return 0 if not config.apply: - print("\n".join(render(vues))) + print("\n".join(render(vues) + render_tree(arbres))) return 1 - sql = repair_sql(vues) - if run_psql(config.database, sql, read_only=False) is None: + + for sql in (repair_sql(vues), repair_tree_sql(arbres, jsonb)): + if sql and run_psql(config.database, sql, read_only=False) is None: + print(f"❌ {t('The correction failed.')}") + return 2 + # Relire APRÈS : annoncer « corrigé » sans vérifier ferait relancer + # la migration sur le même mur. + if find(config.database) or (arbres and find_tree(config.database, jsonb)): print(f"❌ {t('The correction failed.')}") return 2 - reste = find(config.database) - if reste: - print(f"❌ {t('The correction failed.')}") - return 2 - print("\n".join(render(vues, applique=True))) + print( + "\n".join( + render(vues, applique=True) + render_tree(arbres, applique=True) + ) + ) return 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 1154f65..6a5118a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6135,10 +6135,6 @@ TRANSLATIONS = { "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,", @@ -6175,6 +6171,26 @@ TRANSLATIONS = { "fr": "Désinstaller le(s) thème(s) que l'erreur nomme", "en": "Uninstall the theme(s) the error names", }, + "Every view agrees with what Odoo expects.": { + "fr": "Toutes les vues s'accordent avec ce qu'Odoo attend.", + "en": "Every view agrees with what Odoo expects.", + }, + "custom — nothing will ever rewrite it": { + "fr": "faite main — rien ne la réécrira jamais", + "en": "custom — nothing will ever rewrite it", + }, + "from a module — a module update also fixes it": { + "fr": "d'un module — une mise à jour la répare aussi", + "en": "from a module — a module update also fixes it", + }, + "renamed to ": { + "fr": "a renommée en ", + "en": "renamed to ", + }, + "view(s) still carry a tag Odoo 18": { + "fr": "vue(s) portent encore une balise qu'Odoo 18", + "en": "view(s) still carry a tag Odoo 18", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/test/test_fix_view_type.py b/test/test_fix_view_type.py index e28926f..0fb1ec3 100644 --- a/test/test_fix_view_type.py +++ b/test/test_fix_view_type.py @@ -88,11 +88,8 @@ class TestTheRepairSql(unittest.TestCase): 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_clean_check_renders_nothing(self): + self.assertEqual(fvt.render([]), []) def test_a_finding_names_the_view_and_both_types(self): texte = "\n".join(fvt.render([vue(637, "tree", "search")])) @@ -193,6 +190,280 @@ class TestTheExitCodes(unittest.TestCase): self.assertEqual(code, 2) +def arbre(vid=3931, origine="custom", type_="list", xmlid="-"): + return { + "id": vid, + "type": type_, + "xmlid": xmlid, + "model": "res.partner", + "origin": origine, + } + + +class TestTheTreeTag(unittest.TestCase): + """Odoo 18 a renommé `tree` en `list`, balise comprise. + + OpenUpgrade convertit la COLONNE `type` et jamais l'ARCH. Une vue de + module s'en remet à la première mise à jour ; une vue sans xmlid + n'est réécrite par rien. + """ + + def test_the_replacement_covers_open_AND_close(self): + sql = fvt.repair_tree_sql([arbre()], jsonb=True) + self.assertIn("'', ''", sql) + + def test_it_replaces_EVERY_occurrence_not_just_the_root(self): + # Un `` imbriqué dans un formulaire — une liste one2many — + # est refusé autant que celui de la racine. `replace` en SQL + # remplace tout ; un ancrage `^` n'aurait pris que le premier. + sql = fvt.repair_tree_sql([arbre()], jsonb=True) + self.assertNotIn("^", sql) + self.assertNotIn("regexp", sql.lower()) + + def test_jsonb_is_rebuilt_language_by_language(self): + # Une traduction oubliée laisserait la vue cassée dans cette + # langue seulement — une panne qui ne se montre qu'à certains. + sql = fvt.repair_tree_sql([arbre()], jsonb=True) + self.assertIn("jsonb_each_text", sql) + self.assertIn("jsonb_object_agg", sql) + + def test_a_text_column_is_handled_too(self): + sql = fvt.repair_tree_sql([arbre()], jsonb=False) + self.assertNotIn("jsonb", sql) + self.assertIn("replace(replace(arch_db", sql) + + def test_nothing_to_fix_gives_no_sql(self): + self.assertEqual(fvt.repair_tree_sql([], jsonb=True), "") + + def test_it_targets_the_listed_ids_only(self): + sql = fvt.repair_tree_sql([arbre(11), arbre(22)], jsonb=True) + self.assertIn("WHERE id IN (11, 22)", sql) + + def test_the_report_tells_custom_from_module(self): + # C'est LE renseignement utile : l'une se répare d'une mise à + # jour, l'autre ne se répare jamais toute seule. + texte = "\n".join( + fvt.render_tree([arbre(1, "custom"), arbre(2, "module")]) + ) + self.assertIn( + todo_i18n.t("custom — nothing will ever rewrite it"), texte + ) + self.assertIn( + todo_i18n.t("from a module — a module update also fixes it"), texte + ) + + def test_no_tree_renders_nothing(self): + self.assertEqual(fvt.render_tree([]), []) + + +class TestTheSqlAgainstARealPostgres(unittest.TestCase): + """Le SQL, exécuté pour de vrai. + + Une assertion sur le TEXTE d'une requête ne voit pas ce qu'elle + fait : ajouter un `LIMIT 1` la laisserait passer alors qu'une seule + langue serait convertie — une vue cassée dans les autres, une panne + qui ne se montre qu'à certains. + """ + + BASE = "tmp_fix_view_type_test" + + @classmethod + def setUpClass(cls): + import shutil as _shutil + import subprocess as _sub + + if not _shutil.which("psql") or not _shutil.which("createdb"): + raise unittest.SkipTest("PostgreSQL absent") + _sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True) + fait = _sub.run(["createdb", cls.BASE], capture_output=True) + if fait.returncode: + raise unittest.SkipTest("createdb impossible") + + @classmethod + def tearDownClass(cls): + import shutil as _shutil + import subprocess as _sub + + if _shutil.which("dropdb"): + _sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True) + + def prepare(self, colonne, valeur): + fvt.run_psql( + self.BASE, "DROP TABLE IF EXISTS ir_ui_view", read_only=False + ) + fvt.run_psql( + self.BASE, + f"CREATE TABLE ir_ui_view (id serial PRIMARY KEY," + f" arch_db {colonne})", + read_only=False, + ) + fvt.run_psql( + self.BASE, + f"INSERT INTO ir_ui_view (arch_db) VALUES ({valeur})", + read_only=False, + ) + + def arch(self): + lignes = fvt.run_psql( + self.BASE, "SELECT arch_db::text FROM ir_ui_view" + ) + return lignes[0][0] if lignes else "" + + def test_every_language_is_converted(self): + self.prepare( + "jsonb", + '\'{"en_US": "",' + ' "fr_CA": ""}\'::jsonb', + ) + fvt.run_psql( + self.BASE, + fvt.repair_tree_sql([arbre(1)], jsonb=True), + read_only=False, + ) + obtenu = self.arch() + self.assertNotIn(""), 2) + self.assertEqual(obtenu.count(""), 2) + + def test_a_nested_tree_is_converted_too(self): + # Une liste one2many dans un formulaire : refusée autant que la + # racine, et invisible pour un contrôle qui ne verrait que celle-ci. + self.prepare( + "jsonb", + '\'{"en_US": "
"}\'::jsonb', + ) + fvt.run_psql( + self.BASE, + fvt.repair_tree_sql([arbre(1)], jsonb=True), + read_only=False, + ) + self.assertNotIn("", self.arch()) + + def test_a_text_column_works_too(self): + self.prepare("text", "''") + fvt.run_psql( + self.BASE, + fvt.repair_tree_sql([arbre(1)], jsonb=False), + read_only=False, + ) + self.assertEqual(self.arch(), "") + + def test_the_detection_query_runs(self): + # Une requête qui ne compile pas rendrait None, et l'outil + # dirait « base illisible » au lieu de « rien à corriger ». + self.prepare("jsonb", '\'{"en_US": "
"}\'::jsonb') + fvt.run_psql( + self.BASE, + "CREATE TABLE IF NOT EXISTS ir_model_data" + " (id serial, model text, res_id integer, module text, name text)", + read_only=False, + ) + fvt.run_psql( + self.BASE, + "ALTER TABLE ir_ui_view ADD COLUMN IF NOT EXISTS type text," + " ADD COLUMN IF NOT EXISTS model text", + read_only=False, + ) + self.assertEqual(fvt.find_tree(self.BASE, jsonb=True), []) + + +class TestTheVersionGate(unittest.TestCase): + """Avant la 18, `` est parfaitement légitime.""" + + def setUp(self): + self.vrai = fvt.run_psql + self.ecritures = [] + + def tearDown(self): + fvt.run_psql = self.vrai + + def branche(self, majeure, arbres=True): + def faux(database, sql, read_only=True): + if not read_only: + self.ecritures.append(sql) + return [] + if "latest_version" in sql: + return [[f"{majeure}.0.1.3"]] + if "information_schema" in sql: + return [["jsonb"]] + if "WITH RECURSIVE" in sql: + return [] + if " légitime casserait une vue saine. + self.branche(17) + code, sortie = self.lance(["-d", "db"]) + self.assertEqual(code, 0) + self.assertEqual(self.ecritures, []) + self.assertIn( + todo_i18n.t("Every view agrees with what Odoo expects."), sortie + ) + + def test_an_18_database_reports_it(self): + self.branche(18) + code, sortie = self.lance(["-d", "db"]) + self.assertEqual(code, 1) + self.assertIn("3931", sortie) + self.assertEqual(self.ecritures, []) + + def test_an_unknown_version_stays_on_the_safe_side(self): + # Sans version on ne SAIT pas : ne rien toucher. + self.branche(None) + code, _ = self.lance(["-d", "db"]) + self.assertEqual(code, 0) + + def test_apply_writes_once_per_repair(self): + appels = {"n": 0} + + def faux(database, sql, read_only=True): + if not read_only: + self.ecritures.append(sql) + return [] + if "latest_version" in sql: + return [["18.0.1.3"]] + if "information_schema" in sql: + return [["jsonb"]] + if "WITH RECURSIVE" in sql: + return [] + if "