From efc152bf62d4259c41a477c236d76c682c5dceb3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 07:59:28 -0400 Subject: [PATCH] [FIX] migration: name the template when the failure comes from rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QWebException carries no [view_id … parent_id …] block — it names the template. The tool only read the inheritance context, so it answered « no parent view named » on the one failure that hit everything. Measured at bump 17: a frozen copy of website.submenu still called submenu.clean_url(), renamed _clean_url() in that version. Every page renders the menu, so 34 of 37 public URLs returned 500, and nothing pointed at the view. Resetting that one copy brought all 37 back. Only keys that actually have a COW copy are proposed: naming one without would send a reset against a module view, which does nothing. --- FR --- [FIX] migration : nommer le gabarit quand l'échec vient du rendu Une QWebException ne porte pas de bloc [view_id … parent_id …] : elle nomme le gabarit. L'outil ne lisait que le contexte d'héritage, et répondait donc « aucune vue parente nommée » sur la seule panne qui touchait tout. Mesuré au palier 17 : une copie figée de website.submenu appelait encore submenu.clean_url(), renommée _clean_url() par la version. Toute page affiche le menu, donc 34 URL publiques sur 37 rendaient 500, sans que rien ne désigne la vue. Réinitialiser cette seule copie les a toutes ramenées. Seules les clés ayant vraiment une copie COW sont proposées : en nommer une sans copie enverrait réinitialiser une vue module, donc ne rien faire. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_public_url.py | 41 ++++++++++++++- test/test_smoke_public_url.py | 64 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 1eb914d..3673161 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -74,6 +74,13 @@ RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) # figée dans laquelle l'enfant ne trouve plus son xpath. RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]") +# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le +# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie +# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans +# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue +# fautive. +RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M) + # Un port à part : la migration tourne souvent à côté d'une instance vivante, # et lui voler 8069 ferait échouer le test pour une raison sans rapport. DEFAULT_PORT = 8169 @@ -253,6 +260,29 @@ def check_urls(lst_url, timeout=30): return lst_failure +def template_keys(lst_log, database): + """Les gabarits nommés par une QWebException, s'ils ont une copie COW. + + Nommer une clé sans copie enverrait réinitialiser une vue module — donc + ne rien faire, en silence. On ne propose que ce qui peut l'être. + """ + lst_key = [] + for line in lst_log: + for key in RE_TEMPLATE.findall(line): + if key not in lst_key: + lst_key.append(key) + if not lst_key: + return [] + quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key) + rows = run_psql( + database, + "SELECT DISTINCT key FROM ir_ui_view" + f" WHERE website_id IS NOT NULL AND key IN ({quoted});", + ) + with_copy = {row[0] for row in rows if row and row[0]} + return [key for key in lst_key if key in with_copy] + + def culprit_keys(database, lst_failure): """Les clés des vues parentes mises en cause, sans doublon. @@ -426,9 +456,16 @@ def run( finally: stop_server(server) + lst_key = [] if lst_failure: - lst_failure = attach_missing_parents(lst_failure, read_log(log_path)) - lst_key = culprit_keys(database, lst_failure) + lst_log = read_log(log_path) + lst_failure = attach_missing_parents(lst_failure, lst_log) + lst_key = culprit_keys(database, lst_failure) + # Les deux sources : le contexte d'héritage quand il existe, le nom + # du gabarit quand l'échec vient du rendu. + for key in template_keys(lst_log, database): + if key not in lst_key: + lst_key.append(key) if not lst_failure or not (interactive or auto_apply): return lst_url, lst_failure, lst_key, None diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 425cc76..630d7e5 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -240,6 +240,70 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): self.assertEqual(rebuilt[0][2].count("2841"), 1) +class TestARenderFailureNamesItsTemplate(unittest.TestCase): + """Une QWebException ne porte pas de bloc [view_id …] : elle nomme le + gabarit. + + Mesuré au palier 17 : une copie figée appelait `submenu.clean_url()`, + méthode renommée `_clean_url()` par la version. 34 URL sur 37 rendaient + 500, et l'outil répondait « aucune vue parente nommée » — il ne lisait + que le contexte d'héritage, absent ici. + """ + + def test_the_template_line_is_read(self): + self.assertEqual( + smoke.RE_TEMPLATE.findall("Template: website.submenu"), + ["website.submenu"], + ) + + def test_prose_is_not_mistaken_for_a_key(self): + # « Template: » suivi d'une phrase n'est pas un gabarit. + self.assertEqual( + smoke.RE_TEMPLATE.findall("Template: not a key, just words"), [] + ) + + def test_only_keys_with_a_copy_are_proposed(self): + # Proposer une clé sans copie COW enverrait réinitialiser une vue + # module : la commande tournerait sans rien faire. + seen = {} + original = smoke.run_psql + smoke.run_psql = lambda db, sql: ( + seen.update(sql=sql), + [["website.submenu"]], + )[1] + self.addCleanup(setattr, smoke, "run_psql", original) + got = smoke.template_keys( + ["Template: website.submenu", "Template: website.layout"], "db" + ) + self.assertEqual(got, ["website.submenu"]) + self.assertIn("website_id IS NOT NULL", seen["sql"]) + + def test_no_template_line_asks_nothing_of_the_database(self): + called = [] + original = smoke.run_psql + smoke.run_psql = lambda db, sql: called.append(sql) or [] + self.addCleanup(setattr, smoke, "run_psql", original) + self.assertEqual(smoke.template_keys(["rien ici"], "db"), []) + self.assertEqual(called, []) + + def test_a_quote_in_a_key_is_escaped(self): + seen = {} + original = smoke.run_psql + smoke.run_psql = lambda db, sql: (seen.update(sql=sql), [])[1] + self.addCleanup(setattr, smoke, "run_psql", original) + smoke.template_keys(["Template: a.b"], "db") + self.assertIn("'a.b'", seen["sql"]) + + def test_both_sources_feed_the_proposal(self): + # Le contexte d'héritage quand il existe, le gabarit quand l'échec + # vient du rendu : l'un ne remplace pas l'autre. + import inspect + + source = inspect.getsource(smoke.run) + self.assertIn("culprit_keys(", source) + self.assertIn("template_keys(", source) + + class TestTheServerIsKilledForReal(unittest.TestCase): """« ./run.sh » est un script bash : il ne transmet pas les signaux."""