erplibre/script/analyse/analyse_custom_field.py

573 lines
20 KiB
Python
Raw Normal View History

#!/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())