[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)
# 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 `<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é.
"""
@ -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 `<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):
"""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 <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):
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

View file

@ -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 <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": {
"fr": "Recensement",
"en": "Census",

View file

@ -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("'<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):
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))