erplibre/script/analyse/analyse_view_custom.py
Mathieu Benoit 0870696701 [FIX] script: make the analysis and migration tools executable
Nine of the thirteen carried a shebang without the bit, so only four
could be run by their own path. Set to 755, not chmod +x: two were
664 under the 0002 umask and would have become 775 — executed by
others while a group member could still rewrite them. That pairing is
the only real risk here; the bit alone grants nothing, since reading
the file is enough to run python3 on it.

Being runnable opens a path without the venv: the shebang resolves to
the system python3, which has no Textual. --tui fell back to the text
report saying nothing. It now names the interpreter and the venv.

--- FR ---

[FIX] script : rendre exécutables les outils d'analyse et de migration

Neuf des treize portaient un shebang sans le bit ; quatre seulement
se lançaient par leur chemin. Mis à 755, pas chmod +x : deux étaient
en 664 sous l'umask 0002 et seraient passés à 775 — exécutés par
d'autres alors qu'un membre du groupe pouvait encore les réécrire.
C'est la seule vraie prise ici ; le bit seul n'accorde rien, lire le
fichier suffit déjà à lancer python3 dessus.

Devenir lançable ouvre un chemin sans le venv : le shebang résout le
python du système, sans Textual. --tui retombait sur le rapport texte
sans rien dire. Il nomme désormais l'interpréteur et le venv.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

858 lines
32 KiB
Python
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
# <odoo noupdate="1"> 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 collect(
database,
with_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)
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 MESURÉ : 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 <xpath position="attributes"> 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_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)
return {
"tool": "analyse_view_custom",
"version": 1,
"database": os.path.basename(zip_path),
"source": "backup",
"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(
"--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 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())