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
572 lines
20 KiB
Python
572 lines
20 KiB
Python
#!/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())
|