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