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
858 lines
32 KiB
Python
Executable file
858 lines
32 KiB
Python
Executable file
#!/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())
|