[ADD] analyse: inspect an Odoo database without restoring it
A read-only toolkit answering « what is in this database, and what will break on a version bump »: schema size and tables no model claims, customised views compared with their module source, and the x_ fields Studio left behind. It reads a backup zip directly, so a 40 GB dump needs no restore, and it reaches every answer from the TODO menu. --- FR --- Une boîte à outils en lecture seule qui répond à « qu'y a-t-il dans cette base, et qu'est-ce qui cassera à la montée de version » : taille du schéma et tables qu'aucun modèle ne réclame, vues personnalisées comparées à leur source, champs x_ laissés par Studio. Elle lit un zip de sauvegarde tel quel, donc sans restaurer, et tout s'atteint depuis le menu TODO. Assisted-by: Claude Opus 5
This commit is contained in:
parent
e8ee8cc17f
commit
b1c1095977
14 changed files with 5499 additions and 795 deletions
572
script/analyse/analyse_custom_field.py
Normal file
572
script/analyse/analyse_custom_field.py
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Champs et modèles ajoutés hors module : Studio, ou faits à la main.
|
||||
|
||||
Ce que l'outil répond : ce qu'un intégrateur devra reporter à la main lors
|
||||
d'une montée de version, puisque rien ne le recréera. Un champ ``x_`` n'est
|
||||
déclaré dans aucun fichier ; il ne vit que dans ``ir_model_fields``, et une
|
||||
migration qui le perd perd aussi les données de sa colonne.
|
||||
|
||||
Studio n'est pas nécessaire pour lire ça
|
||||
-----------------------------------------
|
||||
``web_studio`` est un module Enterprise, absent de ce dépôt. Les champs qu'il
|
||||
crée restent pourtant de simples lignes de ``ir_model_fields`` avec
|
||||
``state = 'manual'``, et une base migrée depuis une instance Enterprise garde
|
||||
ses identifiants externes ``studio_customization``. Tout se lit en SQL.
|
||||
|
||||
Attribuer un champ à Studio demande deux signaux, pas un
|
||||
---------------------------------------------------------
|
||||
Un champ peut porter PLUSIEURS identifiants externes. Une jointure plate n'en
|
||||
rendrait qu'un, choisi au hasard : Studio passerait inaperçu une fois sur
|
||||
deux. Les modules sont donc agrégés, et l'appartenance testée sur l'ensemble.
|
||||
|
||||
Le préfixe ``x_studio_`` est un indice de plus, jamais le seul : un champ créé
|
||||
à la main en mode développeur s'appelle aussi ``x_quelque_chose``, et ce qui
|
||||
le distingue est qu'il n'a AUCUN identifiant externe.
|
||||
|
||||
Ce qui bloque, et ce qui ne fait que coûter
|
||||
--------------------------------------------
|
||||
Un champ stocké dont la colonne physique manque empêche le registre de
|
||||
charger : c'est un blocage. Un champ dont la relation pointe vers un modèle
|
||||
disparu aussi. Le reste — des champs remplis à reporter, des champs vides à
|
||||
supprimer avant de migrer — est du travail, pas une panne, et le rapport ne
|
||||
les mélange pas.
|
||||
"""
|
||||
|
||||
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
|
||||
AnalyseError,
|
||||
backup_version,
|
||||
column_types,
|
||||
existing_columns,
|
||||
json_query,
|
||||
model_table,
|
||||
normalise_arch,
|
||||
public_tables,
|
||||
read_backup,
|
||||
require_odoo_database,
|
||||
scalar_query,
|
||||
t,
|
||||
tr_col,
|
||||
)
|
||||
|
||||
STUDIO_MODULE = "studio_customization"
|
||||
|
||||
# Un champ relationnel « vers plusieurs » n'a pas de colonne : il vit dans une
|
||||
# table de relation. Ne pas l'exclure ferait rapporter chaque one2many et
|
||||
# chaque many2many comme une colonne manquante.
|
||||
NO_COLUMN_TYPES = ("one2many", "many2many")
|
||||
|
||||
TOP_DEFAULT = 30
|
||||
|
||||
|
||||
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 origin_label(name):
|
||||
"""Libellé traduit d'une provenance. Épelé, pour que le contrôle voie."""
|
||||
return {
|
||||
"studio": t("Studio"),
|
||||
"handmade": t("Made by hand"),
|
||||
"module": t("Declared by a module"),
|
||||
}.get(name, name)
|
||||
|
||||
|
||||
def blocker_label(name):
|
||||
"""Libellé traduit d'un blocage. Épelé, comme les provenances."""
|
||||
return {
|
||||
"missing_column": t("stored, but its column is missing"),
|
||||
"dangling_relation": t("points at a model that no longer exists"),
|
||||
"model_gone": t("its model no longer exists"),
|
||||
"table_unknown": t("its table could not be resolved"),
|
||||
}.get(name, name)
|
||||
|
||||
|
||||
def field_origin(row):
|
||||
"""D'où vient ce champ : Studio, fait à la main, ou déclaré par un module.
|
||||
|
||||
Fonction pure, testable sur fixture.
|
||||
"""
|
||||
lst_module = row.get("xmlid_modules") or []
|
||||
if STUDIO_MODULE in lst_module:
|
||||
return "studio"
|
||||
if not lst_module:
|
||||
return "handmade"
|
||||
return "module"
|
||||
|
||||
|
||||
def _field_rows(database, **kwargs):
|
||||
"""Les champs manuels, avec tout ce qui sert à les juger.
|
||||
|
||||
``state = 'manual'`` est le critère d'Odoo ; le motif sur le nom l'élargit
|
||||
aux bases dont la contrainte n'a pas toujours été posée. Les deux, parce
|
||||
qu'aucun n'est complet seul.
|
||||
"""
|
||||
cols = existing_columns(database, "ir_model_fields", **kwargs)
|
||||
dct_type = column_types(database, "ir_model_fields", **kwargs)
|
||||
|
||||
def col(column, absent):
|
||||
return f"f.{column}" if column in cols else absent
|
||||
|
||||
label = tr_col("f", "field_description", dct_type)
|
||||
help_text = tr_col("f", "help", dct_type)
|
||||
return json_query(
|
||||
database,
|
||||
rf"""
|
||||
SELECT f.id AS id,
|
||||
f.model AS model,
|
||||
f.name AS name,
|
||||
f.ttype AS ttype,
|
||||
{col("relation", "NULL::text")} AS relation,
|
||||
{col("related", "NULL::text")} AS related,
|
||||
{col("required", "false")} AS required,
|
||||
{col("readonly", "false")} AS readonly,
|
||||
{col("store", "true")} AS store,
|
||||
{col("index", "false")} AS indexed,
|
||||
{col("translate", "false")} AS translate,
|
||||
{col("company_dependent", "false")} AS company_dependent,
|
||||
({col("compute", "NULL")} IS NOT NULL
|
||||
AND {col("compute", "''")} <> '') AS is_computed,
|
||||
{label} AS label,
|
||||
{help_text} AS help,
|
||||
{col("state", "NULL::text")} AS state,
|
||||
x.modules AS xmlid_modules,
|
||||
{col("create_date", "NULL::timestamp")} AS create_date,
|
||||
{col("write_date", "NULL::timestamp")} AS write_date
|
||||
FROM ir_model_fields f
|
||||
LEFT JOIN (
|
||||
SELECT res_id, array_agg(DISTINCT module) AS modules
|
||||
FROM ir_model_data
|
||||
WHERE model = 'ir.model.fields'
|
||||
GROUP BY res_id
|
||||
) x ON x.res_id = f.id
|
||||
WHERE {col("state", "''")} = 'manual' OR f.name LIKE 'x\_%'
|
||||
ORDER BY f.model, f.name
|
||||
""",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _model_rows(database, **kwargs):
|
||||
"""Les modèles manuels — ceux que Studio crée comme objets personnalisés."""
|
||||
dct_type = column_types(database, "ir_model", **kwargs)
|
||||
label = tr_col("m", "name", dct_type)
|
||||
return json_query(
|
||||
database,
|
||||
rf"""
|
||||
SELECT m.model AS model,
|
||||
{label} AS description,
|
||||
m.state AS state,
|
||||
m.transient AS transient,
|
||||
x.modules AS xmlid_modules
|
||||
FROM ir_model m
|
||||
LEFT JOIN (
|
||||
SELECT res_id, array_agg(DISTINCT module) AS modules
|
||||
FROM ir_model_data
|
||||
WHERE model = 'ir.model'
|
||||
GROUP BY res_id
|
||||
) x ON x.res_id = m.id
|
||||
WHERE m.state = 'manual' OR m.model LIKE 'x\_%'
|
||||
ORDER BY m.model
|
||||
""",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _selection_rows(database, **kwargs):
|
||||
"""Valeurs de sélection des champs manuels, si la table existe.
|
||||
|
||||
``ir_model_fields_selection`` est apparue en cours de route : avant, les
|
||||
valeurs vivaient dans une chaîne du champ. On sonde plutôt que de dater.
|
||||
"""
|
||||
if not scalar_query(
|
||||
database,
|
||||
"SELECT to_regclass('public.ir_model_fields_selection');",
|
||||
**kwargs,
|
||||
):
|
||||
return {}
|
||||
rows = json_query(
|
||||
database,
|
||||
"""
|
||||
SELECT f.model AS model, f.name AS name, s.value AS value
|
||||
FROM ir_model_fields_selection s
|
||||
JOIN ir_model_fields f ON f.id = s.field_id
|
||||
WHERE f.state = 'manual'
|
||||
ORDER BY f.model, f.name, s.sequence
|
||||
""",
|
||||
**kwargs,
|
||||
)
|
||||
dct = {}
|
||||
for row in rows:
|
||||
dct.setdefault((row["model"], row["name"]), []).append(row["value"])
|
||||
return dct
|
||||
|
||||
|
||||
def collect(database, config_path=None, timeout=120):
|
||||
"""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,
|
||||
)
|
||||
|
||||
lst_field = _field_rows(database, **kwargs)
|
||||
lst_model = _model_rows(database, **kwargs)
|
||||
dct_selection = _selection_rows(database, **kwargs)
|
||||
set_table = public_tables(database, **kwargs)
|
||||
set_model = {
|
||||
row["model"]
|
||||
for row in json_query(database, "SELECT model FROM ir_model", **kwargs)
|
||||
}
|
||||
|
||||
# Les colonnes réelles, une sonde par table concernée seulement.
|
||||
dct_columns = {}
|
||||
for row in lst_field:
|
||||
table = model_table(row["model"], known_tables=set_table)
|
||||
row["table"] = table
|
||||
if table and table not in dct_columns:
|
||||
dct_columns[table] = existing_columns(database, table, **kwargs)
|
||||
|
||||
lst_blocker = _judge(
|
||||
lst_field, set_model, set_table, dct_columns, dct_selection
|
||||
)
|
||||
return _result(
|
||||
database,
|
||||
odoo_version,
|
||||
lst_field,
|
||||
lst_model,
|
||||
lst_blocker,
|
||||
source="database",
|
||||
)
|
||||
|
||||
|
||||
def _judge(lst_field, set_model, set_table, dct_columns, dct_selection):
|
||||
"""Attribuer et juger chaque champ. Renvoie la liste des bloquants.
|
||||
|
||||
Partagé par la lecture d'une base et celle d'une sauvegarde : les deux
|
||||
doivent conclure la même chose des mêmes faits, sinon le zip et la base
|
||||
d'où il vient ne diraient pas pareil.
|
||||
"""
|
||||
lst_blocker = []
|
||||
for row in lst_field:
|
||||
row["origin"] = field_origin(row)
|
||||
row["selection"] = dct_selection.get((row["model"], row["name"]))
|
||||
row["blocker"] = None
|
||||
|
||||
if row["table"] is None:
|
||||
# Table non résolue : un fait, pas une anomalie. Le modèle peut
|
||||
# avoir un _table surchargé qu'on ne connaît pas, ou ne plus
|
||||
# exister du tout — deux choses qu'on ne confond pas ici.
|
||||
row["blocker"] = (
|
||||
"model_gone"
|
||||
if row["model"] not in set_model
|
||||
else "table_unknown"
|
||||
)
|
||||
elif (
|
||||
row["store"]
|
||||
and row["ttype"] not in NO_COLUMN_TYPES
|
||||
and row["name"] not in dct_columns[row["table"]]
|
||||
):
|
||||
# Un champ stocké sans sa colonne empêche le registre de charger.
|
||||
row["blocker"] = "missing_column"
|
||||
elif row["relation"] and row["relation"] not in set_model:
|
||||
row["blocker"] = "dangling_relation"
|
||||
|
||||
if row["blocker"] in (
|
||||
"missing_column",
|
||||
"dangling_relation",
|
||||
"model_gone",
|
||||
):
|
||||
lst_blocker.append(row)
|
||||
|
||||
return lst_blocker
|
||||
|
||||
|
||||
def _result(
|
||||
database,
|
||||
odoo_version,
|
||||
lst_field,
|
||||
lst_model,
|
||||
lst_blocker,
|
||||
source="database",
|
||||
):
|
||||
"""La donnée de sortie, une seule forme quelle que soit la provenance."""
|
||||
dct_origin = {"studio": 0, "handmade": 0, "module": 0}
|
||||
for row in lst_field:
|
||||
dct_origin[row["origin"]] += 1
|
||||
|
||||
return {
|
||||
"tool": "analyse_custom_field",
|
||||
"version": 1,
|
||||
"database": database,
|
||||
"source": source,
|
||||
"odoo_version": odoo_version,
|
||||
"n_fields": len(lst_field),
|
||||
"n_models": len(lst_model),
|
||||
"counts": {
|
||||
**dct_origin,
|
||||
"blockers": len(lst_blocker),
|
||||
"models": len(lst_model),
|
||||
},
|
||||
"fields": lst_field,
|
||||
"models": lst_model,
|
||||
"blockers": lst_blocker,
|
||||
}
|
||||
|
||||
|
||||
def collect_from_backup(zip_path):
|
||||
"""Même analyse, mais depuis une sauvegarde .zip, sans rien restaurer.
|
||||
|
||||
Pourquoi cela existe : restaurer la sauvegarde d'une instance Enterprise
|
||||
sur une installation Community échoue — Odoo veut charger des modules
|
||||
qu'on n'a pas. Les champs Studio, eux, ne sont que des lignes de
|
||||
`ir_model_fields`, et un `dump.sql` est du texte. On les lit donc là où
|
||||
ils sont, plutôt que d'exiger une restauration impossible.
|
||||
|
||||
Ce que la sauvegarde permet en moins : rien, pour cet outil. Le dump
|
||||
contient les `CREATE TABLE`, donc même la colonne physique manquante — le
|
||||
seul vrai bloquant — se détecte.
|
||||
"""
|
||||
manifest, dct_rows, dct_columns, _ = read_backup(
|
||||
zip_path,
|
||||
tables=(
|
||||
"ir_model_fields",
|
||||
"ir_model",
|
||||
"ir_model_data",
|
||||
"ir_module_module",
|
||||
),
|
||||
with_columns=True,
|
||||
)
|
||||
|
||||
# Les identifiants externes, agrégés par champ et par modèle — le même
|
||||
# regroupement que fait le SQL, pour que Studio s'attribue pareil.
|
||||
dct_xmlid = {}
|
||||
for row in dct_rows["ir_model_data"]:
|
||||
key = (row.get("model"), row.get("res_id"))
|
||||
dct_xmlid.setdefault(key, set()).add(row.get("module"))
|
||||
|
||||
lst_field = []
|
||||
for row in dct_rows["ir_model_fields"]:
|
||||
name = row.get("name") or ""
|
||||
if row.get("state") != "manual" and not name.startswith("x_"):
|
||||
continue
|
||||
lst_field.append(
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"model": row.get("model"),
|
||||
"name": name,
|
||||
"ttype": row.get("ttype"),
|
||||
"relation": row.get("relation"),
|
||||
"related": row.get("related"),
|
||||
# Le dump rend « t »/« f » : PostgreSQL écrit les booléens
|
||||
# ainsi dans un COPY, et « f » est une chaîne vraie en Python.
|
||||
"store": row.get("store") != "f",
|
||||
"translate": row.get("translate") == "t",
|
||||
"state": row.get("state"),
|
||||
# Un champ traduit est du jsonb à partir de 16.0. Depuis une
|
||||
# base, tr_col le déballe côté SQL ; depuis un dump, la valeur
|
||||
# arrive brute, et « {"en_US": "Code client"} » ne se lit pas.
|
||||
# normalise_arch fait ce déballage, et c'est la même fonction
|
||||
# des deux côtés — deux implémentations divergeraient.
|
||||
"label": normalise_arch(row.get("field_description")),
|
||||
"help": normalise_arch(row.get("help")),
|
||||
"xmlid_modules": sorted(
|
||||
dct_xmlid.get(("ir.model.fields", row.get("id"))) or []
|
||||
),
|
||||
"create_date": row.get("create_date"),
|
||||
"write_date": row.get("write_date"),
|
||||
}
|
||||
)
|
||||
|
||||
lst_model = [
|
||||
{
|
||||
"model": row.get("model"),
|
||||
"description": normalise_arch(row.get("name")),
|
||||
"state": row.get("state"),
|
||||
"transient": row.get("transient") == "t",
|
||||
"xmlid_modules": sorted(
|
||||
dct_xmlid.get(("ir.model", row.get("id"))) or []
|
||||
),
|
||||
}
|
||||
for row in dct_rows["ir_model"]
|
||||
if row.get("state") == "manual"
|
||||
or (row.get("model") or "").startswith("x_")
|
||||
]
|
||||
|
||||
set_model = {row.get("model") for row in dct_rows["ir_model"]}
|
||||
set_table = set(dct_columns)
|
||||
for row in lst_field:
|
||||
row["table"] = model_table(row["model"], known_tables=set_table)
|
||||
|
||||
lst_blocker = _judge(lst_field, set_model, set_table, dct_columns, {})
|
||||
data = _result(
|
||||
os.path.basename(zip_path),
|
||||
backup_version(dct_rows, manifest),
|
||||
lst_field,
|
||||
lst_model,
|
||||
lst_blocker,
|
||||
source="backup",
|
||||
)
|
||||
data["backup_path"] = zip_path
|
||||
data["backup_db_name"] = manifest.get("db_name")
|
||||
return data
|
||||
|
||||
|
||||
def _field_block(lst_row, top):
|
||||
"""Une ligne par champ : modèle, nom, type, provenance."""
|
||||
lines = [
|
||||
f" {'model':<28}{'field':<30}{'type':<12}{t('origin')}",
|
||||
]
|
||||
for row in lst_row[:top]:
|
||||
lines.append(
|
||||
f" {(row['model'] or '')[:27]:<28}{(row['name'] or '')[:29]:<30}"
|
||||
f"{(row['ttype'] or '')[:11]:<12}{origin_label(row['origin'])}"
|
||||
)
|
||||
if len(lst_row) > top:
|
||||
lines.append(f" … {len(lst_row) - top} {t('more')}")
|
||||
return lines
|
||||
|
||||
|
||||
def render(data, verbose=False, top=TOP_DEFAULT, hints=True):
|
||||
"""Rapport texte. Fonction pure : donnée -> chaîne, testable sans base."""
|
||||
counts = data["counts"]
|
||||
lines = [
|
||||
"",
|
||||
f"🔬 {t('Fields added outside a module')} — {data['database']}"
|
||||
f" (Odoo {data.get('odoo_version') or '?'}"
|
||||
f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})",
|
||||
"",
|
||||
f" {t('Custom fields'):<30}: {data['n_fields']}",
|
||||
]
|
||||
for name in ("studio", "handmade", "module"):
|
||||
if counts.get(name):
|
||||
lines.append(f" {origin_label(name):<30}: {counts[name]}")
|
||||
if data["n_models"]:
|
||||
lines.append(f" {t('Custom models'):<30}: {data['n_models']}")
|
||||
|
||||
if not data["n_fields"] and not data["n_models"]:
|
||||
lines += [
|
||||
"",
|
||||
f"✅ {t('No field or model was added outside a module.')}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
if data["blockers"]:
|
||||
lines += ["", f"── ❌ {t('Blocking')} ({len(data['blockers'])}) ──"]
|
||||
for row in data["blockers"]:
|
||||
lines.append(
|
||||
f" {row['model']}.{row['name']} —"
|
||||
f" {blocker_label(row['blocker'])}"
|
||||
+ (f" → {row['relation']}" if row.get("relation") else "")
|
||||
)
|
||||
lines.append("")
|
||||
lines += wrap_note(
|
||||
" ",
|
||||
t(
|
||||
"A stored field without its column stops the registry from"
|
||||
" loading, so the upgrade will not even start. Settle these"
|
||||
" before anything else."
|
||||
),
|
||||
)
|
||||
|
||||
lst_show = data["fields"]
|
||||
if lst_show:
|
||||
lines += [
|
||||
"",
|
||||
f"── ⚠️ {t('To carry over by hand')} ({len(lst_show)}"
|
||||
f"{', ' + str(len(data['blockers'])) + ' ' + t('blocking') if data['blockers'] else ''}) ──",
|
||||
]
|
||||
lines += _field_block(lst_show, len(lst_show) if verbose else top)
|
||||
|
||||
if data["models"]:
|
||||
lines += ["", f"── 🧱 {t('Custom models')} ({len(data['models'])}) ──"]
|
||||
for row in data["models"][: len(data["models"]) if verbose else top]:
|
||||
lines.append(
|
||||
f" {row['model']:<32}{(row.get('description') or '')[:40]}"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines += wrap_note(
|
||||
" ",
|
||||
t(
|
||||
"Nothing declares these in a file, so no module will recreate"
|
||||
" them. What a version upgrade keeps is what someone carried over."
|
||||
),
|
||||
)
|
||||
if hints and not verbose:
|
||||
lines += wrap_note(
|
||||
" ℹ️ ",
|
||||
t("Use -v to list them all, --json for the raw data."),
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=t(
|
||||
"List the fields and models added outside a module — Studio or by"
|
||||
" hand (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(
|
||||
"--top",
|
||||
type=int,
|
||||
default=TOP_DEFAULT,
|
||||
help=t("how many to show (default: 30)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help=t("list every one")
|
||||
)
|
||||
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, config_path=config.config)
|
||||
except AnalyseError as exc:
|
||||
print(f"❌ {exc}")
|
||||
return 2
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{t('Cancelled.')}")
|
||||
return 2
|
||||
|
||||
if config.json:
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
||||
else:
|
||||
print(render(data, verbose=config.verbose, top=config.top))
|
||||
return 1 if (data["fields"] or data["models"]) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
189
script/analyse/analyse_diff_tui.py
Normal file
189
script/analyse/analyse_diff_tui.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Naviguer dans les différences entre une vue en base et celle du module.
|
||||
|
||||
Lecture seule, sans exception. La touche « r » AFFICHE la commande de
|
||||
réinitialisation, elle ne l'exécute pas : cet écran sert à décider, et décider
|
||||
suppose d'avoir lu. Une vue personnalisée porte souvent un travail réel que
|
||||
personne ne veut perdre d'un appui sur une touche.
|
||||
|
||||
Pourquoi un seul DataTable à trois colonnes
|
||||
--------------------------------------------
|
||||
Deux panneaux séparés ne défilent pas ensemble : il faudrait synchroniser
|
||||
deux barres, et une ligne de gauche finirait en face de la mauvaise ligne de
|
||||
droite — exactement l'erreur qu'un diff doit rendre impossible. Un seul
|
||||
tableau porte « gauche | marque | droite » sur la même ligne, donc
|
||||
l'alignement est structurel et non entretenu. Il donne aussi le curseur, la
|
||||
sélection et l'événement de survol sans rien écrire.
|
||||
|
||||
``TextArea`` a été écarté : c'est un éditeur, et il n'y a pas de coloration
|
||||
XML sans tree-sitter, absent du venv.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
new_path = os.path.normpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
sys.path.append(new_path)
|
||||
|
||||
from script.analyse.lib_analyse import side_by_side, t # noqa: E402
|
||||
|
||||
CSS = """
|
||||
Screen { layout: vertical; }
|
||||
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
||||
#body { height: 1fr; }
|
||||
#views { width: 34; border-right: solid $accent; }
|
||||
#diff { width: 1fr; }
|
||||
"""
|
||||
|
||||
|
||||
def diff_rows(finding):
|
||||
"""Les lignes en écart d'un constat, sans les lignes identiques.
|
||||
|
||||
Le contexte est utile dans un diff unifié qu'on lit en entier ; ici on
|
||||
saute d'un écart à l'autre, et les centaines de lignes identiques d'une
|
||||
vue de site web ne feraient que les éloigner.
|
||||
"""
|
||||
return [
|
||||
(mark, left, right)
|
||||
for mark, left, right in side_by_side(
|
||||
finding.get("arch_ref"), finding.get("arch_db_text")
|
||||
)
|
||||
if mark != " "
|
||||
]
|
||||
|
||||
|
||||
def build_app(data):
|
||||
"""Construire l'application Textual. Importe Textual seulement ici.
|
||||
|
||||
L'import vit dans la fonction pour que le module reste importable — et
|
||||
donc testable — sur une machine sans Textual.
|
||||
"""
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
|
||||
class DiffApp(App):
|
||||
CSS = globals()["CSS"]
|
||||
BINDINGS = [
|
||||
("q,escape", "quit", t("Quit")),
|
||||
("n", "only_diff", t("Differences only")),
|
||||
("w", "ignore_indent", t("Ignore indentation")),
|
||||
("c", "copy", t("Copy")),
|
||||
("r", "command", t("Reset command")),
|
||||
]
|
||||
|
||||
def __init__(self, data):
|
||||
super().__init__()
|
||||
self.data = data
|
||||
self.lst_finding = [
|
||||
row for row in data["findings"] if row.get("differs")
|
||||
]
|
||||
self.only_diff = True
|
||||
self.ignore_indent = False
|
||||
self.intent = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("", id="head")
|
||||
with Horizontal(id="body"):
|
||||
yield DataTable(id="views", cursor_type="row")
|
||||
yield DataTable(id="diff", cursor_type="row")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.title = t("Customised views")
|
||||
table = self.query_one("#views", DataTable)
|
||||
table.add_columns(t("view"), "+/-/≠")
|
||||
for row in self.lst_finding:
|
||||
stats = row.get("diff_stats") or {}
|
||||
table.add_row(
|
||||
(row.get("key") or str(row["id"]))[:26],
|
||||
f"{stats.get('added', 0)}/{stats.get('removed', 0)}"
|
||||
f"/{stats.get('changed', 0)}",
|
||||
key=str(row["id"]),
|
||||
)
|
||||
diff = self.query_one("#diff", DataTable)
|
||||
diff.add_columns(t("module (file)"), " ", t("database"))
|
||||
if self.lst_finding:
|
||||
self._show(0)
|
||||
|
||||
def _show(self, index):
|
||||
row = self.lst_finding[index]
|
||||
stats = row.get("diff_stats") or {}
|
||||
self.query_one("#head", Static).update(
|
||||
f"{row.get('key') or row['id']} · "
|
||||
f"{row.get('arch_fs') or '—'}\n"
|
||||
f"+{stats.get('added', 0)} -{stats.get('removed', 0)} "
|
||||
f"≠{stats.get('changed', 0)} · "
|
||||
f"{', '.join(row.get('reason') or []) or '—'}"
|
||||
)
|
||||
diff = self.query_one("#diff", DataTable)
|
||||
diff.clear()
|
||||
for mark, left, right in side_by_side(
|
||||
row.get("arch_ref"), row.get("arch_db_text")
|
||||
):
|
||||
if self.only_diff and mark == " ":
|
||||
continue
|
||||
if self.ignore_indent:
|
||||
left = (left or "").strip()
|
||||
right = (right or "").strip()
|
||||
diff.add_row(left or "", mark, right or "")
|
||||
|
||||
def on_data_table_row_highlighted(self, event):
|
||||
if event.data_table.id == "views" and self.lst_finding:
|
||||
self._show(event.cursor_row)
|
||||
|
||||
def _refresh(self):
|
||||
table = self.query_one("#views", DataTable)
|
||||
if self.lst_finding:
|
||||
self._show(table.cursor_row)
|
||||
|
||||
def action_only_diff(self):
|
||||
self.only_diff = not self.only_diff
|
||||
self._refresh()
|
||||
|
||||
def action_ignore_indent(self):
|
||||
self.ignore_indent = not self.ignore_indent
|
||||
self._refresh()
|
||||
|
||||
def action_copy(self):
|
||||
table = self.query_one("#views", DataTable)
|
||||
if not self.lst_finding:
|
||||
return
|
||||
row = self.lst_finding[table.cursor_row]
|
||||
text = "\n".join(
|
||||
f"{mark} {left or ''} | {right or ''}"
|
||||
for mark, left, right in diff_rows(row)
|
||||
)
|
||||
# Tronqué en gardant la FIN : c'est là que se trouve ce qu'on
|
||||
# vient d'ajouter, donc ce qu'on cherche le plus souvent.
|
||||
self.copy_to_clipboard(text[-100_000:])
|
||||
self.notify(t("Difference copied."))
|
||||
|
||||
def action_command(self):
|
||||
"""Rendre l'intention à l'appelant : l'écran n'écrit jamais."""
|
||||
table = self.query_one("#views", DataTable)
|
||||
if not self.lst_finding:
|
||||
return
|
||||
self.intent = ("command", self.lst_finding[table.cursor_row])
|
||||
self.exit()
|
||||
|
||||
return DiffApp(data)
|
||||
|
||||
|
||||
def run_diff_tui(data, run_app=True):
|
||||
"""Ouvrir l'écran. Renvoie l'intention retenue, ou None.
|
||||
|
||||
``run_app=False`` construit l'application sans la lancer : c'est ce qui
|
||||
permet de la tester sans terminal.
|
||||
"""
|
||||
app = build_app(data)
|
||||
if not run_app:
|
||||
return app
|
||||
app.run()
|
||||
return app.intent
|
||||
562
script/analyse/analyse_schema_size.py
Normal file
562
script/analyse/analyse_schema_size.py
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Poids d'une base Odoo, et tables qui ne correspondent à plus rien.
|
||||
|
||||
Ce que l'outil répond : combien pèse cette base, où est le gras, et quelles
|
||||
tables ne sont réclamées par aucun modèle installé. Ces dernières sont les
|
||||
reliquats de modules désinstallés sans ``DROP TABLE``, que chaque montée de
|
||||
version recopie et redéploie sans que personne ne les regarde.
|
||||
|
||||
Trois pièges évités, chacun pour une bonne raison
|
||||
-------------------------------------------------
|
||||
**Une table m2m n'a aucune ligne dans ``ir_model``.** Les recenser comme
|
||||
orphelines ferait crier au loup sur ~200 tables d'une base ordinaire. Odoo
|
||||
tient leur liste dans ``ir_model_relation``, on la lui demande.
|
||||
|
||||
**Une table introuvable n'est pas une anomalie, c'est une inconnue.** Un
|
||||
modèle dont le ``_table`` est surchargé — ``ir.actions.act_window`` vit dans
|
||||
``ir_act_window`` — serait classé « sans table » par un
|
||||
``replace('.', '_')`` naïf. ``lib_analyse.model_table()`` connaît les
|
||||
surcharges, et ce qu'il ne résout pas est marqué inconnu, jamais orphelin.
|
||||
|
||||
**Les modèles abstraits SONT dans ``ir_model``.** ``registry.py`` appelle
|
||||
``_reflect_models()`` sur tous les modèles chargés, sans filtre sur
|
||||
``_abstract`` ; il n'existe d'ailleurs aucune colonne ``abstract``. Des
|
||||
centaines de modèles sans table sont donc parfaitement normaux : c'est un
|
||||
fait rapporté, pas un constat.
|
||||
|
||||
Le comptage de lignes est une estimation, et le dit
|
||||
---------------------------------------------------
|
||||
``reltuples`` vient du dernier ``ANALYZE``. PostgreSQL 14 et suivants y
|
||||
mettent ``-1`` quand la table n'a jamais été analysée — afficher ``0`` ferait
|
||||
passer une table pleine pour une table vide. On affiche ``?``. Le compte exact
|
||||
est derrière ``--exact`` parce qu'il coûte un balayage complet par table.
|
||||
"""
|
||||
|
||||
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
|
||||
AnalyseError,
|
||||
backup_version,
|
||||
column_types,
|
||||
json_query,
|
||||
model_table,
|
||||
normalise_arch,
|
||||
quote_literal,
|
||||
read_backup,
|
||||
require_odoo_database,
|
||||
scalar_query,
|
||||
t,
|
||||
tr_col,
|
||||
)
|
||||
|
||||
# Tables réelles qui n'appartiennent pas à Odoo : une extension PostgreSQL les
|
||||
# pose dans le schéma public. Les compter comme orphelines enverrait
|
||||
# l'utilisateur supprimer une table dont dépend PostGIS.
|
||||
SYSTEM_TABLES = {
|
||||
"spatial_ref_sys", # PostGIS
|
||||
}
|
||||
|
||||
TOP_DEFAULT = 20
|
||||
|
||||
|
||||
def wrap_note(prefix, text, width=79):
|
||||
"""Replier une phrase à la largeur du terminal, sans la découper en clés.
|
||||
|
||||
Une phrase coupée en trois clés de traduction se replie correctement dans
|
||||
la langue où elle a été écrite, et n'importe comment dans l'autre : l'ordre
|
||||
des mots et la longueur diffèrent. La phrase reste donc entière côté
|
||||
traduction, et c'est l'affichage qui la replie.
|
||||
"""
|
||||
lines = textwrap.wrap(text, width=width - len(prefix)) or [""]
|
||||
pad = " " * len(prefix)
|
||||
return [prefix + lines[0]] + [pad + line for line in lines[1:]]
|
||||
|
||||
|
||||
def fmt_bytes(value):
|
||||
"""Taille lisible, en unités binaires — mêmes symboles en fr et en en."""
|
||||
if value is None:
|
||||
return "?"
|
||||
size = float(value)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if size < 1024 or unit == "TiB":
|
||||
return (
|
||||
f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||||
)
|
||||
size /= 1024
|
||||
return f"{size:.1f} TiB"
|
||||
|
||||
|
||||
def fmt_rows(value):
|
||||
"""Nombre de lignes, ou « ? » si la table n'a jamais été analysée.
|
||||
|
||||
reltuples vaut -1 depuis PostgreSQL 14 quand aucun ANALYZE n'a tourné.
|
||||
Avant, il valait 0 — indistinguable d'une table vide. On rend « ? » dans
|
||||
les deux cas plutôt qu'un chiffre auquel personne ne devrait se fier.
|
||||
"""
|
||||
if value is None or value < 0:
|
||||
return "?"
|
||||
return f"{value:,}".replace(",", " ")
|
||||
|
||||
|
||||
def _table_rows(database, **kwargs):
|
||||
"""Une ligne par table réelle du schéma public, avec son poids.
|
||||
|
||||
relkind 'r' pour une table ordinaire, 'p' pour une partitionnée. Odoo n'en
|
||||
partitionne pas, mais le compte ne doit pas devenir faux en silence le jour
|
||||
où cela changera.
|
||||
"""
|
||||
return json_query(
|
||||
database,
|
||||
"""
|
||||
SELECT c.relname AS table_name,
|
||||
pg_total_relation_size(c.oid) AS total_bytes,
|
||||
pg_table_size(c.oid) AS table_bytes,
|
||||
pg_indexes_size(c.oid) AS index_bytes,
|
||||
c.reltuples::bigint AS est_rows
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC
|
||||
""",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _model_rows(database, **kwargs):
|
||||
"""Modèles déclarés, avec leur description traduite.
|
||||
|
||||
ir_model.name est un champ traduit : jsonb à partir de 16.0, texte avant.
|
||||
tr_col décide sur le type réel de la colonne.
|
||||
"""
|
||||
dct_type = column_types(database, "ir_model", **kwargs)
|
||||
label = tr_col("ir_model", "name", dct_type)
|
||||
return json_query(
|
||||
database,
|
||||
f"""
|
||||
SELECT model AS model,
|
||||
{label} AS description,
|
||||
state AS state,
|
||||
transient AS transient
|
||||
FROM ir_model
|
||||
ORDER BY model
|
||||
""",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _relation_tables(database, **kwargs):
|
||||
"""Tables m2m qu'Odoo revendique, via ir_model_relation.
|
||||
|
||||
Sans elles, toute table de relation passerait pour orpheline. La table peut
|
||||
manquer sur une base très ancienne : on sonde plutôt que de supposer.
|
||||
"""
|
||||
if not scalar_query(
|
||||
database, "SELECT to_regclass('public.ir_model_relation');", **kwargs
|
||||
):
|
||||
return set(), False
|
||||
rows = json_query(
|
||||
database, "SELECT name AS name FROM ir_model_relation", **kwargs
|
||||
)
|
||||
return {r["name"] for r in rows if r.get("name")}, True
|
||||
|
||||
|
||||
def _exact_counts(database, lst_table, **kwargs):
|
||||
"""count(*) réel par table — un balayage complet chacune.
|
||||
|
||||
format('%I') met les identifiants entre guillemets côté PostgreSQL : aucun
|
||||
nom de table venu du catalogue ne peut casser la requête ni en détourner
|
||||
le sens.
|
||||
"""
|
||||
if not lst_table:
|
||||
return {}
|
||||
values = ", ".join(f"({quote_literal(name)})" for name in lst_table)
|
||||
rows = json_query(
|
||||
database,
|
||||
f"""
|
||||
SELECT v.table_name AS table_name,
|
||||
(xpath('/row/c/text()',
|
||||
query_to_xml(
|
||||
format('SELECT count(*) AS c FROM public.%I',
|
||||
v.table_name),
|
||||
false, true, '')))[1]::text::bigint AS exact_rows
|
||||
FROM (VALUES {values}) AS v(table_name)
|
||||
""",
|
||||
**kwargs,
|
||||
)
|
||||
return {r["table_name"]: r["exact_rows"] for r in rows}
|
||||
|
||||
|
||||
def collect(database, exact=False, config_path=None, timeout=120):
|
||||
"""Tout le travail. Donnée pure, sérialisable, aucun affichage."""
|
||||
kwargs = {"config_path": config_path, "timeout": timeout}
|
||||
require_odoo_database(database, **kwargs)
|
||||
|
||||
db_bytes = scalar_query(
|
||||
database,
|
||||
"SELECT pg_database_size(current_database());",
|
||||
**kwargs,
|
||||
)
|
||||
odoo_version = scalar_query(
|
||||
database,
|
||||
"SELECT latest_version FROM ir_module_module WHERE name = 'base';",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
lst_table = _table_rows(database, **kwargs)
|
||||
lst_model = _model_rows(database, **kwargs)
|
||||
set_relation, has_relation_table = _relation_tables(database, **kwargs)
|
||||
set_table = {row["table_name"] for row in lst_table}
|
||||
|
||||
# Table -> modèle. Une table peut porter plusieurs modèles : les huit
|
||||
# ir.actions.* partagent ir_actions. On garde le premier par ordre
|
||||
# alphabétique, pour que deux exécutions disent la même chose.
|
||||
dct_table_model = {}
|
||||
lst_without_table = []
|
||||
for row in lst_model:
|
||||
table = model_table(row["model"], known_tables=set_table)
|
||||
if table is None:
|
||||
lst_without_table.append(
|
||||
{
|
||||
"model": row["model"],
|
||||
"description": row.get("description"),
|
||||
"state": row.get("state"),
|
||||
"transient": row.get("transient"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
dct_table_model.setdefault(table, row["model"])
|
||||
|
||||
lst_orphan = []
|
||||
for row in lst_table:
|
||||
name = row["table_name"]
|
||||
if name in dct_table_model:
|
||||
row["model"] = dct_table_model[name]
|
||||
row["origin"] = "model"
|
||||
elif name in set_relation:
|
||||
row["model"] = None
|
||||
row["origin"] = "m2m"
|
||||
elif name in SYSTEM_TABLES:
|
||||
row["model"] = None
|
||||
row["origin"] = "system"
|
||||
else:
|
||||
row["model"] = None
|
||||
row["origin"] = "orphan"
|
||||
lst_orphan.append(row)
|
||||
row["exact_rows"] = None
|
||||
|
||||
if exact:
|
||||
dct_exact = _exact_counts(
|
||||
database, [r["table_name"] for r in lst_table], **kwargs
|
||||
)
|
||||
for row in lst_table:
|
||||
row["exact_rows"] = dct_exact.get(row["table_name"])
|
||||
|
||||
return {
|
||||
"tool": "analyse_schema_size",
|
||||
"version": 1,
|
||||
"database": database,
|
||||
"odoo_version": odoo_version,
|
||||
"db_bytes": int(db_bytes) if db_bytes else None,
|
||||
"exact": exact,
|
||||
"has_relation_table": has_relation_table,
|
||||
"n_tables": len(lst_table),
|
||||
"n_models": len(lst_model),
|
||||
"tables": lst_table,
|
||||
"orphan_tables": lst_orphan,
|
||||
"models_without_table": lst_without_table,
|
||||
"counts": {
|
||||
"orphan_tables": len(lst_orphan),
|
||||
"models_without_table": len(lst_without_table),
|
||||
"m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def collect_from_backup(zip_path):
|
||||
"""Même analyse, depuis une sauvegarde .zip, sans rien restaurer.
|
||||
|
||||
Ce qu'une sauvegarde donne EN MIEUX : le nombre de lignes est exact,
|
||||
compté dans le dump, là où une base rend l'estimation du dernier ANALYZE.
|
||||
|
||||
Ce qu'elle ne peut pas donner : le poids sur le disque. Un dump ignore les
|
||||
index et le ballonnement, et présenter le poids de ses données comme une
|
||||
taille de table tromperait sur ce qui fait grossir une base. La colonne
|
||||
affichée est donc « poids dans le dump », et elle est nommée ainsi.
|
||||
"""
|
||||
manifest, dct_rows, _, dct_census = read_backup(
|
||||
zip_path,
|
||||
tables=("ir_model", "ir_model_relation", "ir_module_module"),
|
||||
census=True,
|
||||
)
|
||||
set_table = set(dct_census)
|
||||
set_relation = {
|
||||
row.get("name") for row in dct_rows.get("ir_model_relation") or []
|
||||
}
|
||||
has_relation_table = "ir_model_relation" in dct_census
|
||||
|
||||
dct_table_model = {}
|
||||
lst_without_table = []
|
||||
for row in dct_rows["ir_model"]:
|
||||
table = model_table(row.get("model") or "", known_tables=set_table)
|
||||
if table is None:
|
||||
lst_without_table.append(
|
||||
{
|
||||
"model": row.get("model"),
|
||||
"description": normalise_arch(row.get("name")),
|
||||
"state": row.get("state"),
|
||||
"transient": row.get("transient") == "t",
|
||||
}
|
||||
)
|
||||
continue
|
||||
dct_table_model.setdefault(table, row.get("model"))
|
||||
|
||||
lst_table, lst_orphan = [], []
|
||||
for name, census in sorted(
|
||||
dct_census.items(), key=lambda kv: -kv[1]["dump_bytes"]
|
||||
):
|
||||
row = {
|
||||
"table_name": name,
|
||||
# Aucune de ces trois-là ne se lit dans un dump : les laisser à
|
||||
# None fait afficher « ? », ce qui est la vérité, plutôt qu'un
|
||||
# zéro qui se lirait comme « cette table est vide ».
|
||||
"total_bytes": None,
|
||||
"table_bytes": None,
|
||||
"index_bytes": None,
|
||||
"dump_bytes": census["dump_bytes"],
|
||||
"est_rows": census["rows"],
|
||||
"exact_rows": census["rows"],
|
||||
"model": dct_table_model.get(name),
|
||||
"origin": "model",
|
||||
}
|
||||
if name in dct_table_model:
|
||||
pass
|
||||
elif name in set_relation:
|
||||
row["origin"] = "m2m"
|
||||
elif name in SYSTEM_TABLES:
|
||||
row["origin"] = "system"
|
||||
else:
|
||||
row["origin"] = "orphan"
|
||||
lst_orphan.append(row)
|
||||
lst_table.append(row)
|
||||
|
||||
return {
|
||||
"tool": "analyse_schema_size",
|
||||
"version": 1,
|
||||
"database": os.path.basename(zip_path),
|
||||
"source": "backup",
|
||||
"backup_path": zip_path,
|
||||
"odoo_version": backup_version(dct_rows, manifest),
|
||||
"db_bytes": None,
|
||||
"dump_bytes": sum(c["dump_bytes"] for c in dct_census.values()),
|
||||
"exact": True,
|
||||
"has_relation_table": has_relation_table,
|
||||
"n_tables": len(lst_table),
|
||||
"n_models": len(dct_rows["ir_model"]),
|
||||
"tables": lst_table,
|
||||
"orphan_tables": lst_orphan,
|
||||
"models_without_table": lst_without_table,
|
||||
"counts": {
|
||||
"orphan_tables": len(lst_orphan),
|
||||
"models_without_table": len(lst_without_table),
|
||||
"m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _table_block(lst_row, exact, source="database"):
|
||||
"""Tableau aligné : une ligne par table, colonnes de largeur fixe.
|
||||
|
||||
« heap » plutôt que « table » pour pg_table_size : la colonne « table »
|
||||
porte déjà le nom, et le même mot pour deux choses dans le même tableau se
|
||||
lit mal. Les quatre en-têtes techniques ne passent pas par t() — ils
|
||||
s'écrivent pareil en français et en anglais, contrairement à « rows ».
|
||||
"""
|
||||
if source == "backup":
|
||||
# Un dump n'a ni index ni ballonnement : afficher trois colonnes vides
|
||||
# ferait croire à une mesure manquante plutôt qu'à une mesure qui
|
||||
# n'existe pas.
|
||||
lines = [f" {'table':<44}{t('in the dump'):>14}{t('rows'):>14}"]
|
||||
for row in lst_row:
|
||||
lines.append(
|
||||
f" {row['table_name']:<44}"
|
||||
f"{fmt_bytes(row.get('dump_bytes')):>14}"
|
||||
f"{fmt_rows(row['exact_rows']):>14}"
|
||||
)
|
||||
return lines
|
||||
lines = [
|
||||
f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}"
|
||||
f"{t('rows'):>14}"
|
||||
]
|
||||
for row in lst_row:
|
||||
count = row["exact_rows"] if exact else row["est_rows"]
|
||||
lines.append(
|
||||
f" {row['table_name']:<34}"
|
||||
f"{fmt_bytes(row['total_bytes']):>10}"
|
||||
f"{fmt_bytes(row['table_bytes']):>10}"
|
||||
f"{fmt_bytes(row['index_bytes']):>10}"
|
||||
f"{fmt_rows(count):>14}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def render(data, verbose=False, top=TOP_DEFAULT, hints=True):
|
||||
"""Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.
|
||||
|
||||
``hints`` gouverne les conseils en ligne de commande (« utilisez -v »,
|
||||
« --exact »). Ils aident qui a tapé la commande ; ils insultent qui est
|
||||
dans un menu, à qui l'on demande de sortir et de retaper autre chose.
|
||||
L'appel depuis le menu les coupe et offre les mêmes actions comme choix.
|
||||
"""
|
||||
version = data.get("odoo_version") or "?"
|
||||
lines = [
|
||||
"",
|
||||
f"🔬 {t('Schema analysis')} — {data['database']} (Odoo {version}"
|
||||
f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})",
|
||||
"",
|
||||
(
|
||||
f" {t('Weight in the dump'):<22}: "
|
||||
f"{fmt_bytes(data.get('dump_bytes'))}"
|
||||
if data.get("source") == "backup"
|
||||
else f" {t('Database size'):<22}: {fmt_bytes(data['db_bytes'])}"
|
||||
),
|
||||
f" {t('Tables'):<22}: {data['n_tables']}",
|
||||
f" {t('Models'):<22}: {data['n_models']}",
|
||||
]
|
||||
n_without = data["counts"]["models_without_table"]
|
||||
if n_without:
|
||||
lines.append(
|
||||
f" {t('Models without table'):<22}: {n_without}"
|
||||
f" ({t('abstract models have none, by design')})"
|
||||
)
|
||||
if not data["has_relation_table"]:
|
||||
lines += wrap_note(
|
||||
"⚠️ ",
|
||||
t(
|
||||
"ir_model_relation is absent, so m2m tables cannot be told"
|
||||
" apart from orphans: the list below is unreliable."
|
||||
),
|
||||
)
|
||||
|
||||
lst_table = data["tables"]
|
||||
shown = lst_table if verbose else lst_table[:top]
|
||||
if shown:
|
||||
label = (
|
||||
t("All tables, heaviest first")
|
||||
if verbose
|
||||
else f"{t('Heaviest tables')} ({len(shown)}/{len(lst_table)})"
|
||||
)
|
||||
lines += ["", f"── {label} ──"]
|
||||
lines += _table_block(
|
||||
shown, data["exact"], data.get("source", "database")
|
||||
)
|
||||
if hints and not verbose and len(lst_table) > len(shown):
|
||||
lines.append(f" … {t('use -v to list them all')}")
|
||||
elif not verbose and len(lst_table) > len(shown):
|
||||
lines.append(f" … {len(lst_table) - len(shown)} {t('more')}")
|
||||
|
||||
lst_orphan = data["orphan_tables"]
|
||||
if not lst_orphan:
|
||||
lines += ["", f"✅ {t('Every table belongs to an installed model.')}"]
|
||||
else:
|
||||
lines += [
|
||||
"",
|
||||
f"── ⚠️ {t('Orphan tables')} ({len(lst_orphan)}) ──",
|
||||
]
|
||||
lines += _table_block(
|
||||
lst_orphan, data["exact"], data.get("source", "database")
|
||||
)
|
||||
lines.append("")
|
||||
lines += wrap_note(
|
||||
" ",
|
||||
t(
|
||||
"No installed model claims these tables. They are usually left"
|
||||
" over from modules uninstalled without DROP TABLE, and every"
|
||||
" version upgrade carries them along."
|
||||
),
|
||||
)
|
||||
lines += wrap_note(
|
||||
" 💡 ", t("Check what they hold before dropping anything.")
|
||||
)
|
||||
|
||||
if not data["exact"] and hints:
|
||||
lines.append("")
|
||||
lines += wrap_note(
|
||||
" ℹ️ ",
|
||||
t(
|
||||
"Row counts are estimates from the last ANALYZE. Use --exact"
|
||||
" for real counts, at the cost of one full scan per table."
|
||||
),
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=t(
|
||||
"Report the size of an Odoo database and the tables no installed"
|
||||
" model claims (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(
|
||||
"--exact",
|
||||
action="store_true",
|
||||
help=t("count rows exactly: one full scan per table"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
default=TOP_DEFAULT,
|
||||
help=t("how many tables to show (default: 20)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help=t("list every table")
|
||||
)
|
||||
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, exact=config.exact, config_path=config.config
|
||||
)
|
||||
except AnalyseError as exc:
|
||||
print(f"❌ {exc}")
|
||||
return 2
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{t('Cancelled.')}")
|
||||
return 2
|
||||
|
||||
if config.json:
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(render(data, verbose=config.verbose, top=config.top))
|
||||
return 1 if data["orphan_tables"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
858
script/analyse/analyse_view_custom.py
Normal file
858
script/analyse/analyse_view_custom.py
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
#!/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())
|
||||
872
script/analyse/lib_analyse.py
Normal file
872
script/analyse/lib_analyse.py
Normal file
|
|
@ -0,0 +1,872 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Socle commun des outils d'analyse d'une base Odoo, en lecture seule.
|
||||
|
||||
Pourquoi psql en sous-processus plutôt que psycopg2
|
||||
---------------------------------------------------
|
||||
La raison est déjà écrite dans le dépôt, dans ``reset_stale_cow_views.py`` :
|
||||
« Plain psql on purpose: this runs on databases whose Odoo registry does not
|
||||
load, which is precisely when it is needed ». Une base 12.0 sur un checkout
|
||||
18.0 ne charge pas son registre, et c'est exactement le moment où on veut
|
||||
l'analyser. Accessoirement, psycopg2 n'est pas dans ``.venv.erplibre``, qui est
|
||||
l'interpréteur de ces outils.
|
||||
|
||||
La lecture seule est une garantie, pas une promesse
|
||||
--------------------------------------------------
|
||||
``PGOPTIONS`` porte ``default_transaction_read_only=on`` : c'est **le serveur**
|
||||
qui refuse toute écriture, pour toutes les transactions de la connexion. Un
|
||||
``SET`` glissé dans le même ``-c`` ne suffirait pas — ``psql -c`` ouvre une
|
||||
transaction implicite unique, et ``default_transaction_read_only`` ne vaut que
|
||||
pour les transactions *suivantes*.
|
||||
|
||||
Ne jamais deviner la forme du schéma
|
||||
------------------------------------
|
||||
Douze versions d'Odoo se partagent ces tables. Les colonnes apparaissent,
|
||||
changent de type (``text`` puis ``jsonb`` à partir de 16.0), ou n'existent que
|
||||
si un module est installé. D'où ``existing_columns()`` et ``tr_col()`` : on
|
||||
sonde avant d'écrire une requête, on ne date pas les colonnes de mémoire.
|
||||
|
||||
Les sondes lisent ``pg_attribute``, pas ``information_schema`` :
|
||||
``information_schema`` est filtré par les droits. Avec un rôle non
|
||||
propriétaire, elle renverrait un ensemble vide, et l'analyse concluerait
|
||||
« aucune colonne website, donc aucune vue COW » sans le moindre avertissement.
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
new_path = os.path.normpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
sys.path.append(new_path)
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
REPO_ROOT = new_path
|
||||
|
||||
# Une valeur littérale « False » dans config.conf veut dire « non défini » :
|
||||
# c'est ainsi qu'Odoo écrit l'absence de valeur dans son fichier de config.
|
||||
CONFIG_UNSET = ("false", "none", "")
|
||||
|
||||
# Un nom de base voyage jusqu'à une commande shell (`odoo_bin.sh shell -d …`,
|
||||
# lancée avec shell=True par execute.py). On le valide au lieu de compter sur
|
||||
# l'échappement : la liste de caractères qu'une base Odoo utilise réellement
|
||||
# est courte, et tout le reste est soit une erreur de frappe, soit une
|
||||
# injection.
|
||||
RE_DATABASE_NAME = re.compile(r"[A-Za-z0-9_.-]+")
|
||||
|
||||
# Modèles dont la table N'EST PAS `_name.replace('.', '_')`.
|
||||
#
|
||||
# Dérivé des sources, pas écrit de mémoire : parcours AST de tous les `.py` de
|
||||
# `odoo18.0/` et `addons/` (27 843 fichiers), en gardant les classes dont le
|
||||
# `_table` diffère du défaut. Beaucoup de modules déclarent un `_table` égal au
|
||||
# défaut — ce sont des déclarations sans effet, à ne pas confondre avec une
|
||||
# surcharge.
|
||||
#
|
||||
# Sans cette table, `replace('.', '_')` échoue précisément sur les modèles les
|
||||
# plus fréquents dans `ir_model_data` : `ir.actions.act_window` chercherait
|
||||
# `ir_actions_act_window`, qui n'existe pas.
|
||||
#
|
||||
# La liste n'est pas la vérité, seulement ce qu'on sait : un modèle absent
|
||||
# d'ici et dont la table est introuvable est classé « table inconnue » (un
|
||||
# fait), jamais « table orpheline » (une anomalie). Pour la régénérer, refaire
|
||||
# le parcours AST sur l'arbre courant.
|
||||
MODEL_TABLE_OVERRIDE = {
|
||||
"ir.actions.act_multi": "ir_actions",
|
||||
"ir.actions.act_url": "ir_act_url",
|
||||
"ir.actions.act_window": "ir_act_window",
|
||||
"ir.actions.act_window.message": "ir_actions",
|
||||
"ir.actions.act_window.view": "ir_act_window_view",
|
||||
"ir.actions.act_window_close": "ir_actions",
|
||||
"ir.actions.actions": "ir_actions",
|
||||
"ir.actions.client": "ir_act_client",
|
||||
"ir.actions.report": "ir_act_report_xml",
|
||||
"ir.actions.server": "ir_act_server",
|
||||
"project.task.stage.personal": "project_task_user_rel",
|
||||
# `ir.actions.report.xml` est le nom d'avant 11.0 : les lignes
|
||||
# `ir_model_data` d'une base ancienne le portent encore.
|
||||
"ir.actions.report.xml": "ir_act_report_xml",
|
||||
}
|
||||
|
||||
|
||||
class AnalyseError(Exception):
|
||||
"""Échec de l'outil, pas un constat d'analyse.
|
||||
|
||||
Distinction qui porte le code de retour : 2 pour « je n'ai pas pu
|
||||
analyser », réservé à cette exception ; 1 pour « j'ai analysé et j'ai
|
||||
trouvé des constats ». Les confondre rendrait une analyse en échec
|
||||
indistinguable d'une base à problèmes.
|
||||
"""
|
||||
|
||||
|
||||
def valid_database_name(name):
|
||||
"""Le nom est-il un nom de base plausible, sûr à mettre dans une commande ?"""
|
||||
return bool(name) and RE_DATABASE_NAME.fullmatch(name) is not None
|
||||
|
||||
|
||||
def read_config(config_path=None):
|
||||
"""Lire config.conf, en repliant sur /etc/odoo/odoo.conf comme run.sh.
|
||||
|
||||
Renvoie un dict des options, vide si aucun fichier n'est trouvé — l'absence
|
||||
de config n'est pas une erreur : sur une installation native, psql se
|
||||
connecte très bien par le socket unix sans aucun paramètre.
|
||||
"""
|
||||
lst_candidate = (
|
||||
[config_path]
|
||||
if config_path
|
||||
else [
|
||||
os.path.join(REPO_ROOT, "config.conf"),
|
||||
"/etc/odoo/odoo.conf",
|
||||
]
|
||||
)
|
||||
for path in lst_candidate:
|
||||
if path and os.path.isfile(path):
|
||||
parser = configparser.RawConfigParser()
|
||||
try:
|
||||
parser.read(path)
|
||||
except configparser.Error:
|
||||
continue
|
||||
if parser.has_section("options"):
|
||||
return dict(parser.items("options"))
|
||||
return {}
|
||||
|
||||
|
||||
def pg_env(config_path=None, timeout=120, overrides=None):
|
||||
"""Variables d'environnement pour psql : connexion + lecture seule.
|
||||
|
||||
Les paramètres viennent de config.conf, pas d'une hypothèse « socket unix
|
||||
et rôle = utilisateur système » : le dépôt lui-même livre
|
||||
``db_user = erplibre`` et un docker-compose.yml avec un mot de passe.
|
||||
|
||||
``PGOPTIONS`` est ce qui rend l'analyse incapable d'écrire, et borne la
|
||||
durée d'une requête — un scan qui part en vrille ne bloque pas un menu.
|
||||
"""
|
||||
config = read_config(config_path)
|
||||
my_env = os.environ.copy()
|
||||
|
||||
dct_map = {
|
||||
"db_host": "PGHOST",
|
||||
"db_port": "PGPORT",
|
||||
"db_user": "PGUSER",
|
||||
"db_password": "PGPASSWORD",
|
||||
"db_sslmode": "PGSSLMODE",
|
||||
}
|
||||
for key, var in dct_map.items():
|
||||
value = str(config.get(key, "")).strip()
|
||||
if value.lower() not in CONFIG_UNSET:
|
||||
my_env[var] = value
|
||||
for var, value in (overrides or {}).items():
|
||||
if value:
|
||||
my_env[var] = str(value)
|
||||
|
||||
my_env["PGOPTIONS"] = (
|
||||
f"-c default_transaction_read_only=on -c statement_timeout={timeout}s"
|
||||
)
|
||||
# Un ~/.psqlrc avec \timing ou \pset ajoute des lignes à la sortie et casse
|
||||
# le parsing. -X l'ignore, mais PSQLRC vide protège aussi les appels qui
|
||||
# oublieraient -X.
|
||||
my_env["PSQLRC"] = ""
|
||||
return my_env
|
||||
|
||||
|
||||
def run_psql(database, sql, timeout=120, config_path=None, overrides=None):
|
||||
"""Exécuter du SQL et rendre la sortie brute, une ligne par enregistrement.
|
||||
|
||||
``-X`` ignore ~/.psqlrc, ``-w`` interdit l'invite de mot de passe (sans
|
||||
lui, un mot de passe manquant bloque le menu TODO sans rien afficher),
|
||||
``ON_ERROR_STOP=1`` fait échouer au premier problème plutôt que de rendre
|
||||
une sortie partielle qu'on prendrait pour un résultat.
|
||||
"""
|
||||
if not valid_database_name(database):
|
||||
raise AnalyseError(f"{t('Invalid database name: ')}{database!r}")
|
||||
cmd = [
|
||||
"psql",
|
||||
"-X",
|
||||
"-w",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-d",
|
||||
database,
|
||||
"-tAc",
|
||||
sql,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 30,
|
||||
cwd=REPO_ROOT,
|
||||
env=pg_env(config_path, timeout=timeout, overrides=overrides),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise AnalyseError(
|
||||
f"{t('psql is not installed or not in PATH.')}"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AnalyseError(
|
||||
f"{t('Query exceeded the timeout (s): ')}{timeout}"
|
||||
) from exc
|
||||
if result.returncode:
|
||||
raise AnalyseError(
|
||||
f"{t('Cannot read from the database: ')}"
|
||||
f"{result.stderr.strip() or result.returncode}"
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def json_query(database, sql, **kwargs):
|
||||
"""Rendre le résultat d'un SELECT comme une liste de dicts.
|
||||
|
||||
Le SQL est enveloppé côté PostgreSQL plutôt que découpé côté Python : une
|
||||
arch de vue contient des retours de ligne et des « | », donc tout
|
||||
séparateur maison finirait par couper au mauvais endroit. C'est le même
|
||||
choix que ``snapshot_cow_views.py``.
|
||||
|
||||
``sql`` est un SELECT SANS point-virgule final : il devient une
|
||||
sous-requête.
|
||||
"""
|
||||
inner = sql.strip().rstrip(";")
|
||||
wrapped = (
|
||||
"SELECT COALESCE(json_agg(row_to_json(t))::text, '[]')"
|
||||
f" FROM ({inner}) t;"
|
||||
)
|
||||
raw = run_psql(database, wrapped, **kwargs).strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise AnalyseError(f"{t('Unreadable JSON from psql: ')}{exc}") from exc
|
||||
|
||||
|
||||
def scalar_query(database, sql, **kwargs):
|
||||
"""Première valeur de la première ligne, ou None si aucune ligne."""
|
||||
raw = run_psql(database, sql, **kwargs).strip()
|
||||
if not raw:
|
||||
return None
|
||||
return raw.splitlines()[0].strip() or None
|
||||
|
||||
|
||||
def require_odoo_database(database, **kwargs):
|
||||
"""Refuser tout ce qui n'est pas une base Odoo, avant d'aller plus loin.
|
||||
|
||||
Sans ce contrôle, ``-d postgres`` ou une base vide remonte un
|
||||
« relation "ir_ui_view" does not exist » brut, qui ressemble à un bogue de
|
||||
l'outil alors que c'est une erreur de saisie.
|
||||
"""
|
||||
found = scalar_query(
|
||||
database, "SELECT to_regclass('public.ir_module_module');", **kwargs
|
||||
)
|
||||
if not found:
|
||||
raise AnalyseError(f"'{database}' {t('is not an Odoo database.')}")
|
||||
return True
|
||||
|
||||
|
||||
def database_version(database, **kwargs):
|
||||
"""Version Odoo de la BASE, qui n'est pas celle du checkout.
|
||||
|
||||
Comparer les deux est ce qui évite d'ouvrir un shell Odoo pour rien : sur
|
||||
une base 13.0 avec un checkout 18.0, le registre ne chargera pas, et mieux
|
||||
vaut le dire tout de suite qu'après trente secondes de chargement.
|
||||
"""
|
||||
return scalar_query(
|
||||
database,
|
||||
"SELECT latest_version FROM ir_module_module WHERE name = 'base';",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def existing_columns(database, table, **kwargs):
|
||||
"""Colonnes réellement présentes, via pg_attribute (pas information_schema).
|
||||
|
||||
Renvoie un ensemble vide si la table n'existe pas — les deux cas se
|
||||
distinguent avec ``to_regclass`` si l'appelant en a besoin.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT a.attname FROM pg_attribute a"
|
||||
" JOIN pg_class c ON c.oid = a.attrelid"
|
||||
" JOIN pg_namespace n ON n.oid = c.relnamespace"
|
||||
f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}"
|
||||
" AND a.attnum > 0 AND NOT a.attisdropped;"
|
||||
)
|
||||
return {
|
||||
line.strip()
|
||||
for line in run_psql(database, sql, **kwargs).splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
|
||||
|
||||
def column_types(database, table, **kwargs):
|
||||
"""{colonne: type PostgreSQL} — dit `jsonb` là où 15.0 disait `text`."""
|
||||
sql = (
|
||||
"SELECT a.attname, format_type(a.atttypid, a.atttypmod)"
|
||||
" FROM pg_attribute a"
|
||||
" JOIN pg_class c ON c.oid = a.attrelid"
|
||||
" JOIN pg_namespace n ON n.oid = c.relnamespace"
|
||||
f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}"
|
||||
" AND a.attnum > 0 AND NOT a.attisdropped;"
|
||||
)
|
||||
dct_type = {}
|
||||
for line in run_psql(database, sql, **kwargs).splitlines():
|
||||
if "|" in line:
|
||||
name, _, kind = line.partition("|")
|
||||
dct_type[name.strip()] = kind.strip()
|
||||
return dct_type
|
||||
|
||||
|
||||
def tr_col(table, column, dct_type, lang="en_US"):
|
||||
"""Fragment SQL lisant un champ traduit, quelle que soit la version.
|
||||
|
||||
À partir de 16.0 un champ traduit est un ``jsonb`` ``{"en_US": "…"}`` ;
|
||||
jusqu'à 15.0 c'est du texte. Un seul endroit décide, et il décide sur le
|
||||
type réel de la colonne — pas sur un numéro de version, qu'il faudrait
|
||||
connaître et qui mentirait sur une base à moitié migrée.
|
||||
|
||||
``dct_type`` vient de ``column_types()``. Une colonne inconnue rend NULL
|
||||
plutôt que du SQL invalide : l'appelant verra un champ vide, pas une
|
||||
requête qui explose.
|
||||
"""
|
||||
kind = (dct_type or {}).get(column)
|
||||
if kind is None:
|
||||
return "NULL::text"
|
||||
qualified = f'"{table}"."{column}"' if table else f'"{column}"'
|
||||
if kind == "jsonb":
|
||||
return f"{qualified}->>{quote_literal(lang)}"
|
||||
return f"{qualified}::text"
|
||||
|
||||
|
||||
def quote_literal(value):
|
||||
"""Littéral SQL sûr : les quotes simples sont doublées.
|
||||
|
||||
Nécessaire parce que ces requêtes sont assemblées en texte pour psql, sans
|
||||
paramètres liés. Les seules valeurs concernées ici sont des noms de tables
|
||||
et de colonnes venant du catalogue, mais un nom de table hérité peut
|
||||
parfaitement porter une apostrophe.
|
||||
"""
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def model_table(model, known_tables=None):
|
||||
"""Table d'un modèle, ou None si elle est introuvable.
|
||||
|
||||
None veut dire « je ne sais pas », jamais « il n'y en a pas » : c'est ce
|
||||
qui empêche de classer un modèle à `_table` surchargé comme une anomalie.
|
||||
``known_tables`` est l'ensemble des tables réelles, quand l'appelant l'a.
|
||||
"""
|
||||
table = MODEL_TABLE_OVERRIDE.get(model, model.replace(".", "_"))
|
||||
if known_tables is not None and table not in known_tables:
|
||||
return None
|
||||
return table
|
||||
|
||||
|
||||
def public_tables(database, **kwargs):
|
||||
"""Tables réelles du schéma public, vues par le catalogue.
|
||||
|
||||
``relkind IN ('r', 'p')`` : 'r' pour une table ordinaire, 'p' pour une
|
||||
table partitionnée. Odoo n'en partitionne pas, mais le jour où cela
|
||||
changera, le compte ne doit pas devenir faux en silence.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT c.relname FROM pg_class c"
|
||||
" JOIN pg_namespace n ON n.oid = c.relnamespace"
|
||||
" WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p');"
|
||||
)
|
||||
return {
|
||||
line.strip()
|
||||
for line in run_psql(database, sql, **kwargs).splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
|
||||
|
||||
JSON_BEGIN = "ANALYSE_JSON_BEGIN"
|
||||
JSON_END = "ANALYSE_JSON_END"
|
||||
|
||||
|
||||
def odoo_config_path(config_path=None):
|
||||
"""Chemin du fichier de configuration Odoo, comme run.sh le résout.
|
||||
|
||||
Sans ``-c``, ``odoo_bin.sh`` ne passe AUCUNE configuration : ``addons_path``
|
||||
retombe sur le défaut d'Odoo, et le shell ne voit alors aucun des dépôts de
|
||||
``addons/``. ``read_arch_from_file`` ne trouverait plus un seul fichier et
|
||||
rendrait une arch de référence vide pour toutes les vues — sans erreur.
|
||||
C'est la panne la plus coûteuse de cet outillage, parce qu'elle est
|
||||
silencieuse : le rapport dirait « aucun écart » sur une base pleine
|
||||
d'écarts.
|
||||
"""
|
||||
for path in (
|
||||
config_path,
|
||||
os.path.join(REPO_ROOT, "config.conf"),
|
||||
"/etc/odoo/odoo.conf",
|
||||
):
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
raise AnalyseError(t("No Odoo configuration file found."))
|
||||
|
||||
|
||||
def odoo_shell_json(
|
||||
database, script_path, env=None, timeout=600, config_path=None
|
||||
):
|
||||
"""Pousser un script dans « odoo-bin shell » et récupérer son JSON.
|
||||
|
||||
Pas de ``shell=True`` : la commande est une liste, le script arrive par
|
||||
l'entrée standard. Un nom de base n'a donc rien à échapper — il ne traverse
|
||||
aucun interpréteur de commandes.
|
||||
|
||||
Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne
|
||||
lit que ce qui est entre elles. Leur absence est une erreur franche, pas
|
||||
une liste vide qu'on prendrait pour « rien à signaler ».
|
||||
"""
|
||||
if not valid_database_name(database):
|
||||
raise AnalyseError(f"{t('Invalid database name: ')}{database!r}")
|
||||
with open(script_path, "r", encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
|
||||
my_env = os.environ.copy()
|
||||
my_env.update(env or {})
|
||||
cmd = [
|
||||
os.path.join(REPO_ROOT, "odoo_bin.sh"),
|
||||
"shell",
|
||||
"-c",
|
||||
odoo_config_path(config_path),
|
||||
"-d",
|
||||
database,
|
||||
"--no-http",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=REPO_ROOT,
|
||||
env=my_env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise AnalyseError(t("odoo_bin.sh not found.")) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AnalyseError(
|
||||
f"{t('The Odoo shell exceeded the timeout (s): ')}{timeout}"
|
||||
) from exc
|
||||
|
||||
output = result.stdout or ""
|
||||
if JSON_BEGIN not in output or JSON_END not in output:
|
||||
detail = (result.stderr or output).strip().splitlines()[-3:]
|
||||
raise AnalyseError(
|
||||
f"{t('The Odoo shell returned no result: ')}{' / '.join(detail)}"
|
||||
)
|
||||
chunk = output.split(JSON_BEGIN, 1)[1].split(JSON_END, 1)[0]
|
||||
try:
|
||||
return json.loads(chunk.strip())
|
||||
except ValueError as exc:
|
||||
raise AnalyseError(
|
||||
f"{t('Unreadable JSON from the Odoo shell: ')}{exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def normalise_arch(value):
|
||||
"""L'arch en chaîne, quel que soit le type de la colonne.
|
||||
|
||||
``arch_db`` est du texte jusqu'à 15.0 et du jsonb à partir de 16.0, avec
|
||||
une entrée par langue. Reprise de ``reset_stale_cow_views.normalise_arch``,
|
||||
à l'identique : deux implémentations de cette conversion finiraient par
|
||||
diverger sur un cas limite, et c'est exactement le genre d'écart qui
|
||||
ferait conclure « la vue a changé » sur une base qui n'a rien changé.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return "" if value is None else str(value)
|
||||
text = value.strip()
|
||||
if text.startswith("{") and '"' in text:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
return value
|
||||
if isinstance(data, dict) and data:
|
||||
for lang in ("en_US", *sorted(data)):
|
||||
if lang in data and isinstance(data[lang], str):
|
||||
return data[lang]
|
||||
return value
|
||||
|
||||
|
||||
# Attributs dont la valeur est un chemin ou une expression : l'espace y sépare
|
||||
# des jetons, il se normalise, mais il ne se supprime pas. « //div[1] /span »
|
||||
# et « //div[1]/span » ne désignent pas la même chose.
|
||||
SPACING_ATTRS = ("expr", "position", "groups", "t-call", "class")
|
||||
|
||||
|
||||
def canonical(arch):
|
||||
"""Forme canonique d'une arch, pour DÉCIDER s'il y a un écart.
|
||||
|
||||
Sert à répondre « est-ce différent », jamais à afficher : ce qu'un humain
|
||||
relit, c'est l'arch brute.
|
||||
|
||||
Pourquoi pas ``etree.canonicalize()`` en un appel, alors que lxml est là :
|
||||
son seul levier sur les espaces, ``strip_text``, s'applique à TOUS les
|
||||
nœuds texte, y compris le contenu d'un ``t-esc`` ou d'un CDATA — donc il
|
||||
masquerait des différences de contenu réelles. Et il n'offre aucun levier
|
||||
sur les espaces à l'intérieur d'une valeur d'attribut, alors que c'est
|
||||
précisément là que vit le bruit : un ``expr`` réindenté n'est pas une
|
||||
modification. D'où ce parcours, qui trie les attributs comme le ferait
|
||||
c14n, replie les espaces là où ils ne portent rien, et laisse le texte
|
||||
tranquille partout ailleurs.
|
||||
|
||||
Renvoie None si l'arch n'est pas du XML analysable — un écart ne se
|
||||
conclut pas sur une comparaison qui n'a pas eu lieu.
|
||||
"""
|
||||
from lxml import etree
|
||||
|
||||
text = normalise_arch(arch)
|
||||
if not text.strip():
|
||||
return None
|
||||
try:
|
||||
parser = etree.XMLParser(remove_blank_text=True, remove_comments=True)
|
||||
root = etree.fromstring(text.encode("utf-8"), parser=parser)
|
||||
except etree.XMLSyntaxError:
|
||||
return None
|
||||
|
||||
def render(node):
|
||||
lst_attr = []
|
||||
for key in sorted(node.attrib):
|
||||
value = node.attrib[key]
|
||||
if key in SPACING_ATTRS:
|
||||
value = " ".join(value.split())
|
||||
lst_attr.append(f"{key}={value!r}")
|
||||
head = node.tag + ("[" + ",".join(lst_attr) + "]" if lst_attr else "")
|
||||
# Le texte n'est replié que sur ses bords : l'indentation autour d'un
|
||||
# élément est de la mise en forme, l'espace À L'INTÉRIEUR d'un libellé
|
||||
# ou d'un t-esc est du contenu.
|
||||
own = (node.text or "").strip()
|
||||
parts = [head] + ([f"#{own}" for _ in (1,) if own])
|
||||
parts += [render(child) for child in node]
|
||||
tail = (node.tail or "").strip()
|
||||
if tail:
|
||||
parts.append(f"~{tail}")
|
||||
return "(" + " ".join(parts) + ")"
|
||||
|
||||
return render(root)
|
||||
|
||||
|
||||
def arch_differs(left, right):
|
||||
"""(différent ?, comparable ?) entre deux arch.
|
||||
|
||||
Deux réponses parce qu'il y a trois issues : identiques, différentes, et
|
||||
« je n'ai pas pu comparer ». Confondre la troisième avec la première
|
||||
ferait répondre « tout va bien » sur une vue au XML cassé, qui est
|
||||
justement celle qu'il faut regarder.
|
||||
"""
|
||||
canon_left = canonical(left)
|
||||
canon_right = canonical(right)
|
||||
if canon_left is None or canon_right is None:
|
||||
return None, False
|
||||
return canon_left != canon_right, True
|
||||
|
||||
|
||||
def side_by_side(left, right):
|
||||
"""[(marque, gauche, droite)] alignés, pour l'affichage côte à côte.
|
||||
|
||||
Marque : ' ' identiques, '≠' remplacées, '-' seulement à gauche, '+'
|
||||
seulement à droite. Consommé par le TUI ET par le rendu texte, pour que
|
||||
les deux racontent la même chose.
|
||||
"""
|
||||
import difflib
|
||||
|
||||
lst_left = normalise_arch(left).splitlines()
|
||||
lst_right = normalise_arch(right).splitlines()
|
||||
lst_row = []
|
||||
matcher = difflib.SequenceMatcher(None, lst_left, lst_right)
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
for offset in range(i2 - i1):
|
||||
lst_row.append(
|
||||
(" ", lst_left[i1 + offset], lst_right[j1 + offset])
|
||||
)
|
||||
elif tag == "replace":
|
||||
for offset in range(max(i2 - i1, j2 - j1)):
|
||||
lst_row.append(
|
||||
(
|
||||
"≠",
|
||||
lst_left[i1 + offset] if i1 + offset < i2 else None,
|
||||
lst_right[j1 + offset] if j1 + offset < j2 else None,
|
||||
)
|
||||
)
|
||||
elif tag == "delete":
|
||||
for offset in range(i1, i2):
|
||||
lst_row.append(("-", lst_left[offset], None))
|
||||
elif tag == "insert":
|
||||
for offset in range(j1, j2):
|
||||
lst_row.append(("+", None, lst_right[offset]))
|
||||
return lst_row
|
||||
|
||||
|
||||
def diff_stats(lst_row):
|
||||
"""{added, removed, changed} depuis les lignes de side_by_side()."""
|
||||
return {
|
||||
"added": sum(1 for mark, _, _ in lst_row if mark == "+"),
|
||||
"removed": sum(1 for mark, _, _ in lst_row if mark == "-"),
|
||||
"changed": sum(1 for mark, _, _ in lst_row if mark == "≠"),
|
||||
}
|
||||
|
||||
|
||||
# --- Lire une sauvegarde .zip sans restaurer quoi que ce soit ----------------
|
||||
#
|
||||
# Une sauvegarde Odoo est un zip contenant `manifest.json`, un `filestore/` et
|
||||
# un `dump.sql` — un pg_dump TEXTE, avec ses blocs `COPY … FROM stdin;` et ses
|
||||
# `CREATE TABLE`. Tout se lit donc sans PostgreSQL et sans Odoo.
|
||||
#
|
||||
# Ce que ça débloque : analyser la sauvegarde d'une instance Enterprise depuis
|
||||
# une installation Community. La restaurer y échoue — Odoo veut charger des
|
||||
# modules qu'on n'a pas — alors que les champs Studio, eux, ne sont que des
|
||||
# lignes de `ir_model_fields` qu'on peut lire telles quelles.
|
||||
#
|
||||
# La lecture est en FLOT, une seule passe : un dump de production pèse des
|
||||
# gigaoctets, et on n'en veut que trois tables.
|
||||
|
||||
# Échappements de pg_dump dans un bloc COPY. `\N` (NULL) est traité à part :
|
||||
# c'est une valeur, pas un caractère.
|
||||
COPY_ESCAPE = {
|
||||
"b": "\b",
|
||||
"f": "\f",
|
||||
"n": "\n",
|
||||
"r": "\r",
|
||||
"t": "\t",
|
||||
"v": "\v",
|
||||
"\\": "\\",
|
||||
}
|
||||
|
||||
# Lignes d'un CREATE TABLE qui ne déclarent pas une colonne.
|
||||
NOT_A_COLUMN = (
|
||||
"CONSTRAINT",
|
||||
"PRIMARY",
|
||||
"UNIQUE",
|
||||
"CHECK",
|
||||
"FOREIGN",
|
||||
"EXCLUDE",
|
||||
)
|
||||
|
||||
|
||||
def unescape_copy(value):
|
||||
r"""Une valeur d'un bloc COPY, dés-échappée. « \N » devient None."""
|
||||
if value == "\\N":
|
||||
return None
|
||||
if "\\" not in value:
|
||||
return value
|
||||
out, index = [], 0
|
||||
while index < len(value):
|
||||
char = value[index]
|
||||
if char == "\\" and index + 1 < len(value):
|
||||
out.append(COPY_ESCAPE.get(value[index + 1], value[index + 1]))
|
||||
index += 2
|
||||
else:
|
||||
out.append(char)
|
||||
index += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def find_member(archive, filename):
|
||||
"""Le membre nommé ``filename``, où qu'il soit dans l'archive.
|
||||
|
||||
Cherché à la racine d'abord, puis n'importe où : selon qui fabrique la
|
||||
sauvegarde, elle est mise à plat ou rangée sous un dossier.
|
||||
"""
|
||||
lst_name = archive.namelist()
|
||||
if filename in lst_name:
|
||||
return filename
|
||||
for name in lst_name:
|
||||
if name.rsplit("/", 1)[-1] == filename:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def backup_manifest(zip_path):
|
||||
"""Le manifest.json d'une sauvegarde, ou {} s'il n'y en a pas.
|
||||
|
||||
Son absence n'est PAS une erreur. Une sauvegarde odoo.sh n'en contient
|
||||
aucun — seulement ``dump.sql`` et ``filestore/`` — et refuser le fichier
|
||||
pour cela reviendrait à refuser d'analyser précisément les bases qu'on ne
|
||||
peut pas restaurer, ce qui est tout l'intérêt de lire un zip.
|
||||
|
||||
Le manifeste ne portait de toute façon qu'une commodité : la version. Elle
|
||||
se lit dans le dump lui-même, où elle est plus sûre — c'est la base qui
|
||||
parle, pas un fichier écrit à côté.
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
member = find_member(archive, "manifest.json")
|
||||
if member is None:
|
||||
return {}
|
||||
with archive.open(member) as handle:
|
||||
return json.load(handle)
|
||||
except ValueError:
|
||||
# Un manifeste illisible ne vaut pas mieux qu'un manifeste absent, et
|
||||
# ne doit pas empêcher de lire le dump qui est à côté.
|
||||
return {}
|
||||
except (OSError, zipfile.BadZipFile) as exc:
|
||||
raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc
|
||||
|
||||
|
||||
def backup_version(dct_rows, manifest=None):
|
||||
"""Version Odoo d'une sauvegarde, lue dans le dump avant le manifeste.
|
||||
|
||||
``ir_module_module.latest_version`` du module ``base`` est ce que la base
|
||||
dit d'elle-même ; le manifeste n'est qu'un repli.
|
||||
"""
|
||||
for row in dct_rows.get("ir_module_module") or []:
|
||||
if row.get("name") == "base" and row.get("latest_version"):
|
||||
return row["latest_version"]
|
||||
return (manifest or {}).get("version")
|
||||
|
||||
|
||||
def read_backup(zip_path, tables=(), with_columns=(), census=False):
|
||||
"""Lire un dump.sql en flot.
|
||||
|
||||
-> (manifest, {table: [lignes]}, {table: colonnes}, {table: recensement})
|
||||
|
||||
Une seule passe, quelle que soit la taille du dump : on ne garde en mémoire
|
||||
que les tables demandées.
|
||||
|
||||
``census`` compte les lignes et pèse chaque table SANS la garder. Le
|
||||
comptage est alors EXACT, là où une base rend une estimation d'après le
|
||||
dernier ANALYZE. Le poids est celui des données dans le dump, pas sur le
|
||||
disque : une sauvegarde ne sait rien des index ni du ballonnement, et
|
||||
présenter l'un pour l'autre tromperait sur ce qui fait grossir une base.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
manifest = backup_manifest(zip_path)
|
||||
set_want = set(tables)
|
||||
# True = toutes les tables. Le dump les déclare toutes de toute façon, et
|
||||
# on ne sait quelles tables comptent qu'APRÈS avoir lu les champs : les
|
||||
# garder toutes coûte quelques centaines de kilo-octets et évite une
|
||||
# seconde passe sur un fichier qui peut peser des gigaoctets.
|
||||
all_cols = with_columns is True
|
||||
set_cols = set() if all_cols else set(with_columns)
|
||||
dct_rows = {name: [] for name in set_want}
|
||||
dct_columns = {name: set() for name in set_cols}
|
||||
dct_census = {}
|
||||
|
||||
try:
|
||||
archive = zipfile.ZipFile(zip_path)
|
||||
except (OSError, zipfile.BadZipFile) as exc:
|
||||
raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc
|
||||
with archive:
|
||||
member = find_member(archive, "dump.sql")
|
||||
if member is None:
|
||||
# Le dump est la seule pièce indispensable : c'est LUI qui porte
|
||||
# les données. Son absence est donc la vraie erreur, là où celle
|
||||
# du manifeste n'en est pas une.
|
||||
raise AnalyseError(
|
||||
f"{t('This backup holds no dump.sql: ')}{zip_path}"
|
||||
)
|
||||
with archive.open(member) as raw:
|
||||
stream = io.TextIOWrapper(raw, encoding="utf-8", errors="replace")
|
||||
for line in stream:
|
||||
if line.startswith("COPY public."):
|
||||
name = line[len("COPY public.") :].split(" ", 1)[0]
|
||||
keep = name in set_want
|
||||
if not keep and not census:
|
||||
continue
|
||||
header = line[line.index("(") + 1 : line.rindex(")")]
|
||||
lst_col = [c.strip() for c in header.split(",")]
|
||||
n_row, n_byte = 0, 0
|
||||
for row in stream:
|
||||
if row.startswith("\\."):
|
||||
break
|
||||
n_row += 1
|
||||
n_byte += len(row)
|
||||
if not keep:
|
||||
continue
|
||||
values = row.rstrip("\n").split("\t")
|
||||
dct_rows[name].append(
|
||||
dict(
|
||||
zip(
|
||||
lst_col, [unescape_copy(v) for v in values]
|
||||
)
|
||||
)
|
||||
)
|
||||
if census:
|
||||
dct_census[name] = {
|
||||
"rows": n_row,
|
||||
"dump_bytes": n_byte,
|
||||
"columns": lst_col,
|
||||
}
|
||||
elif line.startswith("CREATE TABLE public."):
|
||||
name = line[len("CREATE TABLE public.") :].split(" ", 1)[0]
|
||||
if census:
|
||||
dct_census.setdefault(
|
||||
name, {"rows": 0, "dump_bytes": 0, "columns": []}
|
||||
)
|
||||
for row in stream:
|
||||
stripped = row.strip()
|
||||
if stripped.startswith(");"):
|
||||
break
|
||||
if not all_cols and name not in set_cols:
|
||||
continue
|
||||
if stripped.upper().startswith(NOT_A_COLUMN):
|
||||
continue
|
||||
column = stripped.split(" ", 1)[0].strip('",')
|
||||
if column:
|
||||
dct_columns.setdefault(name, set()).add(column)
|
||||
return manifest, dct_rows, dct_columns, dct_census
|
||||
|
||||
|
||||
def _describe():
|
||||
"""Dire ce qu'est ce fichier, et où sont les outils.
|
||||
|
||||
Ce fichier porte un shebang et un nom qui ressemble à celui d'un outil ;
|
||||
le lancer ne produisait rien du tout, ce qui se lit comme une panne plutôt
|
||||
que comme « ce n'est pas un exécutable ». Il énumère donc ses voisins qui,
|
||||
eux, se lancent — la liste vient du disque, elle ne peut pas se périmer
|
||||
quand un outil s'ajoute.
|
||||
"""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def is_runnable(name):
|
||||
"""Ce fichier se lance-t-il vraiment ?
|
||||
|
||||
Le nom ne suffit pas : `analyse_diff_tui.py` commence pareil et n'a
|
||||
pas de point d'entrée. L'annoncer comme exécutable reproduirait le
|
||||
défaut même que cette fonction corrige — promettre une commande qui
|
||||
ne fait rien. On regarde donc s'il y a un bloc `__main__`.
|
||||
"""
|
||||
if not (name.startswith("analyse_") and name.endswith(".py")):
|
||||
return False
|
||||
try:
|
||||
with open(os.path.join(here, name), encoding="utf-8") as handle:
|
||||
return '__name__ == "__main__"' in handle.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
lst_tool = sorted(name for name in os.listdir(here) if is_runnable(name))
|
||||
print(
|
||||
f"📚 {os.path.basename(__file__)} — "
|
||||
f"{t('shared library, nothing to run here.')}"
|
||||
)
|
||||
print()
|
||||
if lst_tool:
|
||||
print(t("Runnable tools in this directory:"))
|
||||
for tool in lst_tool:
|
||||
print(f" ./script/analyse/{tool} -d <database>")
|
||||
else:
|
||||
print(t("No analysis tool here yet."))
|
||||
print()
|
||||
print(f"{t('From the menu:')} make todo → Execute → Analyse")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_describe()
|
||||
70
script/analyse/shell/view_file_arch.py
Normal file
70
script/analyse/shell/view_file_arch.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Arch de référence des vues, telle que le module la déclare.
|
||||
|
||||
Ce fichier ne se lance PAS seul : il est poussé dans l'entrée standard d'un
|
||||
``odoo-bin shell``, qui lui fournit ``env``. Lancé directement, il ne trouve
|
||||
aucun ``env`` et ne fait rien.
|
||||
|
||||
Pourquoi passer par l'ORM plutôt que relire le XML
|
||||
--------------------------------------------------
|
||||
La question « à quoi comparer l'arch en base » a une réponse dans le code
|
||||
d'Odoo : c'est ce que fait son propre bouton « Reset view », mode ``hard`` —
|
||||
``view.with_context(read_arch_from_file=True, lang=None).arch``.
|
||||
|
||||
Cette seule expression gère ce qu'une relecture du XML devrait réimplémenter :
|
||||
localiser le fichier par ``arch_fs``, y trouver le bon nœud par identifiant
|
||||
externe ou par identifiant court, suivre un ``<record>`` qui ne fait que
|
||||
re-pointer, transformer un ``<template>`` en ``<t t-name>``, résoudre les
|
||||
``%(xmlid)s`` en identifiants réels. Réécrire tout cela, c'est se tromper
|
||||
autrement qu'Odoo.
|
||||
|
||||
Aucune écriture
|
||||
---------------
|
||||
``odoo/cli/shell.py`` fait un ``rollback`` après exécution. Le ``rollback``
|
||||
final ici est une ceinture par-dessus cette bretelle ; il n'y a aucun
|
||||
``commit``, et il n'y en aura pas.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
VIEW_IDS = os.environ.get("VIEW_IDS", "")
|
||||
LANG = os.environ.get("ANALYSE_LANG") or None
|
||||
|
||||
lst_id = [int(part) for part in VIEW_IDS.split(",") if part.strip().isdigit()]
|
||||
lst_out = []
|
||||
|
||||
if lst_id:
|
||||
views = env["ir.ui.view"].with_context(active_test=False).browse(lst_id)
|
||||
for view in views.exists():
|
||||
try:
|
||||
# lang=None demande la valeur brute, non traduite : comparer une
|
||||
# arch traduite à une arch source ferait ressortir chaque terme
|
||||
# traduit comme une différence.
|
||||
reference = view.with_context(
|
||||
read_arch_from_file=True, lang=LANG
|
||||
).arch
|
||||
error = None
|
||||
except Exception as exc: # une vue cassée ne doit pas tuer le lot
|
||||
reference, error = None, f"{type(exc).__name__}: {exc}"
|
||||
try:
|
||||
stored = view.with_context(lang=LANG).arch
|
||||
except Exception as exc:
|
||||
stored, error = None, error or f"{type(exc).__name__}: {exc}"
|
||||
lst_out.append(
|
||||
{
|
||||
"id": view.id,
|
||||
"xml_id": view.xml_id or view.key or "",
|
||||
"arch_db": stored,
|
||||
"arch_file": reference,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
|
||||
print("ANALYSE_JSON_BEGIN")
|
||||
print(json.dumps(lst_out))
|
||||
print("ANALYSE_JSON_END")
|
||||
env.cr.rollback()
|
||||
|
|
@ -242,6 +242,35 @@ class DatabaseManager:
|
|||
print(file_name)
|
||||
return file_name
|
||||
|
||||
def select_backup_path(self, start=None) -> str | None:
|
||||
"""Faire choisir une sauvegarde .zip, au parcours ou au chemin tapé.
|
||||
|
||||
Les deux, parce que ni l'un ni l'autre ne suffit : le parcours part
|
||||
d'`image_db/` et n'aide pas si la sauvegarde vient d'ailleurs ; le
|
||||
chemin tapé oblige à le connaître. Le parcours d'abord, et une saisie
|
||||
directe si l'on en sort sans rien choisir.
|
||||
"""
|
||||
directory = start or os.path.join(os.getcwd(), "image_db")
|
||||
if todo_file_browser is not None and os.path.isdir(directory):
|
||||
self._dir_path = ""
|
||||
browser = todo_file_browser.FileBrowser(
|
||||
directory, self._on_dir_selected
|
||||
)
|
||||
browser.run_main_frame()
|
||||
if self._dir_path and os.path.isfile(self._dir_path):
|
||||
print(self._dir_path)
|
||||
return self._dir_path
|
||||
answer = input(
|
||||
t("Path to the backup .zip (empty to cancel): ")
|
||||
).strip()
|
||||
if not answer:
|
||||
return None
|
||||
path = os.path.expanduser(answer)
|
||||
if not os.path.isfile(path):
|
||||
print(f"❌ {t('No such file: ')}{path}")
|
||||
return None
|
||||
return path
|
||||
|
||||
def download_database_backup_cli(
|
||||
self, show_remote_list: bool = True
|
||||
) -> tuple[int, str, str]:
|
||||
|
|
|
|||
1139
script/todo/todo.py
1139
script/todo/todo.py
File diff suppressed because it is too large
Load diff
|
|
@ -35,10 +35,6 @@ TRANSLATIONS = {
|
|||
"fr": "📦 Installation",
|
||||
"en": "📦 Install",
|
||||
},
|
||||
"Question": {
|
||||
"fr": "❓ Question",
|
||||
"en": "❓ Question",
|
||||
},
|
||||
"Fork - Open TODO in a new tab": {
|
||||
"fr": "🔀 Fork - Ouvre TODO dans une nouvelle tabulation",
|
||||
"en": "🔀 Fork - Open TODO in a new tab",
|
||||
|
|
@ -3129,6 +3125,577 @@ TRANSLATIONS = {
|
|||
"recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"},
|
||||
"runs erased": {"fr": "runs effacés", "en": "runs erased"},
|
||||
"Cancelled.": {"fr": "Annulé.", "en": "Cancelled."},
|
||||
# --- Menu Execute › Analyse ---
|
||||
# L'émoji vit dans la valeur, jamais dans la clé. Deux clés distinctes,
|
||||
# comme « Data » / « Database » : l'entrée de menu porte l'émoji,
|
||||
# l'étiquette du fil d'Ariane est nue — _todo_telemetry_tui traduit le
|
||||
# dernier segment du chemin, et un émoji y détonnerait.
|
||||
"Analyse - Odoo database analysis": {
|
||||
"fr": "🔬 Analyse - Analyse de base de données Odoo",
|
||||
"en": "🔬 Analyse - Odoo database analysis",
|
||||
},
|
||||
"Analyse": {"fr": "Analyse", "en": "Analysis"},
|
||||
"Analyse a database, without ever writing to it!": {
|
||||
"fr": "Analyser une base, sans jamais y écrire !",
|
||||
"en": "Analyse a database, without ever writing to it!",
|
||||
},
|
||||
# Les icônes du sous-menu Analyse. Elles sont réservées d'avance pour la
|
||||
# famille entière — 🧱 index, 🎈 ballonnement, 📎 pièces jointes,
|
||||
# 🔗 ir_model_data — pour qu'ajouter un outil ne demande pas d'en
|
||||
# rechercher une libre, et que deux outils voisins ne se ressemblent pas.
|
||||
"Structure": {"fr": "🗄 Structure", "en": "🗄 Structure"},
|
||||
"Tables and database size": {
|
||||
"fr": "📏 Tables et poids de la base",
|
||||
"en": "📏 Tables and database size",
|
||||
},
|
||||
"Customisation": {"fr": "🎭 Personnalisation", "en": "🎭 Customisation"},
|
||||
"Customised views, website copies included": {
|
||||
"fr": "🖼 Vues personnalisées, copies de site web comprises",
|
||||
"en": "🖼 Customised views, website copies included",
|
||||
},
|
||||
"Analysis failed: ": {
|
||||
"fr": "L'analyse a échoué : ",
|
||||
"en": "Analysis failed: ",
|
||||
},
|
||||
"Full list and JSON output:": {
|
||||
"fr": "Liste complète et sortie JSON :",
|
||||
"en": "Full list and JSON output:",
|
||||
},
|
||||
"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.": {
|
||||
"fr": "Une sauvegarde n'a pas de registre, donc rien n'a été comparé"
|
||||
" avec la source du module. Le classement ci-dessus n'en a pas besoin"
|
||||
" ; seuls les écarts en ont. Restaurez-la, ou lancez ceci sur la base.",
|
||||
"en": "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.",
|
||||
},
|
||||
# --- script/analyse : vues personnalisées ---
|
||||
"Customised views": {
|
||||
"fr": "Vues personnalisées",
|
||||
"en": "Customised views",
|
||||
},
|
||||
"Views": {"fr": "Vues", "en": "Views"},
|
||||
"why": {"fr": "pourquoi", "en": "why"},
|
||||
"more": {"fr": "de plus", "en": "more"},
|
||||
"From an installed theme": {
|
||||
"fr": "Posée par un thème installé",
|
||||
"en": "From an installed theme",
|
||||
},
|
||||
"Website copy (COW)": {
|
||||
"fr": "Copie de site web (COW)",
|
||||
"en": "Website copy (COW)",
|
||||
},
|
||||
"Made with Studio": {"fr": "Faite avec Studio", "en": "Made with Studio"},
|
||||
"Imported or exported": {
|
||||
"fr": "Importée ou exportée",
|
||||
"en": "Imported or exported",
|
||||
},
|
||||
"Created from the interface": {
|
||||
"fr": "Créée depuis l'interface",
|
||||
"en": "Created from the interface",
|
||||
},
|
||||
"From a module, flagged as touched": {
|
||||
"fr": "D'un module, signalée comme retouchée",
|
||||
"en": "From a module, flagged as touched",
|
||||
},
|
||||
"Straight from a module": {
|
||||
"fr": "Telle quelle depuis un module",
|
||||
"en": "Straight from a module",
|
||||
},
|
||||
"Every view comes straight from a module.": {
|
||||
"fr": "Toutes les vues viennent telles quelles d'un module.",
|
||||
"en": "Every view comes straight from a module.",
|
||||
},
|
||||
"Views that did not come straight from a module": {
|
||||
"fr": "Vues qui ne viennent pas telles quelles d'un module",
|
||||
"en": "Views that did not come straight from a module",
|
||||
},
|
||||
"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:": {
|
||||
"fr": "Les copies de site web sont des données utilisateur : Odoo"
|
||||
" copie une vue au lieu de la modifier. Savoir si elles survivront à"
|
||||
" la prochaine version est une autre question, et ces outils y"
|
||||
" répondent :",
|
||||
"en": "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:",
|
||||
},
|
||||
"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.": {
|
||||
"fr": "Les drapeaux disent qu'une vue a été touchée, pas comment. Ils"
|
||||
" sont incomplets dans les deux sens : un write SQL direct n'arme pas"
|
||||
" arch_updated, et reset_arch l'efface. Seule la comparaison avec la"
|
||||
" source du module tranche.",
|
||||
"en": "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.",
|
||||
},
|
||||
"List the views of an Odoo database that did not come straight from a"
|
||||
" module, website copies included (read-only).": {
|
||||
"fr": "Lister les vues d'une base Odoo qui ne viennent pas telles"
|
||||
" quelles d'un module, copies de site web comprises (lecture seule).",
|
||||
"en": "List the views of an Odoo database that did not come straight"
|
||||
" from a module, website copies included (read-only).",
|
||||
},
|
||||
"only show this category": {
|
||||
"fr": "n'afficher que cette catégorie",
|
||||
"en": "only show this category",
|
||||
},
|
||||
"how many views to show (default: 20)": {
|
||||
"fr": "nombre de vues à afficher (défaut : 20)",
|
||||
"en": "how many views to show (default: 20)",
|
||||
},
|
||||
"list every view": {
|
||||
"fr": "afficher toutes les vues",
|
||||
"en": "list every view",
|
||||
},
|
||||
"which views to compare (default: flagged)": {
|
||||
"fr": "quelles vues comparer (défaut : celles qui sont signalées)",
|
||||
"en": "which views to compare (default: flagged)",
|
||||
},
|
||||
"From a module, silently drifted": {
|
||||
"fr": "D'un module, dérivée en silence",
|
||||
"en": "From a module, silently drifted",
|
||||
},
|
||||
"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.": {
|
||||
"fr": "En --scope all, un écart est une piste, pas un verdict :"
|
||||
" read_arch_from_file rend le fichier brut, alors que la base porte"
|
||||
" l'arch APRÈS traitement au chargement. Mesuré sur une base 18.0"
|
||||
" fraîchement installée, 160 de ses 974 vues diffèrent déjà ainsi.",
|
||||
"en": "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.",
|
||||
},
|
||||
"Compare with the module source? (Y/n): ": {
|
||||
"fr": "Comparer avec la source du module ? (O/n) : ",
|
||||
"en": "Compare with the module source? (Y/n): ",
|
||||
},
|
||||
# --- Menu Analyse : les actions « aller plus loin ». Ce qui était
|
||||
# conseillé en options de ligne de commande est devenu des entrées :
|
||||
# dire « utilisez -v » à quelqu'un qui est dans un menu, c'est lui
|
||||
# demander d'en sortir pour obtenir ce que le menu pouvait offrir.
|
||||
"Browse the differences (TUI)": {
|
||||
"fr": "🖥 Naviguer dans les écarts (plein écran)",
|
||||
"en": "🖥 Browse the differences (TUI)",
|
||||
},
|
||||
"Compare every view (slower, noisier)": {
|
||||
"fr": "🔬 Comparer toutes les vues (plus long, plus bruyant)",
|
||||
"en": "🔬 Compare every view (slower, noisier)",
|
||||
},
|
||||
"Compare first, then browse.": {
|
||||
"fr": "Comparez d'abord, vous naviguerez ensuite.",
|
||||
"en": "Compare first, then browse.",
|
||||
},
|
||||
"Compare the flagged views with the module source": {
|
||||
"fr": "🔍 Comparer les vues signalées avec la source du module",
|
||||
"en": "🔍 Compare the flagged views with the module source",
|
||||
},
|
||||
"Count rows exactly (full scan)": {
|
||||
"fr": "🔢 Compter les lignes exactement (balayage complet)",
|
||||
"en": "🔢 Count rows exactly (full scan)",
|
||||
},
|
||||
"Counting rows exactly, one scan per table…": {
|
||||
"fr": "Comptage exact des lignes, un balayage par table…",
|
||||
"en": "Counting rows exactly, one scan per table…",
|
||||
},
|
||||
"Export as JSON": {"fr": "💾 Exporter en JSON", "en": "💾 Export as JSON"},
|
||||
"Go further": {"fr": "🔎 Aller plus loin", "en": "🔎 Go further"},
|
||||
"Loading the Odoo registry, this takes a moment…": {
|
||||
"fr": "Chargement du registre Odoo, cela prend un moment…",
|
||||
"en": "Loading the Odoo registry, this takes a moment…",
|
||||
},
|
||||
"No view differs from its module source.": {
|
||||
"fr": "Aucune vue ne diffère de la source de son module.",
|
||||
"en": "No view differs from its module source.",
|
||||
},
|
||||
"Show every table": {
|
||||
"fr": "📜 Afficher toutes les tables",
|
||||
"en": "📜 Show every table",
|
||||
},
|
||||
"Show every view": {
|
||||
"fr": "📜 Afficher toutes les vues",
|
||||
"en": "📜 Show every view",
|
||||
},
|
||||
"Written to: ": {"fr": "Écrit dans : ", "en": "Written to: "},
|
||||
"Flags say a view was touched, not how: only comparing with the module"
|
||||
" source settles it.": {
|
||||
"fr": "Les drapeaux disent qu'une vue a été touchée, pas comment :"
|
||||
" seule la comparaison avec la source du module tranche.",
|
||||
"en": "Flags say a view was touched, not how: only comparing with the"
|
||||
" module source settles it.",
|
||||
},
|
||||
"blocking": {"fr": "bloquants", "en": "blocking"},
|
||||
"Studio and hand-made x_ fields": {
|
||||
"fr": "🧩 Champs x_ (Studio et faits à la main)",
|
||||
"en": "🧩 Studio and hand-made x_ fields",
|
||||
},
|
||||
"Show every field": {
|
||||
"fr": "📜 Afficher tous les champs",
|
||||
"en": "📜 Show every field",
|
||||
},
|
||||
"A database": {"fr": "💾 Une base de données", "en": "💾 A database"},
|
||||
"A backup .zip, without restoring it": {
|
||||
"fr": "🗜 Une sauvegarde .zip, sans la restaurer",
|
||||
"en": "🗜 A backup .zip, without restoring it",
|
||||
},
|
||||
"Path to the backup .zip (empty to cancel): ": {
|
||||
"fr": "Chemin de la sauvegarde .zip (vide pour annuler) : ",
|
||||
"en": "Path to the backup .zip (empty to cancel): ",
|
||||
},
|
||||
"No such file: ": {"fr": "Fichier introuvable : ", "en": "No such file: "},
|
||||
"from a backup": {"fr": "depuis une sauvegarde", "en": "from a backup"},
|
||||
"Odoo backup .zip to inspect, without restoring it": {
|
||||
"fr": "sauvegarde Odoo .zip à examiner, sans la restaurer",
|
||||
"en": "Odoo backup .zip to inspect, without restoring it",
|
||||
},
|
||||
"Not an Odoo backup (no manifest.json): ": {
|
||||
"fr": "Pas une sauvegarde Odoo (aucun manifest.json) : ",
|
||||
"en": "Not an Odoo backup (no manifest.json): ",
|
||||
},
|
||||
"Cannot read the backup: ": {
|
||||
"fr": "Lecture impossible de la sauvegarde : ",
|
||||
"en": "Cannot read the backup: ",
|
||||
},
|
||||
"This backup holds no dump.sql: ": {
|
||||
"fr": "Cette sauvegarde ne contient aucun dump.sql : ",
|
||||
"en": "This backup holds no dump.sql: ",
|
||||
},
|
||||
# --- script/analyse : champs et modèles hors module ---
|
||||
"Nothing declares these in a file, so no module will "
|
||||
"recreate them. What a version upgrade keeps is what "
|
||||
"someone carried over.": {
|
||||
"fr": "Rien ne les déclare dans un fichier, donc aucun module "
|
||||
"ne les recréera. Ce qu'une montée de version conserve "
|
||||
"est ce que quelqu'un a reporté.",
|
||||
"en": "Nothing declares these in a file, so no module will "
|
||||
"recreate them. What a version upgrade keeps is what "
|
||||
"someone carried over.",
|
||||
},
|
||||
"A stored field without its column stops the registry "
|
||||
"from loading, so the upgrade will not even start. Settle "
|
||||
"these before anything else.": {
|
||||
"fr": "Un champ stocké sans sa colonne empêche le registre de "
|
||||
"charger, donc la montée de version ne démarrera même "
|
||||
"pas. Réglez cela avant tout le reste.",
|
||||
"en": "A stored field without its column stops the registry "
|
||||
"from loading, so the upgrade will not even start. Settle "
|
||||
"these before anything else.",
|
||||
},
|
||||
"Use -v to list them all, --json for the raw data.": {
|
||||
"fr": "Utilisez -v pour tout afficher, --json pour la donnée "
|
||||
"brute.",
|
||||
"en": "Use -v to list them all, --json for the raw data.",
|
||||
},
|
||||
"List the fields and models added outside a module — "
|
||||
"Studio or by hand (read-only).": {
|
||||
"fr": "Lister les champs et modèles ajoutés hors module — "
|
||||
"Studio ou faits à la main (lecture seule).",
|
||||
"en": "List the fields and models added outside a module — "
|
||||
"Studio or by hand (read-only).",
|
||||
},
|
||||
"how many to show (default: 30)": {
|
||||
"fr": "nombre à afficher (défaut : 30)",
|
||||
"en": "how many to show (default: 30)",
|
||||
},
|
||||
"list every one": {"fr": "tout afficher", "en": "list every one"},
|
||||
"Studio": {"fr": "Studio", "en": "Studio"},
|
||||
"Made by hand": {"fr": "Fait à la main", "en": "Made by hand"},
|
||||
"Declared by a module": {
|
||||
"fr": "Déclaré par un module",
|
||||
"en": "Declared by a module",
|
||||
},
|
||||
"stored, but its column is missing": {
|
||||
"fr": "stocké, mais sa colonne manque",
|
||||
"en": "stored, but its column is missing",
|
||||
},
|
||||
"points at a model that no longer exists": {
|
||||
"fr": "pointe vers un modèle qui n'existe plus",
|
||||
"en": "points at a model that no longer exists",
|
||||
},
|
||||
"its model no longer exists": {
|
||||
"fr": "son modèle n'existe plus",
|
||||
"en": "its model no longer exists",
|
||||
},
|
||||
"its table could not be resolved": {
|
||||
"fr": "sa table n'a pas pu être résolue",
|
||||
"en": "its table could not be resolved",
|
||||
},
|
||||
"origin": {"fr": "provenance", "en": "origin"},
|
||||
"Fields added outside a module": {
|
||||
"fr": "Champs ajoutés hors module",
|
||||
"en": "Fields added outside a module",
|
||||
},
|
||||
"Custom fields": {"fr": "Champs personnalisés", "en": "Custom fields"},
|
||||
"Custom models": {"fr": "Modèles personnalisés", "en": "Custom models"},
|
||||
"No field or model was added outside a module.": {
|
||||
"fr": "Aucun champ ni modèle n'a été ajouté hors module.",
|
||||
"en": "No field or model was added outside a module.",
|
||||
},
|
||||
"Blocking": {"fr": "Bloquant", "en": "Blocking"},
|
||||
"To carry over by hand": {
|
||||
"fr": "À reporter à la main",
|
||||
"en": "To carry over by hand",
|
||||
},
|
||||
# --- script/analyse : comparaison et navigation des écarts ---
|
||||
"Differences only": {
|
||||
"fr": "Écarts seuls",
|
||||
"en": "Differences only",
|
||||
},
|
||||
"Ignore indentation": {
|
||||
"fr": "Ignorer l'indentation",
|
||||
"en": "Ignore indentation",
|
||||
},
|
||||
"Copy": {
|
||||
"fr": "Copier",
|
||||
"en": "Copy",
|
||||
},
|
||||
"Reset command": {
|
||||
"fr": "Commande de réinitialisation",
|
||||
"en": "Reset command",
|
||||
},
|
||||
"module (file)": {
|
||||
"fr": "module (fichier)",
|
||||
"en": "module (file)",
|
||||
},
|
||||
"database": {
|
||||
"fr": "base",
|
||||
"en": "database",
|
||||
},
|
||||
"Difference copied.": {
|
||||
"fr": "Écart copié.",
|
||||
"en": "Difference copied.",
|
||||
},
|
||||
"No Odoo configuration file found.": {
|
||||
"fr": "Aucun fichier de configuration Odoo trouvé.",
|
||||
"en": "No Odoo configuration file found.",
|
||||
},
|
||||
"odoo_bin.sh not found.": {
|
||||
"fr": "odoo_bin.sh est introuvable.",
|
||||
"en": "odoo_bin.sh not found.",
|
||||
},
|
||||
"The Odoo shell returned no result: ": {
|
||||
"fr": "Le shell Odoo n'a rien renvoyé : ",
|
||||
"en": "The Odoo shell returned no result: ",
|
||||
},
|
||||
"The Odoo shell exceeded the timeout (s): ": {
|
||||
"fr": "Le shell Odoo a dépassé le délai imparti (s) : ",
|
||||
"en": "The Odoo shell exceeded the timeout (s): ",
|
||||
},
|
||||
"Unreadable JSON from the Odoo shell: ": {
|
||||
"fr": "JSON illisible en sortie du shell Odoo : ",
|
||||
"en": "Unreadable JSON from the Odoo shell: ",
|
||||
},
|
||||
"To restore a view to what its module declares — this "
|
||||
"WRITES to the database, so read the difference first:": {
|
||||
"fr": "Pour rendre à une vue ce que déclare son module — ceci "
|
||||
"ÉCRIT dans la base, lisez donc l'écart d'abord :",
|
||||
"en": "To restore a view to what its module declares — this "
|
||||
"WRITES to the database, so read the difference first:",
|
||||
},
|
||||
"compare with the module source (opens an Odoo shell)": {
|
||||
"fr": "comparer avec la source du module (ouvre un shell Odoo)",
|
||||
"en": "compare with the module source (opens an Odoo shell)",
|
||||
},
|
||||
"fail if the comparison could not be made": {
|
||||
"fr": "échouer si la comparaison n'a pas pu être faite",
|
||||
"en": "fail if the comparison could not be made",
|
||||
},
|
||||
"browse the differences in a full-screen view": {
|
||||
"fr": "naviguer dans les écarts en plein écran",
|
||||
"en": "browse the differences in a full-screen view",
|
||||
},
|
||||
"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.": {
|
||||
"fr": "Les drapeaux disent qu'une vue a été touchée, pas comment. "
|
||||
"Ils sont incomplets dans les deux sens : un write SQL "
|
||||
"direct n'arme pas arch_updated, et reset_arch l'efface. "
|
||||
"Seule la comparaison avec la source du module tranche — "
|
||||
"ajoutez --diff.",
|
||||
"en": "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.",
|
||||
},
|
||||
"Database is Odoo": {
|
||||
"fr": "La base est en Odoo",
|
||||
"en": "Database is Odoo",
|
||||
},
|
||||
"checkout is": {
|
||||
"fr": "le checkout est en",
|
||||
"en": "checkout is",
|
||||
},
|
||||
"views were flagged but hold exactly what their module "
|
||||
"declares: only the comparison could tell.": {
|
||||
"fr": "vues étaient signalées mais portent exactement ce que "
|
||||
"déclare leur module : seule la comparaison pouvait le "
|
||||
"dire.",
|
||||
"en": "views were flagged but hold exactly what their module "
|
||||
"declares: only the comparison could tell.",
|
||||
},
|
||||
"No reference arch, so nothing was compared: ": {
|
||||
"fr": "Aucune arch de référence, donc rien n'a été comparé : ",
|
||||
"en": "No reference arch, so nothing was compared: ",
|
||||
},
|
||||
"Not a terminal: showing the text report instead.": {
|
||||
"fr": "Pas un terminal : affichage du rapport texte à la place.",
|
||||
"en": "Not a terminal: showing the text report instead.",
|
||||
},
|
||||
"To restore this view to what its module declares:": {
|
||||
"fr": "Pour rendre à cette vue ce que déclare son module :",
|
||||
"en": "To restore this view to what its module declares:",
|
||||
},
|
||||
# --- script/analyse : erreurs du socle ---
|
||||
"Invalid database name: ": {
|
||||
"fr": "Nom de base invalide : ",
|
||||
"en": "Invalid database name: ",
|
||||
},
|
||||
"Cannot read from the database: ": {
|
||||
"fr": "Lecture impossible dans la base : ",
|
||||
"en": "Cannot read from the database: ",
|
||||
},
|
||||
"is not an Odoo database.": {
|
||||
"fr": "n'est pas une base Odoo.",
|
||||
"en": "is not an Odoo database.",
|
||||
},
|
||||
"psql is not installed or not in PATH.": {
|
||||
"fr": "psql n'est pas installé, ou absent du PATH.",
|
||||
"en": "psql is not installed or not in PATH.",
|
||||
},
|
||||
"Query exceeded the timeout (s): ": {
|
||||
"fr": "La requête a dépassé le délai imparti (s) : ",
|
||||
"en": "Query exceeded the timeout (s): ",
|
||||
},
|
||||
"Unreadable JSON from psql: ": {
|
||||
"fr": "JSON illisible en sortie de psql : ",
|
||||
"en": "Unreadable JSON from psql: ",
|
||||
},
|
||||
"shared library, nothing to run here.": {
|
||||
"fr": "bibliothèque partagée, rien à lancer ici.",
|
||||
"en": "shared library, nothing to run here.",
|
||||
},
|
||||
"Runnable tools in this directory:": {
|
||||
"fr": "Outils exécutables de ce répertoire :",
|
||||
"en": "Runnable tools in this directory:",
|
||||
},
|
||||
"No analysis tool here yet.": {
|
||||
"fr": "Aucun outil d'analyse ici pour l'instant.",
|
||||
"en": "No analysis tool here yet.",
|
||||
},
|
||||
"From the menu:": {"fr": "Depuis le menu :", "en": "From the menu:"},
|
||||
"in the dump": {"fr": "dans le dump", "en": "in the dump"},
|
||||
"Weight in the dump": {
|
||||
"fr": "Poids dans le dump",
|
||||
"en": "Weight in the dump",
|
||||
},
|
||||
# --- script/analyse : poids du schéma ---
|
||||
"Schema analysis": {
|
||||
"fr": "Analyse du schéma",
|
||||
"en": "Schema analysis",
|
||||
},
|
||||
"Database size": {
|
||||
"fr": "Poids de la base",
|
||||
"en": "Database size",
|
||||
},
|
||||
"Tables": {"fr": "Tables", "en": "Tables"},
|
||||
"Models": {"fr": "Modèles", "en": "Models"},
|
||||
"rows": {"fr": "lignes", "en": "rows"},
|
||||
"Models without table": {
|
||||
"fr": "Modèles sans table",
|
||||
"en": "Models without table",
|
||||
},
|
||||
"abstract models have none, by design": {
|
||||
"fr": "les modèles abstraits n'en ont pas, par construction",
|
||||
"en": "abstract models have none, by design",
|
||||
},
|
||||
"ir_model_relation is absent, so m2m tables cannot be told apart from"
|
||||
" orphans: the list below is unreliable.": {
|
||||
"fr": "ir_model_relation est absente : impossible de distinguer une"
|
||||
" table m2m d'une orpheline, la liste ci-dessous n'est pas fiable.",
|
||||
"en": "ir_model_relation is absent, so m2m tables cannot be told apart"
|
||||
" from orphans: the list below is unreliable.",
|
||||
},
|
||||
"Heaviest tables": {
|
||||
"fr": "Tables les plus lourdes",
|
||||
"en": "Heaviest tables",
|
||||
},
|
||||
"All tables, heaviest first": {
|
||||
"fr": "Toutes les tables, de la plus lourde à la plus légère",
|
||||
"en": "All tables, heaviest first",
|
||||
},
|
||||
"use -v to list them all": {
|
||||
"fr": "utilisez -v pour toutes les afficher",
|
||||
"en": "use -v to list them all",
|
||||
},
|
||||
"Every table belongs to an installed model.": {
|
||||
"fr": "Chaque table appartient à un modèle installé.",
|
||||
"en": "Every table belongs to an installed model.",
|
||||
},
|
||||
"Orphan tables": {
|
||||
"fr": "Tables orphelines",
|
||||
"en": "Orphan tables",
|
||||
},
|
||||
"No installed model claims these tables. They are usually left over from"
|
||||
" modules uninstalled without DROP TABLE, and every version upgrade"
|
||||
" carries them along.": {
|
||||
"fr": "Aucun modèle installé ne réclame ces tables. Ce sont le plus"
|
||||
" souvent des reliquats de modules désinstallés sans DROP TABLE, que"
|
||||
" chaque montée de version transporte avec elle.",
|
||||
"en": "No installed model claims these tables. They are usually left"
|
||||
" over from modules uninstalled without DROP TABLE, and every version"
|
||||
" upgrade carries them along.",
|
||||
},
|
||||
"Check what they hold before dropping anything.": {
|
||||
"fr": "Regardez ce qu'elles contiennent avant de supprimer quoi que ce"
|
||||
" soit.",
|
||||
"en": "Check what they hold before dropping anything.",
|
||||
},
|
||||
"Row counts are estimates from the last ANALYZE. Use --exact for real"
|
||||
" counts, at the cost of one full scan per table.": {
|
||||
"fr": "Les nombres de lignes sont estimés d'après le dernier ANALYZE."
|
||||
" Utilisez --exact pour un comptage réel, au prix d'un balayage"
|
||||
" complet par table.",
|
||||
"en": "Row counts are estimates from the last ANALYZE. Use --exact for"
|
||||
" real counts, at the cost of one full scan per table.",
|
||||
},
|
||||
"Report the size of an Odoo database and the tables no installed model"
|
||||
" claims (read-only).": {
|
||||
"fr": "Rapporter le poids d'une base Odoo et les tables qu'aucun"
|
||||
" modèle installé ne réclame (lecture seule).",
|
||||
"en": "Report the size of an Odoo database and the tables no installed"
|
||||
" model claims (read-only).",
|
||||
},
|
||||
"database to inspect": {
|
||||
"fr": "base à examiner",
|
||||
"en": "database to inspect",
|
||||
},
|
||||
"count rows exactly: one full scan per table": {
|
||||
"fr": "compter les lignes exactement : un balayage complet par table",
|
||||
"en": "count rows exactly: one full scan per table",
|
||||
},
|
||||
"how many tables to show (default: 20)": {
|
||||
"fr": "nombre de tables à afficher (défaut : 20)",
|
||||
"en": "how many tables to show (default: 20)",
|
||||
},
|
||||
"list every table": {
|
||||
"fr": "afficher toutes les tables",
|
||||
"en": "list every table",
|
||||
},
|
||||
"output JSON": {"fr": "sortie JSON", "en": "output JSON"},
|
||||
"path to an Odoo config file": {
|
||||
"fr": "chemin d'un fichier de configuration Odoo",
|
||||
"en": "path to an Odoo config file",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
212
test/test_analyse_custom_field.py
Normal file
212
test/test_analyse_custom_field.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Champs hors module : l'attribution et le rendu, sans base.
|
||||
|
||||
Ce qui décide d'un blocage vit dans `collect()`, qui a besoin de PostgreSQL.
|
||||
Il s'éprouve sur une base synthétique portant les cinq cas qui comptent :
|
||||
|
||||
* un champ Studio dont la colonne existe — rien à signaler ;
|
||||
* un champ fait main SANS colonne — bloquant, le registre ne chargerait pas ;
|
||||
* un one2many non stocké — PAS un bloquant, il n'a jamais de colonne ;
|
||||
* un champ sur `ir.actions.act_window`, dont la table est `ir_act_window` —
|
||||
`replace('.', '_')` crierait à la colonne manquante ;
|
||||
* un many2one vers un modèle absent — bloquant.
|
||||
|
||||
Attendu : 6 champs, 2 bloquants, 1 modèle manuel. Rejoué ensuite sur la forme
|
||||
12.0 — `company_dependent` retirée, `field_description` en text, pas de
|
||||
`ir_model_fields_selection` — pour la même sortie.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from script.analyse import analyse_custom_field as A
|
||||
from script.todo import todo_i18n
|
||||
|
||||
|
||||
def field(**override):
|
||||
row = {
|
||||
"id": 1,
|
||||
"model": "res.partner",
|
||||
"name": "x_champ",
|
||||
"ttype": "char",
|
||||
"relation": None,
|
||||
"store": True,
|
||||
"xmlid_modules": None,
|
||||
"origin": "handmade",
|
||||
"blocker": None,
|
||||
}
|
||||
row.update(override)
|
||||
return row
|
||||
|
||||
|
||||
class TestFieldOrigin(unittest.TestCase):
|
||||
def test_no_xmlid_at_all_means_hand_made(self):
|
||||
# Un champ créé en mode développeur n'a AUCUNE ligne ir_model_data.
|
||||
# C'est ce qui le distingue, pas son nom : il s'appelle « x_… » lui
|
||||
# aussi.
|
||||
self.assertEqual(A.field_origin(field(xmlid_modules=None)), "handmade")
|
||||
self.assertEqual(A.field_origin(field(xmlid_modules=[])), "handmade")
|
||||
|
||||
def test_studio_module_means_studio(self):
|
||||
self.assertEqual(
|
||||
A.field_origin(field(xmlid_modules=["studio_customization"])),
|
||||
"studio",
|
||||
)
|
||||
|
||||
def test_studio_seen_among_several_xmlids(self):
|
||||
# Un champ peut porter plusieurs identifiants externes. Une jointure
|
||||
# plate n'en rendrait qu'un, choisi au hasard : Studio passerait
|
||||
# inaperçu une fois sur deux. D'où l'agrégat côté SQL.
|
||||
self.assertEqual(
|
||||
A.field_origin(
|
||||
field(xmlid_modules=["aaa", "studio_customization"])
|
||||
),
|
||||
"studio",
|
||||
)
|
||||
|
||||
def test_a_module_xmlid_means_a_module(self):
|
||||
self.assertEqual(
|
||||
A.field_origin(field(xmlid_modules=["mon_module"])), "module"
|
||||
)
|
||||
|
||||
def test_the_x_studio_prefix_is_never_the_only_signal(self):
|
||||
# Le préfixe ressemble à Studio mais ne prouve rien : sans identifiant
|
||||
# externe, le champ a été fait à la main.
|
||||
self.assertEqual(
|
||||
A.field_origin(field(name="x_studio_faux", xmlid_modules=None)),
|
||||
"handmade",
|
||||
)
|
||||
|
||||
|
||||
class TestLabels(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||||
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||||
# travail et laisse la langue changée pour tout ce qui suit —
|
||||
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||||
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||||
# la mémoïsation directement, et on rend la valeur trouvée.
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def test_every_origin_has_a_label(self):
|
||||
for name in ("studio", "handmade", "module"):
|
||||
self.assertNotEqual(A.origin_label(name), name)
|
||||
|
||||
def test_every_blocker_has_a_label(self):
|
||||
for name in (
|
||||
"missing_column",
|
||||
"dangling_relation",
|
||||
"model_gone",
|
||||
"table_unknown",
|
||||
):
|
||||
self.assertNotEqual(A.blocker_label(name), name)
|
||||
|
||||
def test_unknown_key_falls_back_to_itself(self):
|
||||
self.assertEqual(A.origin_label("inconnu"), "inconnu")
|
||||
|
||||
|
||||
class TestNoColumnTypes(unittest.TestCase):
|
||||
def test_to_many_fields_have_no_column_by_design(self):
|
||||
# Le faux positif à ne pas produire : un one2many n'a jamais de
|
||||
# colonne, il vit dans une table de relation. Le compter manquant
|
||||
# ferait un bloquant sur chaque relation d'une base ordinaire.
|
||||
self.assertIn("one2many", A.NO_COLUMN_TYPES)
|
||||
self.assertIn("many2many", A.NO_COLUMN_TYPES)
|
||||
self.assertNotIn("char", A.NO_COLUMN_TYPES)
|
||||
self.assertNotIn("many2one", A.NO_COLUMN_TYPES)
|
||||
|
||||
|
||||
class TestRender(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||||
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||||
# travail et laisse la langue changée pour tout ce qui suit —
|
||||
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||||
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||||
# la mémoïsation directement, et on rend la valeur trouvée.
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def data(self, **override):
|
||||
fields = [
|
||||
field(id=1, name="x_studio_code", origin="studio"),
|
||||
field(
|
||||
id=2,
|
||||
name="x_fait_main",
|
||||
origin="handmade",
|
||||
blocker="missing_column",
|
||||
),
|
||||
]
|
||||
data = {
|
||||
"tool": "analyse_custom_field",
|
||||
"version": 1,
|
||||
"database": "prod_18",
|
||||
"odoo_version": "18.0.1.3",
|
||||
"n_fields": 2,
|
||||
"n_models": 1,
|
||||
"counts": {
|
||||
"studio": 1,
|
||||
"handmade": 1,
|
||||
"module": 0,
|
||||
"blockers": 1,
|
||||
"models": 1,
|
||||
},
|
||||
"fields": fields,
|
||||
"models": [{"model": "x_contrat", "description": "Contrat"}],
|
||||
"blockers": [fields[1]],
|
||||
}
|
||||
data.update(override)
|
||||
return data
|
||||
|
||||
def test_blockers_come_first_and_say_why(self):
|
||||
out = A.render(self.data())
|
||||
self.assertIn("Blocking (1)", out)
|
||||
self.assertIn("stored, but its column is missing", out)
|
||||
self.assertLess(out.index("Blocking"), out.index("To carry over"))
|
||||
|
||||
def test_says_the_registry_will_not_load(self):
|
||||
self.assertIn("stops the registry from loading", A.render(self.data()))
|
||||
|
||||
def test_no_blocker_block_when_there_is_none(self):
|
||||
data = self.data(blockers=[])
|
||||
data["counts"]["blockers"] = 0
|
||||
self.assertNotIn("Blocking", A.render(data))
|
||||
|
||||
def test_counts_say_how_many_of_the_list_are_blocking(self):
|
||||
# Les bloquants sont AUSSI dans la liste à reporter : le dire, sinon
|
||||
# deux populations dont les comptes ne s'additionnent pas.
|
||||
self.assertIn("(2, 1 blocking)", A.render(self.data()))
|
||||
|
||||
def test_clean_database_says_so(self):
|
||||
data = self.data(
|
||||
n_fields=0, n_models=0, fields=[], models=[], blockers=[]
|
||||
)
|
||||
out = A.render(data)
|
||||
self.assertIn("No field or model was added outside a module.", out)
|
||||
|
||||
def test_says_nothing_will_recreate_them(self):
|
||||
# La raison d'être de l'outil : ces champs ne sont dans aucun fichier.
|
||||
self.assertIn("no module will recreate", A.render(self.data()))
|
||||
|
||||
def test_hint_is_absent_in_verbose_and_in_the_menu(self):
|
||||
self.assertIn("Use -v", A.render(self.data()))
|
||||
self.assertNotIn("Use -v", A.render(self.data(), verbose=True))
|
||||
self.assertNotIn("Use -v", A.render(self.data(), hints=False))
|
||||
|
||||
def test_french_differs(self):
|
||||
english = A.render(self.data())
|
||||
todo_i18n._current_lang = "fr" # cf. setUp : pas de persistance
|
||||
french = A.render(self.data())
|
||||
self.assertIn("Fait à la main", french)
|
||||
self.assertNotEqual(english, french)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
522
test/test_analyse_lib.py
Normal file
522
test/test_analyse_lib.py
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Socle d'analyse : ce qui se teste SANS base de données.
|
||||
|
||||
Tout ici est une fonction pure ou une fonction dont le seul effet est de
|
||||
construire du SQL ou un environnement. C'est délibéré : ces tests doivent
|
||||
tourner sur une machine sans PostgreSQL, donc dans la suite du dépôt.
|
||||
|
||||
Ce qui exige une vraie base — `require_odoo_database`, `database_version`,
|
||||
`existing_columns`, `column_types`, `public_tables` — n'est pas ici : le test
|
||||
ne peut pas fabriquer sa base, puisque créer une base est une écriture et que
|
||||
l'outillage est en lecture seule par construction.
|
||||
|
||||
Ces cinq fonctions s'éprouvent à la main sur une base synthétique, ce qui
|
||||
prend une minute et n'exige aucun Odoo installé :
|
||||
|
||||
createdb erplibre_analyse_selftest
|
||||
psql -d erplibre_analyse_selftest -c "
|
||||
CREATE TABLE ir_module_module (id serial PRIMARY KEY, name varchar,
|
||||
state varchar, latest_version varchar);
|
||||
INSERT INTO ir_module_module (name, state, latest_version)
|
||||
VALUES ('base', 'installed', '18.0.1.3');
|
||||
-- arch_db en jsonb : la forme >= 16.0. Le « | » et le saut de ligne
|
||||
-- sont là exprès : ils prouvent que json_query ne coupe pas dessus.
|
||||
CREATE TABLE ir_ui_view (id serial PRIMARY KEY, name jsonb,
|
||||
arch_db jsonb, website_id integer, arch_fs varchar);
|
||||
INSERT INTO ir_ui_view (name, arch_db) VALUES
|
||||
('{\"en_US\":\"Avec un | et un saut\\nde ligne\"}',
|
||||
'{\"en_US\":\"<form/>\"}');
|
||||
-- une colonne text : la forme <= 15.0, pour éprouver tr_col des deux côtés
|
||||
CREATE TABLE res_partner (id serial PRIMARY KEY, ref text);
|
||||
-- table dont le nom NE dérive PAS du modèle ir.actions.act_window
|
||||
CREATE TABLE ir_act_window (id serial PRIMARY KEY, res_model varchar);"
|
||||
|
||||
Attendu : `database_version` rend « 18.0.1.3 » ; `column_types` distingue
|
||||
`jsonb` de `text` ; `tr_col` produit `->>'en_US'` d'un côté et `::text` de
|
||||
l'autre ; `model_table('ir.actions.act_window')` rend `ir_act_window` ;
|
||||
`require_odoo_database` refuse une base sans `ir_module_module` ; et un
|
||||
`CREATE TABLE` passé à `run_psql` est refusé par le serveur, pas par nous.
|
||||
Puis `dropdb erplibre_analyse_selftest`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from script.analyse import lib_analyse as L
|
||||
from script.todo import todo_i18n
|
||||
|
||||
|
||||
class TestValidDatabaseName(unittest.TestCase):
|
||||
def test_accepts_real_names(self):
|
||||
for name in ("test", "prod_18", "client.prod", "a-b_c.1"):
|
||||
self.assertTrue(L.valid_database_name(name), name)
|
||||
|
||||
def test_rejects_a_traceback_line(self):
|
||||
# Le cas qui a motivé le contrôle : un PostgreSQL injoignable faisait
|
||||
# remonter sa trace d'appel comme si c'était une liste de bases.
|
||||
self.assertFalse(
|
||||
L.valid_database_name("Traceback (most recent call last):")
|
||||
)
|
||||
|
||||
def test_rejects_shell_injection(self):
|
||||
for name in ("a; DROP DATABASE b", "a b", "$(id)", "a|b", "a'b"):
|
||||
self.assertFalse(L.valid_database_name(name), name)
|
||||
|
||||
def test_rejects_empty(self):
|
||||
self.assertFalse(L.valid_database_name(""))
|
||||
self.assertFalse(L.valid_database_name(None))
|
||||
|
||||
|
||||
class TestReadConfig(unittest.TestCase):
|
||||
def _write(self, body):
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".conf", delete=False, encoding="utf-8"
|
||||
)
|
||||
handle.write(body)
|
||||
handle.close()
|
||||
self.addCleanup(os.unlink, handle.name)
|
||||
return handle.name
|
||||
|
||||
def test_reads_the_options_section(self):
|
||||
path = self._write("[options]\ndb_user = erplibre\ndb_port = 5433\n")
|
||||
config = L.read_config(path)
|
||||
self.assertEqual(config["db_user"], "erplibre")
|
||||
self.assertEqual(config["db_port"], "5433")
|
||||
|
||||
def test_missing_file_is_not_an_error(self):
|
||||
# Sur une installation native, psql se connecte par le socket unix sans
|
||||
# aucun paramètre : l'absence de config est un cas normal.
|
||||
self.assertEqual(L.read_config("/nowhere/absent.conf"), {})
|
||||
|
||||
def test_file_without_options_section(self):
|
||||
path = self._write("[other]\nfoo = bar\n")
|
||||
self.assertEqual(L.read_config(path), {})
|
||||
|
||||
|
||||
class TestPgEnv(unittest.TestCase):
|
||||
def _write(self, body):
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".conf", delete=False, encoding="utf-8"
|
||||
)
|
||||
handle.write(body)
|
||||
handle.close()
|
||||
self.addCleanup(os.unlink, handle.name)
|
||||
return handle.name
|
||||
|
||||
def test_read_only_and_timeout_are_always_set(self):
|
||||
env = L.pg_env("/nowhere/absent.conf", timeout=42)
|
||||
self.assertIn("default_transaction_read_only=on", env["PGOPTIONS"])
|
||||
self.assertIn("statement_timeout=42s", env["PGOPTIONS"])
|
||||
|
||||
def test_psqlrc_is_neutralised(self):
|
||||
# Un ~/.psqlrc avec \timing ajoute des lignes à la sortie et casse le
|
||||
# parsing JSON.
|
||||
self.assertEqual(L.pg_env("/nowhere/absent.conf")["PSQLRC"], "")
|
||||
|
||||
def test_config_values_become_pg_variables(self):
|
||||
path = self._write(
|
||||
"[options]\ndb_host = pg.example.org\ndb_port = 5433\n"
|
||||
"db_user = erplibre\ndb_password = s3cret\ndb_sslmode = require\n"
|
||||
)
|
||||
env = L.pg_env(path)
|
||||
self.assertEqual(env["PGHOST"], "pg.example.org")
|
||||
self.assertEqual(env["PGPORT"], "5433")
|
||||
self.assertEqual(env["PGUSER"], "erplibre")
|
||||
self.assertEqual(env["PGPASSWORD"], "s3cret")
|
||||
self.assertEqual(env["PGSSLMODE"], "require")
|
||||
|
||||
def test_literal_false_means_unset(self):
|
||||
# Odoo écrit « False » dans config.conf pour dire « pas de valeur ».
|
||||
# L'exporter tel quel ferait chercher un hôte nommé « False ».
|
||||
path = self._write(
|
||||
"[options]\ndb_host = False\ndb_port = False\n"
|
||||
"db_password = False\ndb_user = erplibre\n"
|
||||
)
|
||||
env = L.pg_env(path)
|
||||
self.assertNotIn("PGHOST", env)
|
||||
self.assertNotIn("PGPORT", env)
|
||||
self.assertNotIn("PGPASSWORD", env)
|
||||
self.assertEqual(env["PGUSER"], "erplibre")
|
||||
|
||||
def test_overrides_win_over_the_config(self):
|
||||
path = self._write("[options]\ndb_host = pg.example.org\n")
|
||||
env = L.pg_env(path, overrides={"PGHOST": "127.0.0.1"})
|
||||
self.assertEqual(env["PGHOST"], "127.0.0.1")
|
||||
|
||||
|
||||
class TestQuoteLiteral(unittest.TestCase):
|
||||
def test_wraps_in_single_quotes(self):
|
||||
self.assertEqual(L.quote_literal("ir_ui_view"), "'ir_ui_view'")
|
||||
|
||||
def test_doubles_embedded_quotes(self):
|
||||
self.assertEqual(L.quote_literal("a'b"), "'a''b'")
|
||||
|
||||
|
||||
class TestTrCol(unittest.TestCase):
|
||||
"""Le fragment SQL d'un champ traduit, décidé sur le TYPE réel.
|
||||
|
||||
Pas sur un numéro de version : une base à moitié migrée porte les deux
|
||||
formes, et un numéro de version mentirait.
|
||||
"""
|
||||
|
||||
def test_jsonb_column_is_unpacked(self):
|
||||
# La forme >= 16.0 : {"en_US": "..."}
|
||||
got = L.tr_col("ir_ui_view", "arch_db", {"arch_db": "jsonb"})
|
||||
self.assertEqual(got, '"ir_ui_view"."arch_db"->>\'en_US\'')
|
||||
|
||||
def test_text_column_is_cast(self):
|
||||
# La forme <= 15.0 : du texte
|
||||
got = L.tr_col("ir_ui_view", "arch_db", {"arch_db": "text"})
|
||||
self.assertEqual(got, '"ir_ui_view"."arch_db"::text')
|
||||
|
||||
def test_character_varying_is_cast_too(self):
|
||||
got = L.tr_col("ir_model", "name", {"name": "character varying"})
|
||||
self.assertEqual(got, '"ir_model"."name"::text')
|
||||
|
||||
def test_unknown_column_yields_null_not_broken_sql(self):
|
||||
# Une colonne absente doit donner un champ vide, pas une requête qui
|
||||
# explose : c'est ce qui permet de sonder puis d'interroger d'un trait.
|
||||
self.assertEqual(L.tr_col("ir_ui_view", "absente", {}), "NULL::text")
|
||||
self.assertEqual(L.tr_col("ir_ui_view", "absente", None), "NULL::text")
|
||||
|
||||
def test_other_language(self):
|
||||
got = L.tr_col("ir_model", "name", {"name": "jsonb"}, lang="fr_CA")
|
||||
self.assertEqual(got, '"ir_model"."name"->>\'fr_CA\'')
|
||||
|
||||
|
||||
class TestModelTable(unittest.TestCase):
|
||||
def test_default_derivation(self):
|
||||
self.assertEqual(L.model_table("res.partner"), "res_partner")
|
||||
|
||||
def test_override_is_applied(self):
|
||||
# replace('.', '_') donnerait ir_actions_act_window, qui n'existe pas.
|
||||
self.assertEqual(
|
||||
L.model_table("ir.actions.act_window"), "ir_act_window"
|
||||
)
|
||||
|
||||
def test_unknown_table_returns_none_not_a_guess(self):
|
||||
# None veut dire « je ne sais pas », jamais « il n'y en a pas » : c'est
|
||||
# ce qui empêche de classer un modèle abstrait comme une anomalie.
|
||||
self.assertIsNone(
|
||||
L.model_table("mail.thread", known_tables={"res_partner"})
|
||||
)
|
||||
|
||||
def test_known_table_passes_through(self):
|
||||
self.assertEqual(
|
||||
L.model_table("res.partner", known_tables={"res_partner"}),
|
||||
"res_partner",
|
||||
)
|
||||
|
||||
|
||||
class TestModelTableOverrideIntegrity(unittest.TestCase):
|
||||
"""La table de surcharges ne doit pas accumuler d'entrées inutiles.
|
||||
|
||||
Beaucoup de modules déclarent un `_table` égal au défaut. Recopier une
|
||||
telle déclaration ici serait du poids mort qui donne l'illusion d'une
|
||||
surcharge — et c'est exactement la confusion qui a fait annoncer 22
|
||||
surcharges là où il y en a 11.
|
||||
"""
|
||||
|
||||
def test_no_entry_equals_the_default(self):
|
||||
for model, table in L.MODEL_TABLE_OVERRIDE.items():
|
||||
self.assertNotEqual(
|
||||
table,
|
||||
model.replace(".", "_"),
|
||||
f"'{model}' n'est pas une surcharge : son _table est le défaut",
|
||||
)
|
||||
|
||||
def test_tables_look_like_table_names(self):
|
||||
for model, table in L.MODEL_TABLE_OVERRIDE.items():
|
||||
self.assertRegex(table, r"^[a-z][a-z0-9_]*$", model)
|
||||
|
||||
|
||||
class TestJsonQuery(unittest.TestCase):
|
||||
"""L'enveloppe json_agg, sans base : on intercepte run_psql."""
|
||||
|
||||
def setUp(self):
|
||||
self.seen = []
|
||||
self.reply = "[]"
|
||||
self.original = L.run_psql
|
||||
|
||||
def fake_run_psql(database, sql, **kwargs):
|
||||
self.seen.append((database, sql))
|
||||
return self.reply
|
||||
|
||||
L.run_psql = fake_run_psql
|
||||
self.addCleanup(setattr, L, "run_psql", self.original)
|
||||
|
||||
def test_wraps_the_select(self):
|
||||
L.json_query("db", "SELECT id FROM ir_ui_view")
|
||||
_, sql = self.seen[0]
|
||||
self.assertIn("json_agg(row_to_json(t))", sql)
|
||||
self.assertIn("FROM (SELECT id FROM ir_ui_view) t", sql)
|
||||
|
||||
def test_strips_a_trailing_semicolon(self):
|
||||
# Un « ; » resté dans la sous-requête produirait du SQL invalide.
|
||||
L.json_query("db", "SELECT id FROM ir_ui_view;")
|
||||
_, sql = self.seen[0]
|
||||
self.assertNotIn(";) t", sql)
|
||||
|
||||
def test_empty_output_is_an_empty_list(self):
|
||||
self.reply = " \n"
|
||||
self.assertEqual(L.json_query("db", "SELECT 1"), [])
|
||||
|
||||
def test_pipes_and_newlines_survive(self):
|
||||
# La raison d'être de l'enveloppe : une arch XML contient des « | » et
|
||||
# des sauts de ligne, donc tout séparateur maison couperait au mauvais
|
||||
# endroit.
|
||||
self.reply = '[{"arch": "a | b\\nc"}]'
|
||||
rows = L.json_query("db", "SELECT 1")
|
||||
self.assertEqual(rows, [{"arch": "a | b\nc"}])
|
||||
|
||||
def test_unreadable_output_raises_analyse_error(self):
|
||||
self.reply = "ERREUR: quelque chose"
|
||||
with self.assertRaises(L.AnalyseError):
|
||||
L.json_query("db", "SELECT 1")
|
||||
|
||||
|
||||
class TestRunPsqlGuards(unittest.TestCase):
|
||||
"""`_current_lang` est un état de MODULE, pas un état de test.
|
||||
|
||||
Ce test passait seul et tombait dans la suite : un autre fichier avait
|
||||
laissé la langue en français, et le message n'est traduit que depuis
|
||||
qu'une traduction existe. Toute assertion sur un texte affiché doit donc
|
||||
fixer la langue, sinon elle dépend de l'ordre des tests.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||||
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||||
# travail et laisse la langue changée pour tout ce qui suit —
|
||||
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||||
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||||
# la mémoïsation directement, et on rend la valeur trouvée.
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def test_hostile_database_name_never_reaches_psql(self):
|
||||
with self.assertRaises(L.AnalyseError) as caught:
|
||||
L.run_psql("a; DROP DATABASE b", "SELECT 1;")
|
||||
self.assertIn("Invalid database name", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestCanonical(unittest.TestCase):
|
||||
"""Ce qui décide s'il y a un écart. Le bruit se joue ici."""
|
||||
|
||||
def test_indentation_is_not_a_change(self):
|
||||
# Le cas mesuré sur une vraie base : une arch ré-indentée n'est pas
|
||||
# une modification, et c'est l'erreur que fait `has_diff` d'Odoo —
|
||||
# une comparaison de chaînes brutes — dans son propre assistant.
|
||||
left = "<form><field name='a'/></form>"
|
||||
right = "<form>\n <field name='a'/>\n</form>"
|
||||
self.assertEqual(L.canonical(left), L.canonical(right))
|
||||
self.assertEqual(L.arch_differs(left, right), (False, True))
|
||||
|
||||
def test_attribute_order_is_not_a_change(self):
|
||||
self.assertEqual(
|
||||
L.canonical("<t a='1' b='2'/>"), L.canonical("<t b='2' a='1'/>")
|
||||
)
|
||||
|
||||
def test_comments_are_not_a_change(self):
|
||||
self.assertEqual(
|
||||
L.canonical("<form><!-- note --><field/></form>"),
|
||||
L.canonical("<form><field/></form>"),
|
||||
)
|
||||
|
||||
def test_an_added_field_is_a_change(self):
|
||||
left = "<form><field name='a'/></form>"
|
||||
right = "<form><field name='a'/><field name='x_custom'/></form>"
|
||||
self.assertEqual(L.arch_differs(left, right), (True, True))
|
||||
|
||||
def test_changed_text_is_a_change(self):
|
||||
# L'espace DANS un libellé est du contenu, pas de la mise en forme.
|
||||
self.assertEqual(
|
||||
L.arch_differs("<t>Total</t>", "<t>Grand total</t>"), (True, True)
|
||||
)
|
||||
|
||||
def test_xpath_expression_spacing_is_normalised(self):
|
||||
# L'espace y sépare des jetons : il se replie, il ne disparaît pas.
|
||||
self.assertEqual(
|
||||
L.canonical('<xpath expr="//div[1] /span"/>'),
|
||||
L.canonical('<xpath expr="//div[1] /span"/>'),
|
||||
)
|
||||
|
||||
def test_a_different_xpath_target_is_a_change(self):
|
||||
self.assertNotEqual(
|
||||
L.canonical('<xpath expr="//div/span"/>'),
|
||||
L.canonical('<xpath expr="//div[1]/span"/>'),
|
||||
)
|
||||
|
||||
def test_broken_xml_is_not_comparable(self):
|
||||
# Ni « identique » ni « différent » : une comparaison qui n'a pas eu
|
||||
# lieu ne doit pas se lire comme une comparaison sans écart.
|
||||
self.assertIsNone(L.canonical("<form><field></form>"))
|
||||
self.assertEqual(
|
||||
L.arch_differs("<form/>", "<form><field>"), (None, False)
|
||||
)
|
||||
|
||||
def test_empty_arch_is_not_comparable(self):
|
||||
self.assertIsNone(L.canonical(""))
|
||||
self.assertIsNone(L.canonical(None))
|
||||
|
||||
def test_jsonb_column_is_unwrapped_first(self):
|
||||
self.assertEqual(
|
||||
L.canonical('{"en_US": "<form/>"}'), L.canonical("<form/>")
|
||||
)
|
||||
|
||||
|
||||
class TestSideBySide(unittest.TestCase):
|
||||
def test_identical_lines_are_aligned(self):
|
||||
rows = L.side_by_side("a\nb", "a\nb")
|
||||
self.assertEqual([mark for mark, _, _ in rows], [" ", " "])
|
||||
|
||||
def test_insert_has_no_left_side(self):
|
||||
rows = L.side_by_side("a", "a\nb")
|
||||
self.assertEqual(rows[-1], ("+", None, "b"))
|
||||
|
||||
def test_delete_has_no_right_side(self):
|
||||
rows = L.side_by_side("a\nb", "a")
|
||||
self.assertEqual(rows[-1], ("-", "b", None))
|
||||
|
||||
def test_replace_keeps_both_sides_on_one_row(self):
|
||||
# Le point de l'affichage côte à côte : les deux versions d'une même
|
||||
# ligne se lisent l'une en face de l'autre, sans rien à synchroniser.
|
||||
rows = L.side_by_side("a", "b")
|
||||
self.assertEqual(rows, [("≠", "a", "b")])
|
||||
|
||||
def test_stats_count_each_kind(self):
|
||||
rows = L.side_by_side("a\nb\nc", "a\nB\nc\nd")
|
||||
self.assertEqual(
|
||||
L.diff_stats(rows), {"added": 1, "removed": 0, "changed": 1}
|
||||
)
|
||||
|
||||
|
||||
class TestUnescapeCopy(unittest.TestCase):
|
||||
r"""Les valeurs d'un bloc COPY d'un dump.sql.
|
||||
|
||||
C'est ce qui permet de lire une sauvegarde sans la restaurer. Une erreur
|
||||
ici corrompt silencieusement une valeur — un « \n » laissé littéral dans
|
||||
une aide de champ, un NULL pris pour la chaîne « \N ».
|
||||
"""
|
||||
|
||||
def test_backslash_n_is_null_not_a_string(self):
|
||||
self.assertIsNone(L.unescape_copy("\\N"))
|
||||
# Mais « \N » AU MILIEU d'une valeur reste du texte.
|
||||
self.assertEqual(L.unescape_copy("a\\Nb"), "aNb")
|
||||
|
||||
def test_plain_value_passes_through(self):
|
||||
self.assertEqual(L.unescape_copy("x_studio_code"), "x_studio_code")
|
||||
self.assertEqual(L.unescape_copy(""), "")
|
||||
|
||||
def test_newline_and_tab_are_restored(self):
|
||||
# Une aide de champ multi-lignes arrive échappée : la laisser telle
|
||||
# quelle mettrait « \n » littéral dans le rapport.
|
||||
self.assertEqual(L.unescape_copy("a\\nb"), "a\nb")
|
||||
self.assertEqual(L.unescape_copy("a\\tb"), "a\tb")
|
||||
self.assertEqual(L.unescape_copy("a\\rb"), "a\rb")
|
||||
|
||||
def test_escaped_backslash(self):
|
||||
self.assertEqual(L.unescape_copy("a\\\\b"), "a\\b")
|
||||
|
||||
def test_unknown_escape_keeps_the_character(self):
|
||||
self.assertEqual(L.unescape_copy("a\\qb"), "aqb")
|
||||
|
||||
def test_trailing_backslash_is_not_an_index_error(self):
|
||||
self.assertEqual(L.unescape_copy("a\\"), "a\\")
|
||||
|
||||
|
||||
class TestNotAColumn(unittest.TestCase):
|
||||
def test_constraint_lines_are_not_columns(self):
|
||||
# Un CREATE TABLE mêle colonnes et contraintes ; prendre le premier
|
||||
# mot d'une ligne CONSTRAINT donnerait une colonne qui n'existe pas,
|
||||
# et un champ x_ passerait pour ayant sa colonne.
|
||||
for word in ("CONSTRAINT", "PRIMARY", "CHECK", "FOREIGN", "UNIQUE"):
|
||||
self.assertTrue(f"{word} foo".upper().startswith(L.NOT_A_COLUMN))
|
||||
self.assertFalse(
|
||||
"name character varying".upper().startswith(L.NOT_A_COLUMN)
|
||||
)
|
||||
|
||||
|
||||
class TestBackupWithoutManifest(unittest.TestCase):
|
||||
"""Une sauvegarde odoo.sh n'a pas de manifest.json.
|
||||
|
||||
Refuser le fichier pour cela revenait à refuser d'analyser précisément
|
||||
les bases qu'on ne peut pas restaurer — ce qui est tout l'intérêt de lire
|
||||
un zip. Le manifeste ne portait qu'une commodité, la version, et la base
|
||||
la dit elle-même dans ir_module_module.
|
||||
"""
|
||||
|
||||
def _zip(self, entries):
|
||||
import zipfile
|
||||
|
||||
handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||
handle.close()
|
||||
self.addCleanup(os.unlink, handle.name)
|
||||
with zipfile.ZipFile(handle.name, "w") as archive:
|
||||
for name, body in entries.items():
|
||||
archive.writestr(name, body)
|
||||
return handle.name
|
||||
|
||||
DUMP = (
|
||||
"CREATE TABLE public.ir_module_module (\n"
|
||||
" id integer NOT NULL,\n"
|
||||
" name character varying\n"
|
||||
");\n"
|
||||
"COPY public.ir_module_module (id, name, latest_version) "
|
||||
"FROM stdin;\n"
|
||||
"1\tbase\tsaas~19.2.1.3\n"
|
||||
"\\.\n"
|
||||
)
|
||||
|
||||
def test_missing_manifest_is_not_an_error(self):
|
||||
path = self._zip({"dump.sql": self.DUMP})
|
||||
self.assertEqual(L.backup_manifest(path), {})
|
||||
|
||||
def test_unreadable_manifest_does_not_block_the_dump(self):
|
||||
path = self._zip({"dump.sql": self.DUMP, "manifest.json": "{ nope"})
|
||||
self.assertEqual(L.backup_manifest(path), {})
|
||||
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
|
||||
self.assertEqual(len(rows["ir_module_module"]), 1)
|
||||
|
||||
def test_a_missing_dump_is_still_refused(self):
|
||||
# Le dump est la seule pièce indispensable : c'est lui qui porte les
|
||||
# données. Son absence reste une vraie erreur.
|
||||
path = self._zip({"manifest.json": "{}"})
|
||||
with self.assertRaises(L.AnalyseError):
|
||||
L.read_backup(path, tables=("ir_model",))
|
||||
|
||||
def test_dump_is_found_under_a_directory(self):
|
||||
path = self._zip({"sauvegarde/dump.sql": self.DUMP})
|
||||
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
|
||||
self.assertEqual(len(rows["ir_module_module"]), 1)
|
||||
|
||||
def test_version_comes_from_the_dump(self):
|
||||
path = self._zip({"dump.sql": self.DUMP})
|
||||
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
|
||||
self.assertEqual(L.backup_version(rows), "saas~19.2.1.3")
|
||||
|
||||
def test_the_dump_wins_over_the_manifest(self):
|
||||
# Le manifeste est écrit à côté de la base ; la base, elle, se
|
||||
# décrit elle-même. En cas de désaccord, on croit la base.
|
||||
path = self._zip(
|
||||
{"dump.sql": self.DUMP, "manifest.json": '{"version": "12.0"}'}
|
||||
)
|
||||
manifest, rows, _, _ = L.read_backup(
|
||||
path, tables=("ir_module_module",)
|
||||
)
|
||||
self.assertEqual(manifest.get("version"), "12.0")
|
||||
self.assertEqual(L.backup_version(rows, manifest), "saas~19.2.1.3")
|
||||
|
||||
def test_manifest_is_the_fallback_when_the_dump_says_nothing(self):
|
||||
self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0")
|
||||
self.assertIsNone(L.backup_version({}, {}))
|
||||
225
test/test_analyse_schema_size.py
Normal file
225
test/test_analyse_schema_size.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Poids du schéma : ce qui se teste sans base.
|
||||
|
||||
Le classement d'une table (modèle / m2m / système / orpheline) vit dans
|
||||
`collect()`, qui interroge PostgreSQL. Il s'éprouve sur la base synthétique
|
||||
décrite dans le docstring de `test_analyse_lib.py`, augmentée des quatre cas
|
||||
qui comptent : une table m2m sans ligne `ir_model` mais listée dans
|
||||
`ir_model_relation`, un modèle à `_table` surchargé, un modèle abstrait, et
|
||||
une vraie orpheline. Attendu : une seule orpheline, la vraie.
|
||||
|
||||
Ici, les fonctions pures — mise en forme et rendu. `render()` est une
|
||||
fonction de la donnée vers le texte, donc tout le rapport se teste sur un
|
||||
dictionnaire écrit à la main.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from script.analyse import analyse_schema_size as A
|
||||
from script.todo import todo_i18n
|
||||
|
||||
|
||||
def fixture(**override):
|
||||
"""Un résultat de collect() minimal, que chaque test tord à sa guise."""
|
||||
data = {
|
||||
"tool": "analyse_schema_size",
|
||||
"version": 1,
|
||||
"database": "prod_18",
|
||||
"odoo_version": "18.0.1.3",
|
||||
"db_bytes": 12884901888,
|
||||
"exact": False,
|
||||
"has_relation_table": True,
|
||||
"n_tables": 3,
|
||||
"n_models": 4,
|
||||
"tables": [
|
||||
{
|
||||
"table_name": "mail_message",
|
||||
"total_bytes": 3221225472,
|
||||
"table_bytes": 2254857830,
|
||||
"index_bytes": 943718400,
|
||||
"est_rows": 4183221,
|
||||
"exact_rows": None,
|
||||
"model": "mail.message",
|
||||
"origin": "model",
|
||||
},
|
||||
{
|
||||
"table_name": "res_groups_users_rel",
|
||||
"total_bytes": 16384,
|
||||
"table_bytes": 8192,
|
||||
"index_bytes": 8192,
|
||||
"est_rows": -1,
|
||||
"exact_rows": None,
|
||||
"model": None,
|
||||
"origin": "m2m",
|
||||
},
|
||||
{
|
||||
"table_name": "old_module_thing",
|
||||
"total_bytes": 431916544,
|
||||
"table_bytes": 400000000,
|
||||
"index_bytes": 31916544,
|
||||
"est_rows": 90211,
|
||||
"exact_rows": None,
|
||||
"model": None,
|
||||
"origin": "orphan",
|
||||
},
|
||||
],
|
||||
"orphan_tables": [],
|
||||
"models_without_table": [],
|
||||
"counts": {
|
||||
"orphan_tables": 0,
|
||||
"models_without_table": 0,
|
||||
"m2m_tables": 1,
|
||||
},
|
||||
}
|
||||
data["orphan_tables"] = [
|
||||
r for r in data["tables"] if r["origin"] == "orphan"
|
||||
]
|
||||
data["counts"]["orphan_tables"] = len(data["orphan_tables"])
|
||||
data.update(override)
|
||||
return data
|
||||
|
||||
|
||||
class TestFmtBytes(unittest.TestCase):
|
||||
def test_zero_and_bytes_have_no_decimal(self):
|
||||
self.assertEqual(A.fmt_bytes(0), "0 B")
|
||||
self.assertEqual(A.fmt_bytes(512), "512 B")
|
||||
|
||||
def test_scales_to_binary_units(self):
|
||||
self.assertEqual(A.fmt_bytes(1024), "1.0 KiB")
|
||||
self.assertEqual(A.fmt_bytes(1024**2), "1.0 MiB")
|
||||
self.assertEqual(A.fmt_bytes(1024**3), "1.0 GiB")
|
||||
self.assertEqual(A.fmt_bytes(1024**4), "1.0 TiB")
|
||||
|
||||
def test_beyond_tebibyte_stays_in_tebibytes(self):
|
||||
self.assertEqual(A.fmt_bytes(5 * 1024**4), "5.0 TiB")
|
||||
|
||||
def test_none_is_a_question_mark(self):
|
||||
self.assertEqual(A.fmt_bytes(None), "?")
|
||||
|
||||
|
||||
class TestFmtRows(unittest.TestCase):
|
||||
def test_never_analyzed_is_not_zero(self):
|
||||
# PostgreSQL >= 14 met reltuples à -1 quand aucun ANALYZE n'a tourné.
|
||||
# Afficher « 0 » ferait passer une table pleine pour une table vide.
|
||||
self.assertEqual(A.fmt_rows(-1), "?")
|
||||
self.assertEqual(A.fmt_rows(None), "?")
|
||||
|
||||
def test_zero_stays_zero(self):
|
||||
# Une table réellement vide et analysée doit dire 0, pas « ? ».
|
||||
self.assertEqual(A.fmt_rows(0), "0")
|
||||
|
||||
def test_groups_thousands_with_spaces(self):
|
||||
self.assertEqual(A.fmt_rows(4183221), "4 183 221")
|
||||
|
||||
|
||||
class TestWrapNote(unittest.TestCase):
|
||||
def test_short_text_is_one_line(self):
|
||||
self.assertEqual(A.wrap_note(" ", "court"), [" court"])
|
||||
|
||||
def test_continuation_lines_align_under_the_first(self):
|
||||
lines = A.wrap_note(" 💡 ", "mot " * 40)
|
||||
self.assertGreater(len(lines), 1)
|
||||
self.assertTrue(lines[0].startswith(" 💡 "))
|
||||
for line in lines[1:]:
|
||||
self.assertTrue(line.startswith(" " * len(" 💡 ")), repr(line))
|
||||
|
||||
def test_respects_the_width(self):
|
||||
for line in A.wrap_note(" ", "mot " * 60, width=50):
|
||||
self.assertLessEqual(len(line), 50, repr(line))
|
||||
|
||||
def test_empty_text_does_not_crash(self):
|
||||
self.assertEqual(A.wrap_note(" ", ""), [" "])
|
||||
|
||||
|
||||
class TestRender(unittest.TestCase):
|
||||
"""Le rapport, sur une donnée écrite à la main.
|
||||
|
||||
`set_lang` est fixé : `_current_lang` est un état de module alimenté par
|
||||
une préférence utilisateur, donc sans cela le test dépendrait de la langue
|
||||
de la machine.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||||
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||||
# travail et laisse la langue changée pour tout ce qui suit —
|
||||
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||||
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||||
# la mémoïsation directement, et on rend la valeur trouvée.
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def test_reports_the_orphan_and_not_the_m2m(self):
|
||||
# Le faux positif à éviter : une table m2m n'a aucune ligne ir_model,
|
||||
# et sur une base ordinaire il y en a des centaines.
|
||||
out = A.render(fixture())
|
||||
self.assertIn("Orphan tables (1)", out)
|
||||
orphan_block = out.split("Orphan tables")[1]
|
||||
self.assertIn("old_module_thing", orphan_block)
|
||||
self.assertNotIn("res_groups_users_rel", orphan_block)
|
||||
|
||||
def test_no_orphan_says_so_plainly(self):
|
||||
data = fixture(orphan_tables=[])
|
||||
data["counts"]["orphan_tables"] = 0
|
||||
out = A.render(data)
|
||||
self.assertIn("Every table belongs to an installed model.", out)
|
||||
self.assertNotIn("Orphan tables", out)
|
||||
|
||||
def test_models_without_table_is_a_fact_not_a_finding(self):
|
||||
# Les modèles abstraits sont dans ir_model et n'ont pas de table : des
|
||||
# centaines sur une base ordinaire. La ligne doit l'expliquer.
|
||||
data = fixture(models_without_table=[{"model": "mail.thread"}])
|
||||
data["counts"]["models_without_table"] = 1
|
||||
out = A.render(data)
|
||||
self.assertIn("Models without table", out)
|
||||
self.assertIn("abstract models have none", out)
|
||||
|
||||
def test_models_without_table_line_is_absent_when_zero(self):
|
||||
self.assertNotIn("Models without table", A.render(fixture()))
|
||||
|
||||
def test_warns_when_m2m_cannot_be_told_apart(self):
|
||||
# Sans ir_model_relation, chaque table m2m passerait pour orpheline :
|
||||
# le rapport doit dire qu'il n'est pas fiable, pas se taire.
|
||||
out = A.render(fixture(has_relation_table=False))
|
||||
self.assertIn("ir_model_relation is absent", out)
|
||||
|
||||
def test_estimate_warning_only_without_exact(self):
|
||||
self.assertIn("estimates from the last ANALYZE", A.render(fixture()))
|
||||
self.assertNotIn(
|
||||
"estimates from the last ANALYZE",
|
||||
A.render(fixture(exact=True)),
|
||||
)
|
||||
|
||||
def test_top_limits_the_table_list(self):
|
||||
out = A.render(fixture(), top=1)
|
||||
self.assertIn("Heaviest tables (1/3)", out)
|
||||
self.assertIn("use -v to list them all", out)
|
||||
|
||||
def test_verbose_lists_everything(self):
|
||||
out = A.render(fixture(), verbose=True)
|
||||
self.assertIn("All tables, heaviest first", out)
|
||||
self.assertNotIn("use -v to list them all", out)
|
||||
|
||||
def test_never_analyzed_shows_a_question_mark(self):
|
||||
out = A.render(fixture(), verbose=True)
|
||||
row = [l for l in out.splitlines() if "res_groups_users_rel" in l][0]
|
||||
self.assertTrue(row.rstrip().endswith("?"), repr(row))
|
||||
|
||||
def test_unknown_odoo_version_does_not_crash(self):
|
||||
self.assertIn("Odoo ?", A.render(fixture(odoo_version=None)))
|
||||
|
||||
def test_french_differs_from_english(self):
|
||||
english = A.render(fixture())
|
||||
todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance
|
||||
french = A.render(fixture())
|
||||
self.assertIn("Tables orphelines", french)
|
||||
self.assertNotEqual(english, french)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
270
test/test_analyse_view_custom.py
Normal file
270
test/test_analyse_view_custom.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Vues personnalisées : le classement, sans base.
|
||||
|
||||
`classify()` est une fonction pure de la ligne SQL vers une catégorie. C'est
|
||||
là que se joue tout ce qui distingue un constat d'un faux positif, donc c'est
|
||||
là que porte l'essentiel de ces tests.
|
||||
|
||||
La collecte s'éprouve sur une base synthétique portant les sept catégories, y
|
||||
compris deux copies COW — l'une avec sa jumelle module, l'autre sans, qui est
|
||||
une page faite dans l'éditeur web. Attendu : 11 vues, 8 constats, et des
|
||||
comptes dont la somme fait exactement 11.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from script.analyse import analyse_view_custom as A
|
||||
from script.todo import todo_i18n
|
||||
|
||||
|
||||
def view(**override):
|
||||
"""Une ligne de vue telle que la rend la requête, tout à zéro."""
|
||||
row = {
|
||||
"id": 1,
|
||||
"name": "Une vue",
|
||||
"key": None,
|
||||
"arch_fs": None,
|
||||
"arch_updated": False,
|
||||
"noupdate": False,
|
||||
"has_arch_prev": False,
|
||||
"active": True,
|
||||
"website_id": None,
|
||||
"theme_template_id": None,
|
||||
"xmlid_modules": None,
|
||||
"xmlids": None,
|
||||
"has_module_twin": False,
|
||||
"arch_bytes": 100,
|
||||
}
|
||||
row.update(override)
|
||||
return row
|
||||
|
||||
|
||||
class TestClassify(unittest.TestCase):
|
||||
def test_plain_module_view(self):
|
||||
row = view(arch_fs="base/views/x.xml", xmlid_modules=["base"])
|
||||
self.assertEqual(A.classify(row)[0], "module_view")
|
||||
|
||||
def test_module_view_flagged_by_arch_updated(self):
|
||||
row = view(
|
||||
arch_fs="sale/views/y.xml",
|
||||
xmlid_modules=["sale"],
|
||||
arch_updated=True,
|
||||
)
|
||||
category, reasons = A.classify(row)
|
||||
self.assertEqual(category, "module_view_flagged")
|
||||
self.assertIn("arch_updated", reasons)
|
||||
|
||||
def test_noupdate_alone_is_not_a_finding(self):
|
||||
"""Le faux positif à ne pas réintroduire.
|
||||
|
||||
Toute vue déclarée dans un bloc <odoo noupdate="1"> porte ce drapeau —
|
||||
les données de mail, account et website en sont pleines — sans que
|
||||
personne n'y ait touché. La compter noierait la catégorie qui compte.
|
||||
"""
|
||||
row = view(
|
||||
arch_fs="mail/data/z.xml", xmlid_modules=["mail"], noupdate=True
|
||||
)
|
||||
category, reasons = A.classify(row)
|
||||
self.assertEqual(category, "module_view")
|
||||
self.assertNotIn(category, A.ACTIONABLE)
|
||||
# L'information n'est pas perdue pour autant.
|
||||
self.assertIn("noupdate", reasons)
|
||||
|
||||
def test_website_copy(self):
|
||||
row = view(key="website.layout", website_id=1, has_module_twin=True)
|
||||
category, reasons = A.classify(row)
|
||||
self.assertEqual(category, "website_cow_copy")
|
||||
self.assertNotIn("no_module_twin", reasons)
|
||||
|
||||
def test_website_copy_without_a_twin_is_a_page_from_the_editor(self):
|
||||
row = view(key="website.page_1", website_id=1, has_module_twin=False)
|
||||
category, reasons = A.classify(row)
|
||||
self.assertEqual(category, "website_cow_copy")
|
||||
self.assertIn("no_module_twin", reasons)
|
||||
|
||||
def test_studio(self):
|
||||
row = view(xmlid_modules=["studio_customization"])
|
||||
self.assertEqual(A.classify(row)[0], "studio")
|
||||
|
||||
def test_studio_seen_among_several_xmlids(self):
|
||||
"""Une vue peut porter plusieurs identifiants externes.
|
||||
|
||||
Une jointure plate n'en rendrait qu'un, choisi au hasard : Studio
|
||||
passerait inaperçu une fois sur deux. D'où l'agrégat côté SQL, dont
|
||||
ceci vérifie que le classement sait se servir.
|
||||
"""
|
||||
row = view(xmlid_modules=["aaa_module", "studio_customization"])
|
||||
self.assertEqual(A.classify(row)[0], "studio")
|
||||
|
||||
def test_imported_or_exported(self):
|
||||
for module in ("__export__", "__import__", "__custom__"):
|
||||
row = view(xmlid_modules=[module])
|
||||
self.assertEqual(
|
||||
A.classify(row)[0], "imported_or_exported", module
|
||||
)
|
||||
|
||||
def test_created_from_the_interface(self):
|
||||
self.assertEqual(A.classify(view())[0], "ui_created")
|
||||
|
||||
def test_theme(self):
|
||||
self.assertEqual(
|
||||
A.classify(view(theme_template_id=42))[0], "theme_installed"
|
||||
)
|
||||
|
||||
def test_precedence_website_beats_studio(self):
|
||||
# Une vue Studio copiée par le site web se regarde d'abord comme une
|
||||
# copie : c'est ce qui décide si elle survivra à la montée de version.
|
||||
row = view(
|
||||
website_id=1,
|
||||
has_module_twin=True,
|
||||
xmlid_modules=["studio_customization"],
|
||||
)
|
||||
self.assertEqual(A.classify(row)[0], "website_cow_copy")
|
||||
|
||||
def test_precedence_theme_beats_website(self):
|
||||
row = view(theme_template_id=7, website_id=1)
|
||||
self.assertEqual(A.classify(row)[0], "theme_installed")
|
||||
|
||||
def test_reasons_accumulate(self):
|
||||
row = view(
|
||||
arch_fs="x.xml",
|
||||
xmlid_modules=["base"],
|
||||
arch_updated=True,
|
||||
noupdate=True,
|
||||
has_arch_prev=True,
|
||||
active=False,
|
||||
)
|
||||
reasons = A.classify(row)[1]
|
||||
self.assertEqual(
|
||||
reasons, ["arch_updated", "noupdate", "has_arch_prev", "inactive"]
|
||||
)
|
||||
|
||||
def test_the_category_is_always_a_known_one(self):
|
||||
for row in (
|
||||
view(),
|
||||
view(theme_template_id=1),
|
||||
view(website_id=1),
|
||||
view(xmlid_modules=["base"], arch_fs="x.xml"),
|
||||
view(xmlid_modules=["studio_customization"]),
|
||||
):
|
||||
self.assertIn(A.classify(row)[0], A.CATEGORIES)
|
||||
|
||||
|
||||
class TestCategoryTables(unittest.TestCase):
|
||||
"""Les trois tables de catégories doivent rester d'accord entre elles."""
|
||||
|
||||
def test_every_category_has_a_label(self):
|
||||
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||||
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||||
# travail et laisse la langue changée pour tout ce qui suit —
|
||||
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||||
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||||
# la mémoïsation directement, et on rend la valeur trouvée.
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
for name in A.CATEGORIES:
|
||||
self.assertNotEqual(
|
||||
A.category_label(name),
|
||||
name,
|
||||
f"'{name}' n'a pas de libellé traduit",
|
||||
)
|
||||
|
||||
def test_actionable_is_a_subset_of_categories(self):
|
||||
self.assertEqual(set(A.ACTIONABLE) - set(A.CATEGORIES), set())
|
||||
|
||||
def test_plain_module_views_are_never_a_finding(self):
|
||||
self.assertNotIn("module_view", A.ACTIONABLE)
|
||||
|
||||
|
||||
class TestRender(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||||
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||||
# travail et laisse la langue changée pour tout ce qui suit —
|
||||
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||||
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||||
# la mémoïsation directement, et on rend la valeur trouvée.
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def data(self, **override):
|
||||
rows = [
|
||||
view(id=2, key="sale.view_order_form", arch_updated=True),
|
||||
view(id=5, key="website.layout", website_id=1),
|
||||
]
|
||||
for row in rows:
|
||||
row["category"], row["reason"] = A.classify(row)
|
||||
data = {
|
||||
"tool": "analyse_view_custom",
|
||||
"version": 1,
|
||||
"database": "prod_18",
|
||||
"odoo_version": "18.0.1.3",
|
||||
"has_website": True,
|
||||
"compared_with_module_source": False,
|
||||
"n_views": 40,
|
||||
"counts": {name: 0 for name in A.CATEGORIES},
|
||||
"findings": rows,
|
||||
}
|
||||
data["counts"]["website_cow_copy"] = 1
|
||||
data["counts"]["module_view"] = 38
|
||||
data["counts"]["ui_created"] = 1
|
||||
data.update(override)
|
||||
return data
|
||||
|
||||
def test_lists_the_findings(self):
|
||||
out = A.render(self.data())
|
||||
self.assertIn("sale.view_order_form", out)
|
||||
self.assertIn("website.layout", out)
|
||||
|
||||
def test_points_at_the_existing_cow_tools(self):
|
||||
# L'inventaire ne rejuge pas les copies : il renvoie vers les outils
|
||||
# qui tranchent, plutôt que de refaire leur travail à moitié.
|
||||
out = A.render(self.data())
|
||||
self.assertIn("check_cow_views.py", out)
|
||||
self.assertIn("reset_stale_cow_views.py", out)
|
||||
|
||||
def test_no_cow_note_without_cow_views(self):
|
||||
data = self.data()
|
||||
data["counts"]["website_cow_copy"] = 0
|
||||
self.assertNotIn("check_cow_views.py", A.render(data))
|
||||
|
||||
def test_says_the_flags_are_not_a_verdict(self):
|
||||
# Sans comparaison, « signalée » n'est pas « modifiée » : le rapport
|
||||
# doit le dire, sinon il promet plus qu'il ne sait.
|
||||
self.assertIn("Flags say a view was touched", A.render(self.data()))
|
||||
|
||||
def test_clean_database_says_so(self):
|
||||
data = self.data(findings=[])
|
||||
out = A.render(data)
|
||||
self.assertIn("Every view comes straight from a module.", out)
|
||||
self.assertNotIn("check_cow_views.py", out)
|
||||
|
||||
def test_top_truncates_and_says_how_many_are_hidden(self):
|
||||
out = A.render(self.data(), top=1)
|
||||
self.assertIn("more", out)
|
||||
|
||||
def test_verbose_shows_everything(self):
|
||||
self.assertNotIn("more", A.render(self.data(), verbose=True))
|
||||
|
||||
def test_category_filter(self):
|
||||
out = A.render(self.data(), category="website_cow_copy")
|
||||
self.assertIn("website.layout", out)
|
||||
self.assertNotIn("sale.view_order_form", out)
|
||||
|
||||
def test_french_differs(self):
|
||||
english = A.render(self.data())
|
||||
todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance
|
||||
french = A.render(self.data())
|
||||
self.assertIn("Copie de site web", french)
|
||||
self.assertNotEqual(english, french)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
199
test/test_todo_menu.py
Normal file
199
test/test_todo_menu.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Le menu Execute : les numéros affichés mènent-ils où ils le disent ?
|
||||
|
||||
Le menu est écrit deux fois — une f-string qui affiche « [7] … », et une
|
||||
chaîne d'`elif status == "7"` qui dispatche. Rien ne les reliait : insérer une
|
||||
entrée au milieu oblige à décaler les deux à la main, et une seule erreur
|
||||
envoie l'utilisateur dans le mauvais écran sans que rien ne proteste.
|
||||
|
||||
Ce test relit les deux et les apparie. Il ne juge pas le contenu du menu :
|
||||
ajouter, retirer ou réordonner reste libre, tant que l'affichage et le
|
||||
dispatch racontent la même histoire.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
TODO_PY = (
|
||||
Path(__file__).resolve().parent.parent / "script" / "todo" / "todo.py"
|
||||
)
|
||||
|
||||
# « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu.
|
||||
RE_SHOWN = re.compile(r'^\[(\d+)\] \{t\("([^"]+)"\)\}', re.M)
|
||||
# « elif status == "12": » suivi de « status = self.prompt_execute_deploy() »
|
||||
RE_DISPATCH = re.compile(
|
||||
r'elif status == "(\d+)":\s*\n\s*status = self\.(\w+)\(\)'
|
||||
)
|
||||
|
||||
|
||||
def prompt_execute_source():
|
||||
"""Le corps de prompt_execute(), affichage et dispatch compris."""
|
||||
source = TODO_PY.read_text(encoding="utf-8")
|
||||
start = source.index("def prompt_execute(self):")
|
||||
end = source.index("def prompt_install(self):", start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
class TestExecuteMenuNumbering(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.body = prompt_execute_source()
|
||||
# [0] Retour est traité par le « if status == "0" » qui précède la
|
||||
# chaîne d'elif : il s'affiche mais n'a pas de branche de dispatch.
|
||||
self.shown = [
|
||||
(int(num), label)
|
||||
for num, label in RE_SHOWN.findall(self.body)
|
||||
if num != "0"
|
||||
]
|
||||
self.dispatch = [
|
||||
(int(num), method)
|
||||
for num, method in RE_DISPATCH.findall(self.body)
|
||||
]
|
||||
|
||||
def test_the_menu_was_actually_parsed(self):
|
||||
# Si la forme du menu change, ce test doit tomber ici plutôt que de
|
||||
# déclarer « tout va bien » sur une liste vide.
|
||||
self.assertGreater(len(self.shown), 5)
|
||||
self.assertEqual(len(self.shown), len(self.dispatch))
|
||||
|
||||
def test_zero_is_handled_before_the_elif_chain(self):
|
||||
self.assertIn('if status == "0":', self.body)
|
||||
|
||||
def test_numbering_is_contiguous_from_one(self):
|
||||
numbers = [num for num, _ in self.shown]
|
||||
self.assertEqual(numbers, list(range(1, len(numbers) + 1)))
|
||||
|
||||
def test_every_shown_entry_has_the_matching_dispatch(self):
|
||||
self.assertEqual(
|
||||
[num for num, _ in self.shown],
|
||||
[num for num, _ in self.dispatch],
|
||||
)
|
||||
|
||||
def test_no_dispatch_branch_is_unreachable(self):
|
||||
shown = {num for num, _ in self.shown}
|
||||
for num, method in self.dispatch:
|
||||
self.assertIn(
|
||||
num,
|
||||
shown,
|
||||
f"la branche [{num}] -> {method} n'est affichée nulle part",
|
||||
)
|
||||
|
||||
# Chaque entrée du menu et la méthode qu'elle DOIT atteindre, par le début
|
||||
# de son libellé. Sans cette table, le test ne vérifie que l'alignement des
|
||||
# numéros — et laisse passer le défaut même qu'une renumérotation produit :
|
||||
# une entrée qui garde son rang mais atterrit dans le mauvais écran.
|
||||
#
|
||||
# Une renumérotation, l'opération risquée, ne touche PAS cette table. Ajouter
|
||||
# ou retirer une entrée demande d'y toucher, et c'est voulu : c'est le seul
|
||||
# moment où quelqu'un doit dire où mène la nouvelle entrée.
|
||||
EXPECTED = {
|
||||
"Code": "prompt_execute_code",
|
||||
"Config": "prompt_execute_config",
|
||||
"Run": "prompt_execute_instance",
|
||||
"Test": "prompt_execute_test",
|
||||
"Process": "prompt_execute_process",
|
||||
"Database": "prompt_execute_database",
|
||||
"Analyse": "prompt_execute_analyse",
|
||||
"Git": "prompt_execute_git",
|
||||
"Doc": "prompt_execute_doc",
|
||||
"GPT code": "prompt_execute_gpt_code",
|
||||
"Automation": "prompt_execute_function",
|
||||
"Deploy": "prompt_execute_deploy",
|
||||
"Network": "prompt_execute_network",
|
||||
"Security": "prompt_execute_security",
|
||||
"Language": "_change_language",
|
||||
}
|
||||
|
||||
def _entry_key(self, label):
|
||||
"""« Doc - Documentation search » -> « Doc »."""
|
||||
return label.split(" - ", 1)[0].strip()
|
||||
|
||||
def test_every_entry_reaches_the_method_it_names(self):
|
||||
dct_dispatch = dict(self.dispatch)
|
||||
for num, label in self.shown:
|
||||
key = self._entry_key(label)
|
||||
self.assertIn(
|
||||
key,
|
||||
self.EXPECTED,
|
||||
f"entrée [{num}] « {label} » absente de EXPECTED :"
|
||||
" déclarez où elle mène",
|
||||
)
|
||||
self.assertEqual(
|
||||
dct_dispatch.get(num),
|
||||
self.EXPECTED[key],
|
||||
f"[{num}] « {label} » mène à"
|
||||
f" {dct_dispatch.get(num)} au lieu de {self.EXPECTED[key]}",
|
||||
)
|
||||
|
||||
def test_expected_table_has_no_stale_entry(self):
|
||||
# Une entrée retirée du menu doit sortir d'EXPECTED, sinon la table
|
||||
# devient un cimetière qui ne protège plus rien.
|
||||
shown_keys = {self._entry_key(label) for _, label in self.shown}
|
||||
self.assertEqual(set(self.EXPECTED) - shown_keys, set())
|
||||
|
||||
|
||||
class TestMenuLabels(unittest.TestCase):
|
||||
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
|
||||
|
||||
Sans elle, `_menu_header` n'affiche pas le segment et
|
||||
`todo_telemetry.build_code_tree` traite le menu comme une COMMANDE :
|
||||
il apparaît en feuille, sous son nom de méthode brut. Trois menus en
|
||||
souffrent déjà — la liste est figée ici pour que le nombre ne grandisse
|
||||
pas, pas pour bénir ce qu'elle contient.
|
||||
"""
|
||||
|
||||
KNOWN_MISSING = {
|
||||
"prompt_execute_test",
|
||||
"prompt_execute_network",
|
||||
"prompt_execute_security",
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
source = TODO_PY.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
cls = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef))
|
||||
self.labels = set()
|
||||
for node in cls.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not any(
|
||||
isinstance(tg, ast.Name) and tg.id == "_MENU_LABELS"
|
||||
for tg in node.targets
|
||||
):
|
||||
continue
|
||||
self.labels = {
|
||||
key.value
|
||||
for key in node.value.keys
|
||||
if isinstance(key, ast.Constant)
|
||||
}
|
||||
self.dispatched = {
|
||||
method
|
||||
for _, method in RE_DISPATCH.findall(prompt_execute_source())
|
||||
}
|
||||
|
||||
def test_menu_labels_was_parsed(self):
|
||||
self.assertIn("prompt_execute", self.labels)
|
||||
|
||||
def test_analyse_menu_has_a_breadcrumb_label(self):
|
||||
self.assertIn("prompt_execute_analyse", self.labels)
|
||||
|
||||
def test_no_new_menu_forgets_its_label(self):
|
||||
submenus = {
|
||||
method
|
||||
for method in self.dispatched
|
||||
if method.startswith("prompt_execute_")
|
||||
}
|
||||
missing = submenus - self.labels - self.KNOWN_MISSING
|
||||
self.assertEqual(
|
||||
missing,
|
||||
set(),
|
||||
f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue