#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Inventaire des vues personnalisées d'une base Odoo, copies COW comprises. Ce que l'outil répond : parmi les milliers de vues d'une base, lesquelles ne viennent pas telles quelles d'un module. Ce sont elles qu'une montée de version peut casser, et elles seules qu'un intégrateur doit relire. Ce qu'il ne répond PAS encore ----------------------------- Il ne compare pas l'arch en base à celle que déclare le module. Une vue portant le drapeau ``arch_updated`` est donc dite **signalée**, pas **modifiée** : le drapeau vient d'Odoo, mais il est incomplet dans les deux sens — un ``write`` SQL direct ne l'arme pas, et ``reset_arch(mode='hard')`` l'efface. Conclure demande de comparer, ce que fera l'outil suivant. Nommer « modifiée » ce qui n'est que « signalée » serait une affirmation que rien ici ne soutient. Les copies COW, et ce qui les distingue de l'outillage existant --------------------------------------------------------------- Personnaliser une vue de site web ne la modifie pas : Odoo en fait une copie liée à un ``website_id``. Quatre outils du dépôt s'en occupent déjà, chacun pour une question de MIGRATION — ``check_cow_views.py`` prédit lesquelles casseront à la version suivante, ``reset_stale_cow_views.py`` trouve celles qui ont dérivé de leur jumelle et sait les réinitialiser, ``neutralize_cow_views.py`` les met hors circuit, ``snapshot_cow_views.py`` compare un avant et un après. Aucun ne fait l'inventaire, et c'est le trou que celui-ci comble : combien de vues sont personnalisées, par quel chemin, et lesquelles méritent un regard. Il ne rejuge donc pas les copies COW — il les compte, dit si chacune a une jumelle module, et renvoie vers l'outil qui tranche. Une vue, une seule catégorie ---------------------------- Une copie COW peut aussi porter ``arch_updated`` ; une vue Studio peut être une copie COW. Les classer plusieurs fois ferait un total supérieur au nombre de vues, et un rapport dont les chiffres ne s'additionnent pas ne se lit pas. La catégorie retenue est donc la plus spécifique, dans l'ordre de ``CATEGORIES``, et le reste de ce qu'on sait vit dans ``reason``. """ import argparse import json import os import sys import textwrap new_path = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..") ) sys.path.append(new_path) from script.analyse.lib_analyse import ( # noqa: E402 REPO_ROOT, AnalyseError, arch_differs, backup_version, column_types, diff_stats, existing_columns, json_query, normalise_arch, odoo_shell_json, read_backup, require_odoo_database, scalar_query, side_by_side, t, tr_col, ) # Le script poussé dans « odoo-bin shell » pour obtenir l'arch de référence. SHELL_SCRIPT = os.path.join( os.path.dirname(os.path.abspath(__file__)), "shell", "view_file_arch.py" ) # Le registre se charge par lots : la ligne de commande et la sortie restent # bornées, et une vue cassée n'emporte que son lot. BATCH = 100 # Modules d'identifiants externes qui ne sont pas des modules : Odoo y range ce # qui vient d'un import, d'un export, ou de Studio. STUDIO_MODULE = "studio_customization" NOT_A_MODULE = ("__export__", "__import__", "__custom__") # L'ordre EST la précédence : la première catégorie qui s'applique gagne. Du # plus spécifique au plus général, pour qu'une vue Studio copiée par le site web # soit comptée comme copie COW — c'est ce qu'un intégrateur ira regarder en # premier — et non comme une vue Studio de plus. CATEGORIES = ( "theme_installed", "website_cow_copy", "studio", "imported_or_exported", "ui_created", "module_view_drifted", "module_view_flagged", "module_view", ) # Les catégories qui demandent un regard. « module_view » n'y est pas : une vue # qui vient d'un module et que rien ne signale est le cas normal, et il compte # pour l'écrasante majorité. ACTIONABLE = ( "module_view_drifted", "module_view_flagged", "ui_created", "studio", "imported_or_exported", "website_cow_copy", "theme_installed", ) TOP_DEFAULT = 20 def wrap_note(prefix, text, width=79): """Replier une phrase à l'affichage, sans la découper en clés.""" lines = textwrap.wrap(text, width=width - len(prefix)) or [""] pad = " " * len(prefix) return [prefix + lines[0]] + [pad + line for line in lines[1:]] def category_label(name): """Libellé traduit d'une catégorie. Un `t(variable)` serait plus court, mais il rendrait le contrôle de couverture aveugle : celui-ci relit les sources et ne voit que les appels à littéral. Une clé manquante repasserait alors en silence, en anglais. Les catégories sont donc épelées, une par une. """ return { "theme_installed": t("From an installed theme"), "website_cow_copy": t("Website copy (COW)"), "studio": t("Made with Studio"), "imported_or_exported": t("Imported or exported"), "ui_created": t("Created from the interface"), "module_view_drifted": t("From a module, silently drifted"), "module_view_flagged": t("From a module, flagged as touched"), "module_view": t("Straight from a module"), }.get(name, name) def classify(row): """(catégorie, raisons) d'une vue. Fonction pure, testable sur fixture. ``raisons`` porte tout ce qu'on sait et que la catégorie ne dit pas : une copie COW qui est aussi signalée le mentionne, sinon l'information se perdrait au profit de la seule catégorie retenue. """ lst_module = row.get("xmlid_modules") or [] has_xmlid = bool(lst_module) lst_reason = [] if row.get("arch_updated"): lst_reason.append("arch_updated") if row.get("noupdate"): lst_reason.append("noupdate") if row.get("has_arch_prev"): lst_reason.append("has_arch_prev") if not row.get("active"): lst_reason.append("inactive") if row.get("theme_template_id"): return "theme_installed", lst_reason if row.get("website_id"): if not row.get("has_module_twin"): lst_reason.append("no_module_twin") return "website_cow_copy", lst_reason if STUDIO_MODULE in lst_module: return "studio", lst_reason if any(module in NOT_A_MODULE for module in lst_module): return "imported_or_exported", lst_reason if not has_xmlid and not row.get("arch_fs"): return "ui_created", lst_reason # `arch_updated` SEUL fait basculer une vue de module. `noupdate` reste une # raison, jamais un motif : toute vue déclarée dans un bloc # le porte — les données de mail, d'account, de website # en sont pleines — et rien n'y a été touché. L'y inclure noierait la # catégorie qui compte sous des centaines de vues parfaitement normales. if row.get("arch_fs") and row.get("arch_updated"): return "module_view_flagged", lst_reason return "module_view", lst_reason def _view_rows(database, **kwargs): """Une ligne par vue, sans son arch. L'arch n'est pas rapatriée : quelques milliers de vues dont certaines dépassent 100 ko tiendraient dans une seule ligne de sortie psql, dupliquée par json.loads. La taille et l'empreinte suffisent à cet inventaire ; la comparaison, qui a besoin du contenu, ira le chercher pour les seules vues retenues. Les identifiants externes sont AGRÉGÉS. Une jointure plate multiplierait les lignes d'une vue qui en porte plusieurs, et un « premier trouvé » déciderait au hasard si elle vient de Studio. """ cols = existing_columns(database, "ir_ui_view", **kwargs) dct_type = column_types(database, "ir_ui_view", **kwargs) def col(column, absent): """« v.colonne » si elle existe, sinon un littéral du bon type. Toutes les colonnes passent par ici, y compris celles qu'on croit acquises comme create_uid : sur une base rognée ou anonymisée, une seule colonne manquante fait échouer la requête entière, et l'outil rendrait 2 là où il pouvait encore répondre. """ return f"v.{column}" if column in cols else absent name = tr_col("v", "name", dct_type) has_website_col = "website_id" in cols website = col("website_id", "NULL::integer") # La CTE « twin » n'a pas l'alias « v » : il lui faut la colonne nue. Sans # le module website, il n'y a aucune copie COW et toute vue à clé est sa # propre référence — d'où le « TRUE ». twin_filter = "website_id IS NULL" if has_website_col else "TRUE" theme = col("theme_template_id", "NULL::integer") arch_fs = col("arch_fs", "NULL::text") arch_updated = col("arch_updated", "false") arch_prev = "(v.arch_prev IS NOT NULL)" if "arch_prev" in cols else "false" return json_query( database, f""" WITH xid AS ( SELECT res_id, array_agg(DISTINCT module) AS modules, array_agg(module || '.' || name ORDER BY module, name) AS xmlids, bool_or(noupdate) AS noupdate FROM ir_model_data WHERE model = 'ir.ui.view' GROUP BY res_id ), twin AS ( SELECT DISTINCT key FROM ir_ui_view WHERE key IS NOT NULL AND {twin_filter} ) SELECT v.id AS id, {name} AS name, {col("model", "NULL::text")} AS model, {col("type", "NULL::text")} AS type, {col("key", "NULL::text")} AS key, {col("mode", "NULL::text")} AS mode, {col("active", "true")} AS active, {col("inherit_id", "NULL::integer")} AS inherit_id, {arch_fs} AS arch_fs, {arch_updated} AS arch_updated, {arch_prev} AS has_arch_prev, {website} AS website_id, {theme} AS theme_template_id, x.modules AS xmlid_modules, x.xmlids AS xmlids, COALESCE(x.noupdate, false) AS noupdate, (v.key IS NOT NULL AND EXISTS (SELECT 1 FROM twin WHERE twin.key = v.key)) AS has_module_twin, {col("create_uid", "NULL::integer")} AS create_uid, {col("create_date", "NULL::timestamp")} AS create_date, {col("write_uid", "NULL::integer")} AS write_uid, {col("write_date", "NULL::timestamp")} AS write_date, octet_length(v.arch_db::text) AS arch_bytes, md5(v.arch_db::text) AS arch_md5 FROM ir_ui_view v LEFT JOIN xid x ON x.res_id = v.id ORDER BY v.id """, **kwargs, ) def checkout_odoo_version(): """Version Odoo de l'ARBRE SOURCE, qui n'est pas celle de la base.""" try: with open(os.path.join(REPO_ROOT, ".odoo-version")) as handle: return handle.read().strip() except OSError: return None def same_major(version_a, version_b): """Deux versions ont-elles la même majeure ? « 18.0.1.3 » vs « 18.0 ».""" if not version_a or not version_b: return False return version_a.split(".")[0] == version_b.split(".")[0] def add_reference_arch(database, lst_finding, config_path=None, timeout=600): """Compléter les constats avec l'arch que déclare le module. Ne s'adresse qu'aux vues qui ont un ``arch_fs`` : les autres n'ont aucune contrepartie dans les sources, il n'y a rien à comparer. Renvoie ``(source, erreur)`` — « orm » si le registre a répondu, « none » sinon, avec le message. Jamais d'échec silencieux : une comparaison qui n'a pas eu lieu ne doit pas se lire comme une comparaison sans écart. """ lst_todo = [row for row in lst_finding if row.get("arch_fs")] if not lst_todo: return "none", None dct_by_id = {row["id"]: row for row in lst_todo} lst_id = sorted(dct_by_id) try: for start in range(0, len(lst_id), BATCH): chunk = lst_id[start : start + BATCH] for answer in odoo_shell_json( database, SHELL_SCRIPT, env={"VIEW_IDS": ",".join(str(i) for i in chunk)}, timeout=timeout, config_path=config_path, ): row = dct_by_id.get(answer["id"]) if row is None: continue row["arch_ref"] = answer.get("arch_file") row["arch_db_text"] = answer.get("arch_db") row["arch_ref_error"] = answer.get("error") except AnalyseError as exc: return "none", str(exc) for row in lst_todo: differs, comparable = arch_differs( row.get("arch_ref"), row.get("arch_db_text") ) row["comparable"] = comparable row["differs"] = differs if comparable: row["diff_stats"] = diff_stats( side_by_side(row["arch_ref"], row["arch_db_text"]) ) return "orm", None def attach_cow_twin_diff(lst_finding, dct_twin, dct_copy_arch): """Comparer chaque copie COW à la vue de module qu'elle masque. C'est LA comparaison qui compte pour une copie, et elle n'a besoin d'aucun registre : les deux côtés sont dans la base, appariés par leur clé — exactement l'appariement que fait Odoo, et la raison pour laquelle renommer une clé suffit à désapparier une copie. Elle marche donc là où la comparaison avec la source du module est impossible : une base dont la version diffère du checkout, et une sauvegarde .zip, qui portent l'une comme l'autre les deux arch. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur web, il n'y a rien à quoi la comparer. Fonction pure — les deux provenances lui passent leurs dictionnaires et concluent donc la même chose des mêmes faits. Renvoie le nombre de copies comparées. """ n_compared = 0 for row in lst_finding: if row["category"] != "website_cow_copy": continue twin = dct_twin.get(row.get("key")) if not twin: continue twin_id, twin_arch = twin copy_arch = dct_copy_arch.get(row["id"]) if copy_arch is None: continue differs, comparable = arch_differs(twin_arch, copy_arch) # Mêmes noms de champs que la comparaison avec la source du module : # l'écran de navigation et le rendu texte marchent alors sans savoir # laquelle des deux a produit la donnée. row["arch_ref"] = twin_arch row["arch_db_text"] = copy_arch row["twin_id"] = twin_id row["comparable"] = comparable row["differs"] = differs if comparable: row["diff_stats"] = diff_stats(side_by_side(twin_arch, copy_arch)) n_compared += 1 return n_compared def _cow_twin_arch(database, lst_key, **kwargs): """{clé: (id, arch)} des vues de module masquées par ces copies.""" if not lst_key: return {} values = ", ".join( "'" + k.replace("'", "''") + "'" for k in sorted(set(lst_key)) ) rows = json_query( database, f""" SELECT DISTINCT ON (v.key) v.key AS key, v.id AS id, v.arch_db::text AS arch FROM ir_ui_view v WHERE v.key IN ({values}) AND v.website_id IS NULL ORDER BY v.key, v.id """, **kwargs, ) return {r["key"]: (r["id"], normalise_arch(r["arch"])) for r in rows} def _cow_copy_arch(database, lst_id, **kwargs): """{id: arch} des seules copies retenues. Rapatrier l'arch de toutes les vues ferait une ligne de sortie de plusieurs centaines de mégaoctets ; celle des seules copies COW en fait quelques-uns. """ if not lst_id: return {} ids = ", ".join(str(int(i)) for i in lst_id) rows = json_query( database, "SELECT id AS id, arch_db::text AS arch" f" FROM ir_ui_view WHERE id IN ({ids})", **kwargs, ) return {r["id"]: normalise_arch(r["arch"]) for r in rows} def add_cow_twin_diff(database, lst_finding, **kwargs): """Comparer les copies COW d'une BASE à leur jumelle. Deux requêtes.""" lst_copy = [ row for row in lst_finding if row["category"] == "website_cow_copy" and row.get("has_module_twin") ] if not lst_copy: return 0 dct_twin = _cow_twin_arch( database, [row["key"] for row in lst_copy if row.get("key")], **kwargs ) dct_copy = _cow_copy_arch( database, [row["id"] for row in lst_copy], **kwargs ) return attach_cow_twin_diff(lst_finding, dct_twin, dct_copy) def collect( database, with_diff=False, with_cow_diff=False, scope="flagged", config_path=None, timeout=120, shell_timeout=600, ): """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" kwargs = {"config_path": config_path, "timeout": timeout} require_odoo_database(database, **kwargs) odoo_version = scalar_query( database, "SELECT latest_version FROM ir_module_module WHERE name = 'base';", **kwargs, ) has_website = bool( scalar_query( database, "SELECT 1 FROM ir_module_module" " WHERE name = 'website' AND state = 'installed';", **kwargs, ) ) lst_view = _view_rows(database, **kwargs) dct_count = {name: 0 for name in CATEGORIES} lst_finding = [] for row in lst_view: category, lst_reason = classify(row) row["category"] = category row["reason"] = lst_reason dct_count[category] += 1 if category in ACTIONABLE: lst_finding.append(row) n_cow_compared = 0 if with_cow_diff: # Indépendant de la voie ORM : les deux arch sont en base, donc ceci # marche même quand la version du checkout interdit l'autre. n_cow_compared = add_cow_twin_diff(database, lst_finding, **kwargs) arch_ref_source, arch_ref_error = "none", None checkout = checkout_odoo_version() if with_diff: if not same_major(odoo_version, checkout): # Le shell charge l'arbre du checkout, pas celui de la base : sur # une base 13.0 avec un checkout 18.0 le registre ne chargera pas. # Le dire en une seconde vaut mieux que trente secondes de # chargement pour aboutir à la même conclusion. arch_ref_error = ( f"{t('Database is Odoo')} {odoo_version}," f" {t('checkout is')} {checkout}" ) else: # « flagged » ne compare que ce qui porte déjà un signe. C'est # rapide, et aveugle au cas même que les drapeaux ratent : une vue # réécrite en SQL direct n'arme pas arch_updated. « all » compare # toute vue ayant un arch_fs et voit cette dérive silencieuse. # « flagged » ne compare que ce qui porte déjà un signe : rapide, # et ce qu'il rapporte est fiable. « all » compare toute vue ayant # un arch_fs, ce qui trouve la dérive qu'aucun drapeau ne signale — # une vue réécrite en SQL direct — mais au prix d'un plancher de # bruit : sur une base 18.0 fraîchement installée, 160 des 974 # vues à arch_fs diffèrent déjà. read_arch_from_file rend le # XML brut du fichier, alors que la base porte l'arch APRÈS # traitement au chargement : un attribut « groups » est consommé, # un est appliqué. En « all », un # écart est une piste, pas un verdict. lst_candidate = ( lst_finding if scope == "flagged" else [row for row in lst_view if row.get("arch_fs")] ) arch_ref_source, arch_ref_error = add_reference_arch( database, lst_candidate, config_path=config_path, timeout=shell_timeout, ) # Une vue signalée dont la forme canonique égale celle du module n'a rien # de modifié : le drapeau disait vrai sur « touchée », faux sur « autre ». # C'est tout l'intérêt de comparer, alors elle quitte les constats. n_identical = 0 if arch_ref_source == "orm": if scope != "flagged": # Une vue sans drapeau dont l'arch diffère de son module a été # réécrite sans passer par Odoo. C'est le constat que seule la # comparaison peut produire, et le plus intéressant du lot. known = {row["id"] for row in lst_finding} for row in lst_view: if row["id"] in known or not row.get("differs"): continue dct_count[row["category"]] -= 1 row["category"] = "module_view_drifted" row["reason"] = row["reason"] + ["differs_from_module"] dct_count["module_view_drifted"] += 1 lst_finding.append(row) lst_kept = [] for row in lst_finding: if ( row["category"] == "module_view_flagged" and row.get("comparable") and row.get("differs") is False ): row["category"] = "module_view" row["reason"] = row["reason"] + ["identical_after_canonical"] dct_count["module_view_flagged"] -= 1 dct_count["module_view"] += 1 n_identical += 1 continue lst_kept.append(row) lst_finding = lst_kept return { "tool": "analyse_view_custom", "version": 1, "database": database, "odoo_version": odoo_version, "checkout_version": checkout, "has_website": has_website, "compared_with_module_source": arch_ref_source == "orm", "arch_ref_source": arch_ref_source, "scope": scope, "arch_ref_error": arch_ref_error, "n_identical_after_canonical": n_identical, "n_cow_compared": n_cow_compared, "n_views": len(lst_view), "counts": dct_count, "findings": lst_finding, } def collect_from_backup(zip_path): """Même inventaire, depuis une sauvegarde .zip, sans rien restaurer. Le classement est identique : il ne dépend que de colonnes que le dump porte toutes. Ce qui manque est la COMPARAISON — l'arch de référence vient de `read_arch_from_file`, donc d'un registre Odoo chargé, et un zip n'en a pas. Le rapport le dit plutôt que de laisser croire à une absence d'écart. """ manifest, dct_rows, _, _ = read_backup( zip_path, tables=("ir_ui_view", "ir_model_data", "ir_module_module"), ) dct_xmlid, dct_noupdate = {}, {} for row in dct_rows["ir_model_data"]: if row.get("model") != "ir.ui.view": continue res_id = row.get("res_id") dct_xmlid.setdefault(res_id, set()).add(row.get("module")) if row.get("noupdate") == "t": dct_noupdate[res_id] = True # Une copie COW a une jumelle si une AUTRE vue, sans website_id, porte la # même clé. Le même appariement que fait la CTE « twin » côté SQL. set_twin = { row.get("key") for row in dct_rows["ir_ui_view"] if row.get("key") and row.get("website_id") in (None, "") } lst_view = [] for row in dct_rows["ir_ui_view"]: res_id = row.get("id") website = row.get("website_id") lst_view.append( { "id": int(res_id) if (res_id or "").isdigit() else res_id, "name": normalise_arch(row.get("name")), "model": row.get("model"), "type": row.get("type"), "key": row.get("key"), "mode": row.get("mode"), "active": row.get("active") != "f", "arch_fs": row.get("arch_fs"), "arch_updated": row.get("arch_updated") == "t", "has_arch_prev": bool(row.get("arch_prev")), "website_id": website if website not in (None, "") else None, "theme_template_id": row.get("theme_template_id") or None, "xmlid_modules": sorted(dct_xmlid.get(res_id) or []), "noupdate": dct_noupdate.get(res_id, False), "has_module_twin": bool( row.get("key") and row.get("key") in set_twin ), "arch_bytes": len(row.get("arch_db") or ""), } ) dct_count = {name: 0 for name in CATEGORIES} lst_finding = [] for row in lst_view: category, lst_reason = classify(row) row["category"] = category row["reason"] = lst_reason dct_count[category] += 1 if category in ACTIONABLE: lst_finding.append(row) # Le dump porte les arch des deux côtés : la comparaison COW est donc # possible depuis un zip, là où celle avec la source du module ne l'est # pas faute de registre. dct_twin = {} for row in dct_rows["ir_ui_view"]: if row.get("key") and row.get("website_id") in (None, ""): dct_twin.setdefault( row["key"], (row.get("id"), normalise_arch(row.get("arch_db"))), ) dct_copy = { (int(r.get("id")) if (r.get("id") or "").isdigit() else r.get("id")): ( normalise_arch(r.get("arch_db")) ) for r in dct_rows["ir_ui_view"] } n_cow_compared = attach_cow_twin_diff(lst_finding, dct_twin, dct_copy) return { "tool": "analyse_view_custom", "version": 1, "database": os.path.basename(zip_path), "source": "backup", "n_cow_compared": n_cow_compared, "backup_path": zip_path, "odoo_version": backup_version(dct_rows, manifest), "checkout_version": checkout_odoo_version(), "has_website": any(r.get("website_id") for r in lst_view), "compared_with_module_source": False, "arch_ref_source": "none", "arch_ref_error": None, "from_backup_no_registry": True, "n_identical_after_canonical": 0, "scope": "flagged", "n_views": len(lst_view), "counts": dct_count, "findings": lst_finding, } def _finding_block(lst_row, top, hints=True): """Une ligne par vue : clé, identifiant externe, poids, raisons.""" lines = [ f" {'id':>6} {'key / xml-id':<44}{'size':>9} {t('why')}", ] for row in lst_row[:top]: label = row.get("key") or (row.get("xmlids") or [""])[0] or "—" size = row.get("arch_bytes") lines.append( f" {row['id']:>6} {label[:44]:<44}" f"{(str(size) + ' B') if size else '?':>9} " f"{', '.join(row.get('reason') or []) or '—'}" ) if len(lst_row) > top: lines.append(f" … {len(lst_row) - top} {t('more')}") return lines def render(data, verbose=False, top=TOP_DEFAULT, category=None, hints=True): """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.""" version = data.get("odoo_version") or "?" counts = data["counts"] lines = [ "", f"🔬 {t('Customised views')} — {data['database']} (Odoo {version}" f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", "", f" {t('Views'):<38}: {data['n_views']}", ] for name in CATEGORIES: if counts.get(name): lines.append(f" {category_label(name):<38}: {counts[name]}") n_finding = len(data["findings"]) if not n_finding: lines += [ "", f"✅ {t('Every view comes straight from a module.')}", ] return "\n".join(lines) + "\n" lst_show = data["findings"] if category: lst_show = [r for r in lst_show if r["category"] == category] lines += [ "", f"── ⚠️ {t('Views that did not come straight from a module')}" f" ({len(lst_show)}) ──", ] lines += _finding_block(lst_show, len(lst_show) if verbose else top) if counts.get("website_cow_copy"): lines.append("") lines += wrap_note( " ", t( "Website copies are user data: Odoo copies a view instead of" " editing it. Whether they will survive the next version is" " another question, and these tools answer it:" ), ) lines += [ " ./script/odoo/migration/check_cow_views.py" " -d DB -t odooXX.0", " ./script/odoo/migration/reset_stale_cow_views.py -d DB", ] lines.append("") if data.get("compared_with_module_source"): if data.get("scope") == "all": lines += wrap_note( " ⚠️ ", t( "In --scope all, a difference is a lead, not a verdict:" " read_arch_from_file returns the raw file, while the" " database holds the arch AFTER load-time processing." " Measured on a freshly installed 18.0 database, 160 of" " its 974 views already differ this way." ), ) lines.append("") if data.get("n_identical_after_canonical"): lines += wrap_note( " ✅ ", f"{data['n_identical_after_canonical']} " + t( "views were flagged but hold exactly what their module" " declares: only the comparison could tell." ), ) lines += wrap_note( " 💡 ", t( "To restore a view to what its module declares — this WRITES" " to the database, so read the difference first:" ), ) lines.append( " echo \"env['ir.ui.view'].browse(ID).reset_arch('hard');" ' env.cr.commit()" \\' ) lines.append( f" | ./odoo_bin.sh shell -c ./config.conf" f" -d {data['database']}" ) elif data.get("from_backup_no_registry"): lines += wrap_note( " ℹ️ ", t( "A backup holds no registry, so nothing was compared with the" " module source. The classification above needs none; only the" " differences do. Restore it, or run this on the database." ), ) elif data.get("arch_ref_error"): lines += wrap_note( " ⚠️ ", t("No reference arch, so nothing was compared: ") + str(data["arch_ref_error"]), ) elif hints: lines += wrap_note( " ℹ️ ", t( "Flags say a view was touched, not how. They are incomplete" " both ways: a direct SQL write does not set arch_updated," " and reset_arch clears it. Comparing with the module source" " is what settles it — add --diff." ), ) else: lines += wrap_note( " ℹ️ ", t( "Flags say a view was touched, not how: only comparing with" " the module source settles it." ), ) return "\n".join(lines) + "\n" def render_diff(row, width=78): """Le diff d'une vue, côte à côte, pour la sortie texte. Calculé sur les arch BRUTES, pas sur les formes canoniques : la forme canonique sert à décider s'il y a un écart, elle ne se relit pas. """ lines = [ "", f"── id={row['id']} {row.get('key') or '—'} " f"({row.get('arch_fs') or '—'}) ──", ] half = (width - 4) // 2 for mark, left, right in side_by_side( row.get("arch_ref"), row.get("arch_db_text") ): if mark == " ": continue lines.append( f" {mark} {(left or '')[:half]:<{half}} │ {(right or '')[:half]}" ) return lines def open_tui(data): """Ouvrir l'écran de navigation. False si on n'a pas pu — l'appelant imprime. Trois refus, trois raisons distinctes, et aucune n'est une panne : rien à montrer, pas de terminal, ou Textual absent. Chacune se dit, plutôt que d'ouvrir un écran vide ou de laisser des codes d'échappement dans un fichier de sortie. """ lst_diff = [row for row in data["findings"] if row.get("differs")] if not lst_diff: return False if not sys.stdout.isatty(): print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") return False try: from script.todo import textual_setup except Exception: textual_setup = None if textual_setup and not textual_setup.ensure(): return False from script.analyse.analyse_diff_tui import run_diff_tui intent = run_diff_tui(data) if intent and intent[0] == "command": row = intent[1] print(f"\n💡 {t('To restore this view to what its module declares:')}") print( f" echo \"env['ir.ui.view'].browse({row['id']})" f".reset_arch('hard'); env.cr.commit()\" \\" ) print( f" | ./odoo_bin.sh shell -c ./config.conf" f" -d {data['database']}" ) return True def main(argv=None): parser = argparse.ArgumentParser( description=t( "List the views of an Odoo database that did not come straight" " from a module, website copies included (read-only)." ) ) source = parser.add_mutually_exclusive_group(required=True) source.add_argument("-d", "--database", help=t("database to inspect")) source.add_argument( "-z", "--zip", dest="backup", help=t("Odoo backup .zip to inspect, without restoring it"), ) parser.add_argument( "--category", choices=CATEGORIES, default=None, help=t("only show this category"), ) parser.add_argument( "--top", type=int, default=TOP_DEFAULT, help=t("how many views to show (default: 20)"), ) parser.add_argument( "-v", "--verbose", action="store_true", help=t("list every view") ) parser.add_argument( "--diff", action="store_true", help=t("compare with the module source (opens an Odoo shell)"), ) parser.add_argument( "--scope", choices=("flagged", "all"), default="flagged", help=t("which views to compare (default: flagged)"), ) parser.add_argument( "--cow-diff", dest="cow_diff", action="store_true", help=t("compare each website copy with the module view it shadows"), ) parser.add_argument( "--strict", action="store_true", help=t("fail if the comparison could not be made"), ) parser.add_argument( "--tui", action="store_true", help=t("browse the differences in a full-screen view"), ) parser.add_argument("--json", action="store_true", help=t("output JSON")) parser.add_argument( "-c", "--config", default=None, help=t("path to an Odoo config file") ) config = parser.parse_args(argv) try: if config.backup: data = collect_from_backup(config.backup) else: data = collect( config.database, with_diff=config.diff, with_cow_diff=config.cow_diff or config.tui, scope=config.scope, config_path=config.config, ) except AnalyseError as exc: print(f"❌ {exc}") return 2 except KeyboardInterrupt: print(f"\n{t('Cancelled.')}") return 2 if config.strict and not data["compared_with_module_source"]: print( f"❌ {t('No reference arch, so nothing was compared: ')}" f"{data.get('arch_ref_error') or ''}" ) return 2 if config.json: print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) return 1 if data["findings"] else 0 if config.tui and open_tui(data): return 1 if data["findings"] else 0 print( render( data, verbose=config.verbose, top=config.top, category=config.category, ) ) if config.verbose and data["compared_with_module_source"]: for row in data["findings"]: if row.get("differs"): print("\n".join(render_diff(row))) return 1 if data["findings"] else 0 if __name__ == "__main__": sys.exit(main())