[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:
parent
300238fe83
commit
7e30377e56
3 changed files with 469 additions and 21 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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__), ".."))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue