[ADD] migration: convert the <tree> tags Odoo 18 renamed to <list>

OpenUpgrade converts the COLUMN and never the ARCH:

    def _fix_list_view_type(cr):
        cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'")

so the load dies on "the root node of a list view should be <list>".
A module view recovers at its next update, which rewrites the arch from
XML. A view with no xmlid -- a hand-made list, a website copy -- is
rewritten by nothing and stays broken forever. The report says which is
which, because the two call for different work.

Every occurrence, not just the root: a <tree> nested in a form is a
one2many list and is refused just the same. Odoo 18's own addons hold
none, so nothing legitimate is at risk. Gated on version 18: before it,
the tag is correct and "fixing" it would break healthy views.

Four tests run the SQL against a real PostgreSQL. A text assertion
cannot see what a query does -- it took a live run to prove every
language of the jsonb arch is converted, not just en_US.

--- FR ---

OpenUpgrade convertit la COLONNE et jamais l'ARCH, d'où l'échec sur « le
nœud racine d'une vue list devrait être <list> ». Une vue de module s'en
remet à sa prochaine mise à jour ; une vue sans xmlid — liste faite
main, copie de site — n'est réécrite par rien. Le rapport distingue les
deux, car le travail diffère.

Toutes les occurrences, pas seulement la racine : un <tree> imbriqué
dans un formulaire est une liste one2many, refusée pareillement. Les
addons d'Odoo 18 n'en portent aucun. Bridé à la 18 : avant, la balise
est juste.

Quatre tests exécutent le SQL contre un vrai PostgreSQL. Une assertion
sur du texte ne voit pas ce qu'une requête fait — il a fallu l'exécuter
pour prouver que toutes les langues du jsonb sont converties.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-22 02:34:22 -04:00
parent 300238fe83
commit 7e30377e56
3 changed files with 469 additions and 21 deletions

View file

@ -2,7 +2,10 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # 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. 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 neuve et sur quatre bases migrées, un seul sur celle qui refusait de
charger. Le détecteur ne crie pas au loup. charger. Le détecteur ne crie pas au loup.
B — un `<tree>` 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 <list>, et non <tree>
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 `<tree>` 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 `<tree>` d'une base 18 est un reste.
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. 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): def run_psql(database, sql, read_only=True):
"""Interroger la base. En lecture seule sauf demande explicite.""" """Interroger la base. En lecture seule sauf demande explicite."""
env = os.environ.copy() 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 `<tree>`. [] 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 '%<tree%'"
f" ORDER BY v.id",
)
if lignes is None:
return None
return [
{
"id": int(ligne[0]),
"type": ligne[1],
"xmlid": ligne[2],
"model": ligne[3],
"origin": ligne[4],
}
for ligne in lignes
if len(ligne) >= 5
]
def repair_tree_sql(vues, jsonb):
"""Le SQL qui remplace `<tree>` par `<list>`, ou "".
TOUTES les occurrences : un `<tree>` 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, '<tree', '<list'),"
" '</tree>', '</list>'))"
" FROM jsonb_each_text(arch_db) AS paires(cle, valeur))"
)
else:
remplace = (
"replace(replace(arch_db, '<tree', '<list'),"
" '</tree>', '</list>')"
)
return f"UPDATE ir_ui_view SET arch_db = {remplace} WHERE id IN ({liste})"
def repair_sql(vues): def repair_sql(vues):
"""Le SQL de correction, ou "" s'il n'y a rien à corriger. """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): def render(vues, applique=False):
if not vues: if not vues:
return [f"✅ {t('Every view type agrees with its inheritance.')}"] return []
lignes = [ lignes = [
f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}" f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}"
f" {t('their inheritance')} :" f" {t('their inheritance')} :"
@ -162,6 +283,31 @@ def render(vues, applique=False):
return lignes return lignes
def render_tree(vues, applique=False):
if not vues:
return []
lignes = [
f"🩹 {len(vues)} {t('view(s) still carry a <tree> tag Odoo 18')}"
f" {t('renamed to <list>')} :"
]
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): def main(argv=None):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
@ -181,21 +327,36 @@ def main(argv=None):
if vues is None: if vues is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}") print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2 return 2
if not vues: # Le renommage `tree` → `list` n'a de sens qu'à partir de la 18 :
print("\n".join(render(vues))) # 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 return 0
if not config.apply: if not config.apply:
print("\n".join(render(vues))) print("\n".join(render(vues) + render_tree(arbres)))
return 1 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.')}") print(f"❌ {t('The correction failed.')}")
return 2 return 2
reste = find(config.database) print(
if reste: "\n".join(
print(f"❌ {t('The correction failed.')}") render(vues, applique=True) + render_tree(arbres, applique=True)
return 2 )
print("\n".join(render(vues, applique=True))) )
return 0 return 0

View file

@ -6135,10 +6135,6 @@ TRANSLATIONS = {
"fr": "Corrigé.", "fr": "Corrigé.",
"en": "Corrected.", "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,": { "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,", "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,", "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", "fr": "Désinstaller le(s) thème(s) que l'erreur nomme",
"en": "Uninstall the theme(s) the error names", "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 <list>": {
"fr": "a renommée en <list>",
"en": "renamed to <list>",
},
"view(s) still carry a <tree> tag Odoo 18": {
"fr": "vue(s) portent encore une balise <tree> qu'Odoo 18",
"en": "view(s) still carry a <tree> tag Odoo 18",
},
"Census": { "Census": {
"fr": "Recensement", "fr": "Recensement",
"en": "Census", "en": "Census",

View file

@ -88,11 +88,8 @@ class TestTheRepairSql(unittest.TestCase):
class TestTheReport(unittest.TestCase): class TestTheReport(unittest.TestCase):
def test_a_clean_database_says_so(self): def test_a_clean_check_renders_nothing(self):
texte = "\n".join(fvt.render([])) self.assertEqual(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): def test_a_finding_names_the_view_and_both_types(self):
texte = "\n".join(fvt.render([vue(637, "tree", "search")])) texte = "\n".join(fvt.render([vue(637, "tree", "search")]))
@ -193,6 +190,280 @@ class TestTheExitCodes(unittest.TestCase):
self.assertEqual(code, 2) 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("'<tree', '<list'", sql)
self.assertIn("'</tree>', '</list>'", sql)
def test_it_replaces_EVERY_occurrence_not_just_the_root(self):
# Un `<tree>` 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": "<tree><field/></tree>",'
' "fr_CA": "<tree><field/></tree>"}\'::jsonb',
)
fvt.run_psql(
self.BASE,
fvt.repair_tree_sql([arbre(1)], jsonb=True),
read_only=False,
)
obtenu = self.arch()
self.assertNotIn("<tree", obtenu)
self.assertEqual(obtenu.count("<list>"), 2)
self.assertEqual(obtenu.count("</list>"), 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": "<form><field><tree/></field></form>"}\'::jsonb',
)
fvt.run_psql(
self.BASE,
fvt.repair_tree_sql([arbre(1)], jsonb=True),
read_only=False,
)
self.assertNotIn("<tree", self.arch())
self.assertIn("<list/>", self.arch())
def test_a_text_column_works_too(self):
self.prepare("text", "'<tree><field/></tree>'")
fvt.run_psql(
self.BASE,
fvt.repair_tree_sql([arbre(1)], jsonb=False),
read_only=False,
)
self.assertEqual(self.arch(), "<list><field/></list>")
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": "<form/>"}\'::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, `<tree>` 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 "<tree" in sql:
return (
[["3931", "list", "-", "res.partner", "custom"]]
if arbres
else []
)
return []
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_a_17_database_is_left_alone(self):
# « Corriger » un <tree> 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 "<tree" in sql:
appels["n"] += 1
# Trouvé au premier passage, plus rien au second.
return (
[["3931", "list", "-", "res.partner", "custom"]]
if appels["n"] == 1
else []
)
return []
fvt.run_psql = faux
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):
self.branche(18)
code, _ = self.lance(["-d", "db", "--apply"])
self.assertEqual(code, 2)
class TestTheWiring(unittest.TestCase): class TestTheWiring(unittest.TestCase):
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))