[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither: it loses its column and keeps its existence. Read as a loss it cries wolf every step; ignored it would mask a real one. OpenUpgrade already knows, in the upgrade_analysis.txt files the checkout carries -- the same source the OCA coverage pages are generated from, read locally, at the checkout's exact version. Three categories keep the signal from drowning: a field whose model went away is not a second finding (544 of them on one real step), a field of a module OpenUpgrade does not analyse was never its business, and the rest is grouped by field name -- __last_update counted itself 391 times. --- FR --- Le rapport comptait des + et des -. Un champ qui cesse d'être stocké n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu comme une perte, il alarme à chaque palier ; ignoré, il en masquerait une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que porte le checkout -- la source des pages de couverture de l'OCA, lue localement, à la version exacte. Trois catégories évitent que le signal se noie : un champ dont le modèle a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le reste est groupé par nom -- __last_update se comptait 391 fois. Assisted-by: Claude Opus 5
This commit is contained in:
parent
8c7ff096b3
commit
ff8b6bcaf9
5 changed files with 892 additions and 2 deletions
|
|
@ -218,6 +218,21 @@ def inspect(database):
|
|||
"SELECT model || '.' || name FROM ir_model_fields ORDER BY 1",
|
||||
)
|
||||
etat["field"] = sorted(ligne[0] for ligne in champs or [])
|
||||
# À quel module appartient chaque champ. Sans cela, un champ d'un
|
||||
# module OCA compterait comme « non déclaré par OpenUpgrade » —
|
||||
# littéralement vrai, et trompeur : OpenUpgrade n'analyse que le cœur
|
||||
# d'Odoo, il ne pouvait rien en dire.
|
||||
origines = run_psql(
|
||||
database,
|
||||
"SELECT f.model || '.' || f.name, d.module FROM ir_model_fields f"
|
||||
" JOIN ir_model_data d ON d.model = 'ir.model.fields'"
|
||||
" AND d.res_id = f.id",
|
||||
)
|
||||
dct_origine = {}
|
||||
for ligne in origines or []:
|
||||
if len(ligne) >= 2:
|
||||
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
|
||||
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
|
||||
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
|
||||
# c'est par elle qu'on les retrouve d'une version à l'autre.
|
||||
copies = run_psql(
|
||||
|
|
@ -577,6 +592,91 @@ def as_version(etat):
|
|||
return None
|
||||
|
||||
|
||||
_CACHE_DECLARE = {}
|
||||
|
||||
|
||||
def declared_index(version):
|
||||
"""L'index OpenUpgrade du palier, mis en cache par version.
|
||||
|
||||
Lire 402 fichiers coûte 0,04 s — négligeable une fois, pas six fois
|
||||
par rapport quand on compare tous les paliers d'affilée.
|
||||
"""
|
||||
if version not in _CACHE_DECLARE:
|
||||
from script.analyse import openupgrade_analysis
|
||||
|
||||
_CACHE_DECLARE[version] = openupgrade_analysis.load(version)
|
||||
return _CACHE_DECLARE[version]
|
||||
|
||||
|
||||
def overlay_declared(
|
||||
version, modeles_perdus, champs_perdus, origine_champ=None
|
||||
):
|
||||
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
|
||||
|
||||
Une perte déclarée par OpenUpgrade n'est pas un incident, c'est le
|
||||
palier qui fait son travail. Les séparer transforme « 300 champs
|
||||
disparus » — un chiffre devant lequel on ne peut rien faire — en une
|
||||
poignée de champs que personne n'a annoncés, et qu'il faut regarder.
|
||||
|
||||
Le cas qui justifie tout : « devenu calculé ». Le champ EXISTE
|
||||
toujours ; il n'a plus de colonne. Ni gain ni perte : transformation.
|
||||
"""
|
||||
from script.analyse import openupgrade_analysis
|
||||
|
||||
if not version:
|
||||
return {"available": False, "reason": "version"}
|
||||
index = declared_index(version)
|
||||
if not index["modules"]:
|
||||
return {"available": False, "reason": "missing"}
|
||||
|
||||
modeles = {"obsolete": [], "renamed": [], "undeclared": []}
|
||||
for nom in modeles_perdus:
|
||||
change = openupgrade_analysis.model_change(nom, index)
|
||||
if not change:
|
||||
modeles["undeclared"].append(nom)
|
||||
elif change[0] == "renamed":
|
||||
modeles["renamed"].append((nom, change[1]))
|
||||
else:
|
||||
modeles["obsolete"].append(nom)
|
||||
|
||||
champs = {
|
||||
"del": [],
|
||||
"unstored": [],
|
||||
"company_dependent": [],
|
||||
"moved": [],
|
||||
"model_gone": [],
|
||||
"not_analysed": [],
|
||||
"undeclared": [],
|
||||
}
|
||||
partis = tuple(f"{nom}." for nom in modeles_perdus)
|
||||
analyses = openupgrade_analysis.analysed_modules(version)
|
||||
for cle in champs_perdus:
|
||||
change = openupgrade_analysis.field_change(cle, index)
|
||||
if change:
|
||||
if change[0] == "moved":
|
||||
champs["moved"].append((cle, change[1]))
|
||||
else:
|
||||
champs[change[0]].append(cle)
|
||||
continue
|
||||
# Un champ dont le MODÈLE a disparu n'est pas une trouvaille de
|
||||
# plus : c'est la même, comptée une fois par champ. Sur un palier
|
||||
# réel cela triplait la liste et noyait le vrai signal.
|
||||
if partis and cle.startswith(partis):
|
||||
champs["model_gone"].append(cle)
|
||||
continue
|
||||
origines = set((origine_champ or {}).get(cle) or [])
|
||||
if origines and not (origines & analyses):
|
||||
champs["not_analysed"].append(cle)
|
||||
continue
|
||||
champs["undeclared"].append(cle)
|
||||
return {
|
||||
"available": True,
|
||||
"modules": index["modules"],
|
||||
"models": modeles,
|
||||
"fields": champs,
|
||||
}
|
||||
|
||||
|
||||
def explain_loss(table, version):
|
||||
"""Ce qu'Odoo a fait de cette table à cette version, ou None.
|
||||
|
||||
|
|
@ -635,14 +735,22 @@ def compare(avant, apres):
|
|||
# Les plus grosses pertes EN TÊTE : on attaque une liste de
|
||||
# cinquante-sept par le haut, pas par ordre alphabétique.
|
||||
lignes_perdues.sort(key=lambda item: -(item[1] - item[2]))
|
||||
champs_perdus = sorted(champ_avant - champ_apres)
|
||||
modeles_perdus = sorted(mod_avant - mod_apres)
|
||||
return {
|
||||
"fields_lost": sorted(champ_avant - champ_apres),
|
||||
"declared": overlay_declared(
|
||||
version,
|
||||
modeles_perdus,
|
||||
champs_perdus,
|
||||
avant.get("field_module"),
|
||||
),
|
||||
"fields_lost": champs_perdus,
|
||||
"fields_gained": sorted(champ_apres - champ_avant),
|
||||
"cow_lost": sorted(cow_avant - cow_apres),
|
||||
"cow_gained": sorted(cow_apres - cow_avant),
|
||||
"modules_lost": sorted(inst_avant - inst_apres),
|
||||
"modules_gained": sorted(inst_apres - inst_avant),
|
||||
"models_lost": sorted(mod_avant - mod_apres),
|
||||
"models_lost": modeles_perdus,
|
||||
"models_gained": sorted(mod_apres - mod_avant),
|
||||
"rows_lost": lignes_perdues,
|
||||
"rows_gained": lignes_gagnees,
|
||||
|
|
@ -735,6 +843,115 @@ def overall(lst_snapshot):
|
|||
return compare(presents[0], presents[-1])
|
||||
|
||||
|
||||
DECLARE_MODELES = (
|
||||
("obsolete", "declared obsolete", "dim"),
|
||||
("renamed", "renamed by Odoo", "dim"),
|
||||
("undeclared", "NOT declared by OpenUpgrade", "warn"),
|
||||
)
|
||||
|
||||
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
|
||||
# regarder finit la liste, donc reste sous les yeux.
|
||||
DECLARE_CHAMPS = (
|
||||
("del", "declared removed", "dim"),
|
||||
("moved", "moved to another module", "dim"),
|
||||
("company_dependent", "became a per-company jsonb column", "dim"),
|
||||
(
|
||||
"unstored",
|
||||
"computed now — the field remains, the column does not",
|
||||
"ok",
|
||||
),
|
||||
("model_gone", "their model went away too", "dim"),
|
||||
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
|
||||
("undeclared", "NOT declared by OpenUpgrade", "warn"),
|
||||
)
|
||||
|
||||
|
||||
def group_by_field_name(cles):
|
||||
"""[(nom, combien, exemple)] par nom de champ, les plus nombreux d'abord.
|
||||
|
||||
Un champ retiré d'un MIXIN disparaît de tous les modèles qui en
|
||||
héritent : `__last_update` s'est ainsi compté 580 fois au palier 17.
|
||||
C'est UN changement. Listé modèle par modèle, il remplissait l'écran
|
||||
et cachait les vingt autres ; groupé, il tient sur une ligne.
|
||||
"""
|
||||
par_nom = {}
|
||||
for cle in cles:
|
||||
nom = cle.rsplit(".", 1)[-1] if "." in cle else cle
|
||||
par_nom.setdefault(nom, []).append(cle)
|
||||
return sorted(
|
||||
((nom, len(lst), sorted(lst)[0]) for nom, lst in par_nom.items()),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
|
||||
|
||||
def render_declared(declare, colour, limit=8):
|
||||
"""Le théorique posé sur le pratique. Rien si on ne peut pas le lire.
|
||||
|
||||
Se taire quand l'analyse manque plutôt que d'afficher des zéros :
|
||||
« 0 déclaré » et « analyse introuvable » se ressemblent à l'œil et ne
|
||||
veulent pas du tout dire la même chose.
|
||||
"""
|
||||
from script.todo.migration_status import paint
|
||||
|
||||
if not declare.get("available"):
|
||||
if declare.get("reason") == "missing":
|
||||
return [
|
||||
f" {paint('⇄', 'dim', colour)}"
|
||||
f" {t('No OpenUpgrade analysis for this step.')}"
|
||||
]
|
||||
return []
|
||||
lignes = [
|
||||
f" {paint('⇄', 'dim', colour)} {t('Against OpenUpgrade')}"
|
||||
f" ({declare['modules']} {t('core module(s) analysed')})"
|
||||
]
|
||||
for titre, groupes, table in (
|
||||
(t("model(s) lost"), declare["models"], DECLARE_MODELES),
|
||||
(t("field(s) lost"), declare["fields"], DECLARE_CHAMPS),
|
||||
):
|
||||
total = sum(len(groupes.get(cle) or []) for cle, _l, _c in table)
|
||||
if not total:
|
||||
continue
|
||||
lignes.append(f" {total} {titre} :")
|
||||
for cle, libelle, teinte in table:
|
||||
trouves = groupes.get(cle) or []
|
||||
if not trouves:
|
||||
continue
|
||||
lignes.append(
|
||||
f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
|
||||
f" {t(libelle)}"
|
||||
)
|
||||
if cle not in ("undeclared", "unstored"):
|
||||
# Ces deux-là seuls méritent des noms : l'un parce qu'il
|
||||
# faut aller voir, l'autre parce qu'on croirait à une
|
||||
# perte. Nommer les autres noierait le rapport.
|
||||
continue
|
||||
plats = [
|
||||
element[0] if isinstance(element, tuple) else element
|
||||
for element in trouves
|
||||
]
|
||||
if table is DECLARE_CHAMPS:
|
||||
groupes_nom = group_by_field_name(plats)
|
||||
for nom, combien, exemple in groupes_nom[:limit]:
|
||||
suffixe = (
|
||||
f" × {combien} {t('model(s)')}" if combien > 1 else ""
|
||||
)
|
||||
montre = exemple if combien == 1 else nom
|
||||
lignes.append(f" {montre}{suffixe}")
|
||||
if len(groupes_nom) > limit:
|
||||
lignes.append(
|
||||
f" … {len(groupes_nom) - limit}"
|
||||
f" {t('other field name(s)')}"
|
||||
)
|
||||
else:
|
||||
for nom in plats[:limit]:
|
||||
lignes.append(f" {nom}")
|
||||
if len(plats) > limit:
|
||||
lignes.append(
|
||||
f" … {len(plats) - limit} {t('more')}"
|
||||
)
|
||||
return lignes
|
||||
|
||||
|
||||
def render_text(lst_snapshot, colour=None, limit=8):
|
||||
"""Le rapport complet. C'est aussi le repli du plein écran."""
|
||||
from script.todo.migration_status import paint, supports_colour
|
||||
|
|
@ -868,6 +1085,7 @@ def render_compare(diff, colour, limit=8):
|
|||
lignes.append(f" {connu['why']}")
|
||||
if len(connues) > limit:
|
||||
lignes.append(f" … {len(connues) - limit} {t('more')}")
|
||||
lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))
|
||||
delta = diff["delta"]
|
||||
lignes.append(
|
||||
" "
|
||||
|
|
|
|||
267
script/analyse/openupgrade_analysis.py
Normal file
267
script/analyse/openupgrade_analysis.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Ce qu'OpenUpgrade DÉCLARE changer, pour l'opposer à ce qui a changé.
|
||||
|
||||
Le rapport de qualité comptait des « + » et des « − ». C'est vrai mais
|
||||
insuffisant : un champ qui cesse d'être stocké disparaît de la base sans
|
||||
rien perdre — il est calculé maintenant. Compté comme une perte, il
|
||||
alarme ; ignoré, il masquerait une vraie perte. Il faut un troisième
|
||||
état, et OpenUpgrade le connaît déjà.
|
||||
|
||||
Chaque module cœur d'Odoo porte, dans OpenUpgrade, un fichier
|
||||
`upgrade_analysis.txt` qui liste ce que le palier change :
|
||||
|
||||
---Models in module 'account'---
|
||||
obsolete model account.unreconcile [transient]
|
||||
new model product.combo (renamed from pos.combo in module point_of_sale)
|
||||
---Fields in module 'account'---
|
||||
account / account.account / code (char) : not stored anymore
|
||||
account / account.cash.rounding / loss_account_id (many2one) : needs
|
||||
conversion to v18-style company dependent
|
||||
|
||||
C'est la même information que la page « coverage analysis » publiée par
|
||||
l'OCA — elle est engendrée depuis ces fichiers — mais lue dans le dépôt,
|
||||
donc sans réseau, et à la version exacte du checkout.
|
||||
|
||||
Ce qu'on n'y trouvera pas
|
||||
-------------------------
|
||||
Ces fichiers ne couvrent que les modules CŒUR d'Odoo. Un champ d'un
|
||||
module OCA ou maison n'y sera jamais, et son absence ne veut donc pas
|
||||
dire « perte non déclarée » au même titre. Le rapport le dit plutôt que
|
||||
de laisser conclure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
REPO_ROOT = os.path.normpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
)
|
||||
|
||||
# `new model x.y [transient]` ou `new model x.y (renamed from a.b in module m)`
|
||||
MODELE_NEUF = re.compile(
|
||||
r"^new model (\S+)(?:\s+\(renamed from (\S+) in module (\S+)\))?"
|
||||
)
|
||||
MODELE_OBSOLETE = re.compile(r"^obsolete model (\S+)")
|
||||
# `module / model / champ (type) : description` — le type est absent sur
|
||||
# les pseudo-champs comme `_order`, et le « : » n'est pas toujours espacé.
|
||||
CHAMP = re.compile(
|
||||
r"^(\S+)\s+/\s+(\S+)\s+/\s+(\S+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$"
|
||||
)
|
||||
# `module is now 'sale' ('point_of_sale')`
|
||||
DEPLACE = re.compile(r"module is now '([^']+)'")
|
||||
|
||||
SECTIONS = {
|
||||
"Models": "model",
|
||||
"Fields": "field",
|
||||
"XML records": "xml",
|
||||
}
|
||||
|
||||
|
||||
def scripts_dir(version, root=None):
|
||||
"""Où vivent les analyses pour ce palier d'ARRIVÉE."""
|
||||
return os.path.join(
|
||||
root or REPO_ROOT,
|
||||
f"odoo{version}.0",
|
||||
"OCA_OpenUpgrade",
|
||||
"openupgrade_scripts",
|
||||
"scripts",
|
||||
)
|
||||
|
||||
|
||||
def analysis_files(version, root=None):
|
||||
"""Les `upgrade_analysis.txt` de ce palier, triés.
|
||||
|
||||
On filtre sur le dossier de version : à côté des `18.0.1.0` vivent un
|
||||
`tests` et, en 14, un `0.0` — les lire mélangerait les paliers.
|
||||
"""
|
||||
base = scripts_dir(version, root)
|
||||
trouves = []
|
||||
if not os.path.isdir(base):
|
||||
return trouves
|
||||
prefixe = f"{version}."
|
||||
for module in sorted(os.listdir(base)):
|
||||
dossier = os.path.join(base, module)
|
||||
if not os.path.isdir(dossier):
|
||||
continue
|
||||
for release in sorted(os.listdir(dossier)):
|
||||
if not release.startswith(prefixe):
|
||||
continue
|
||||
chemin = os.path.join(dossier, release, "upgrade_analysis.txt")
|
||||
if os.path.isfile(chemin):
|
||||
trouves.append(chemin)
|
||||
return trouves
|
||||
|
||||
|
||||
_CACHE_MODULES = {}
|
||||
|
||||
|
||||
def analysed_modules(version, root=None):
|
||||
"""Les modules cœur qu'OpenUpgrade analyse à ce palier.
|
||||
|
||||
Les noms de dossiers SONT la liste. Elle sert à distinguer « aucune
|
||||
déclaration » de « hors de son champ » : OpenUpgrade ne dit rien d'un
|
||||
module OCA, et le compter comme non déclaré accuserait à tort.
|
||||
"""
|
||||
if version in _CACHE_MODULES:
|
||||
return _CACHE_MODULES[version]
|
||||
base = scripts_dir(version, root)
|
||||
trouves = set()
|
||||
if os.path.isdir(base):
|
||||
trouves = {
|
||||
nom
|
||||
for nom in os.listdir(base)
|
||||
if os.path.isdir(os.path.join(base, nom))
|
||||
}
|
||||
if root is None:
|
||||
_CACHE_MODULES[version] = trouves
|
||||
return trouves
|
||||
|
||||
|
||||
def blank_index():
|
||||
return {
|
||||
"models_new": set(),
|
||||
"models_obsolete": set(),
|
||||
"models_renamed": {},
|
||||
"fields_new": set(),
|
||||
"fields_del": set(),
|
||||
"fields_unstored": set(),
|
||||
"fields_stored": set(),
|
||||
"fields_moved": {},
|
||||
"fields_company_dependent": set(),
|
||||
"fields_other": {},
|
||||
"xml_new": 0,
|
||||
"xml_del": 0,
|
||||
"modules": 0,
|
||||
}
|
||||
|
||||
|
||||
def classify(description):
|
||||
"""La catégorie d'un changement de champ, d'après sa description.
|
||||
|
||||
L'ordre compte : « NEW relation: … » commence par NEW, et
|
||||
« not stored anymore » contient « stored ». On teste donc du plus
|
||||
précis au plus général plutôt que par appartenance de sous-chaîne.
|
||||
"""
|
||||
texte = description.strip()
|
||||
if texte.startswith("NEW"):
|
||||
return "new"
|
||||
if texte.startswith("DEL"):
|
||||
return "del"
|
||||
if texte.startswith("not stored anymore"):
|
||||
return "unstored"
|
||||
if texte.startswith("is now stored"):
|
||||
return "stored"
|
||||
if "needs conversion to" in texte and "company dependent" in texte:
|
||||
return "company_dependent"
|
||||
if DEPLACE.search(texte):
|
||||
return "moved"
|
||||
return "other"
|
||||
|
||||
|
||||
def parse(texte, index=None):
|
||||
"""Lire UN fichier d'analyse dans l'index (créé au besoin)."""
|
||||
index = blank_index() if index is None else index
|
||||
section = None
|
||||
for ligne in texte.splitlines():
|
||||
entete = re.match(r"^---(.+?) in module '([^']+)'---", ligne)
|
||||
if entete:
|
||||
section = SECTIONS.get(entete.group(1))
|
||||
continue
|
||||
if not ligne.strip():
|
||||
continue
|
||||
if section == "model":
|
||||
_read_model(ligne, index)
|
||||
elif section == "field":
|
||||
_read_field(ligne, index)
|
||||
elif section == "xml":
|
||||
if ligne.startswith("NEW"):
|
||||
index["xml_new"] += 1
|
||||
elif ligne.startswith("DEL"):
|
||||
index["xml_del"] += 1
|
||||
return index
|
||||
|
||||
|
||||
def _read_model(ligne, index):
|
||||
trouve = MODELE_NEUF.match(ligne)
|
||||
if trouve:
|
||||
index["models_new"].add(trouve.group(1))
|
||||
if trouve.group(2):
|
||||
# Le renommage se lit du côté du NOUVEAU modèle : c'est là
|
||||
# qu'OpenUpgrade note d'où il vient. L'ancien n'apparaît nulle
|
||||
# part comme « obsolete », donc sans cette ligne sa
|
||||
# disparition passerait pour une perte sèche.
|
||||
index["models_renamed"][trouve.group(2)] = trouve.group(1)
|
||||
return
|
||||
trouve = MODELE_OBSOLETE.match(ligne)
|
||||
if trouve:
|
||||
index["models_obsolete"].add(trouve.group(1))
|
||||
|
||||
|
||||
def _read_field(ligne, index):
|
||||
trouve = CHAMP.match(ligne)
|
||||
if not trouve:
|
||||
return
|
||||
_module, modele, champ, _type, description = trouve.groups()
|
||||
if champ.startswith("_"):
|
||||
# `_order`, `_sql_constraints` : ce ne sont pas des champs, et les
|
||||
# compter fausserait le rapprochement avec `ir_model_fields`.
|
||||
return
|
||||
cle = f"{modele}.{champ}"
|
||||
genre = classify(description)
|
||||
if genre == "new":
|
||||
index["fields_new"].add(cle)
|
||||
elif genre == "del":
|
||||
index["fields_del"].add(cle)
|
||||
elif genre == "unstored":
|
||||
index["fields_unstored"].add(cle)
|
||||
elif genre == "stored":
|
||||
index["fields_stored"].add(cle)
|
||||
elif genre == "company_dependent":
|
||||
index["fields_company_dependent"].add(cle)
|
||||
elif genre == "moved":
|
||||
index["fields_moved"][cle] = DEPLACE.search(description).group(1)
|
||||
else:
|
||||
index["fields_other"].setdefault(cle, []).append(description.strip())
|
||||
|
||||
|
||||
def load(version, root=None):
|
||||
"""L'index complet d'un palier. `modules` à 0 = rien de lisible."""
|
||||
index = blank_index()
|
||||
for chemin in analysis_files(version, root):
|
||||
try:
|
||||
with open(chemin, "r", encoding="utf-8") as handle:
|
||||
parse(handle.read(), index)
|
||||
except OSError:
|
||||
continue
|
||||
index["modules"] += 1
|
||||
return index
|
||||
|
||||
|
||||
def model_change(nom, index):
|
||||
"""Ce qu'OpenUpgrade déclare pour ce modèle disparu, ou None."""
|
||||
if nom in index["models_renamed"]:
|
||||
return ("renamed", index["models_renamed"][nom])
|
||||
if nom in index["models_obsolete"]:
|
||||
return ("obsolete", None)
|
||||
return None
|
||||
|
||||
|
||||
def field_change(cle, index):
|
||||
"""Ce qu'OpenUpgrade déclare pour ce champ disparu, ou None.
|
||||
|
||||
« unstored » est le cas qui justifie tout l'outil : le champ EXISTE
|
||||
toujours, il n'a plus de colonne parce qu'il est calculé. Ce n'est ni
|
||||
un gain ni une perte — c'est une transformation, et la confondre avec
|
||||
une perte fait crier au loup à chaque palier.
|
||||
"""
|
||||
if cle in index["fields_del"]:
|
||||
return ("del", None)
|
||||
if cle in index["fields_unstored"]:
|
||||
return ("unstored", None)
|
||||
if cle in index["fields_company_dependent"]:
|
||||
return ("company_dependent", None)
|
||||
if cle in index["fields_moved"]:
|
||||
return ("moved", index["fields_moved"][cle])
|
||||
return None
|
||||
|
|
@ -5891,6 +5891,70 @@ TRANSLATIONS = {
|
|||
"fr": "ligne(s)",
|
||||
"en": "row(s)",
|
||||
},
|
||||
"Against OpenUpgrade": {
|
||||
"fr": "Face à OpenUpgrade",
|
||||
"en": "Against OpenUpgrade",
|
||||
},
|
||||
"NOT declared by OpenUpgrade": {
|
||||
"fr": "NON déclarés par OpenUpgrade",
|
||||
"en": "NOT declared by OpenUpgrade",
|
||||
},
|
||||
"No OpenUpgrade analysis for this step.": {
|
||||
"fr": "Pas d'analyse OpenUpgrade pour ce palier.",
|
||||
"en": "No OpenUpgrade analysis for this step.",
|
||||
},
|
||||
"became a per-company jsonb column": {
|
||||
"fr": "passés en colonne jsonb par société",
|
||||
"en": "became a per-company jsonb column",
|
||||
},
|
||||
"computed now — the field remains, the column does not": {
|
||||
"fr": "devenus calculés — le champ demeure, la colonne non",
|
||||
"en": "computed now — the field remains, the column does not",
|
||||
},
|
||||
"core module(s) analysed": {
|
||||
"fr": "module(s) cœur analysé(s)",
|
||||
"en": "core module(s) analysed",
|
||||
},
|
||||
"declared obsolete": {
|
||||
"fr": "déclarés obsolètes",
|
||||
"en": "declared obsolete",
|
||||
},
|
||||
"declared removed": {
|
||||
"fr": "déclarés supprimés",
|
||||
"en": "declared removed",
|
||||
},
|
||||
"field(s) lost": {
|
||||
"fr": "champ(s) perdu(s)",
|
||||
"en": "field(s) lost",
|
||||
},
|
||||
"model(s) lost": {
|
||||
"fr": "modèle(s) perdu(s)",
|
||||
"en": "model(s) lost",
|
||||
},
|
||||
"moved to another module": {
|
||||
"fr": "déplacés vers un autre module",
|
||||
"en": "moved to another module",
|
||||
},
|
||||
"renamed by Odoo": {
|
||||
"fr": "renommés par Odoo",
|
||||
"en": "renamed by Odoo",
|
||||
},
|
||||
"their model went away too": {
|
||||
"fr": "leur modèle a disparu aussi",
|
||||
"en": "their model went away too",
|
||||
},
|
||||
"in a module OpenUpgrade does not analyse": {
|
||||
"fr": "dans un module qu'OpenUpgrade n'analyse pas",
|
||||
"en": "in a module OpenUpgrade does not analyse",
|
||||
},
|
||||
"model(s)": {
|
||||
"fr": "modèle(s)",
|
||||
"en": "model(s)",
|
||||
},
|
||||
"other field name(s)": {
|
||||
"fr": "autre(s) nom(s) de champ",
|
||||
"en": "other field name(s)",
|
||||
},
|
||||
"Census": {
|
||||
"fr": "Recensement",
|
||||
"en": "Census",
|
||||
|
|
|
|||
|
|
@ -1175,5 +1175,160 @@ class TestItRefusesToNestItself(Base):
|
|||
self.assertIn("its own", source)
|
||||
|
||||
|
||||
class TestTheOpenUpgradeOverlay(unittest.TestCase):
|
||||
"""Le théorique posé sur le pratique.
|
||||
|
||||
Le rapport comptait des « + » et des « − ». Un champ devenu calculé
|
||||
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence.
|
||||
Sans troisième état, on alarmait à chaque palier.
|
||||
"""
|
||||
|
||||
INDEX = {
|
||||
"models_new": {"product.combo"},
|
||||
"models_obsolete": {"account.unreconcile"},
|
||||
"models_renamed": {"pos.combo": "product.combo"},
|
||||
"fields_new": set(),
|
||||
"fields_del": {"account.journal.secure_sequence_id"},
|
||||
"fields_unstored": {"account.account.code"},
|
||||
"fields_stored": set(),
|
||||
"fields_moved": {"pos.combo.base_price": "product"},
|
||||
"fields_company_dependent": {"account.cash.rounding.loss_account_id"},
|
||||
"fields_other": {},
|
||||
"xml_new": 0,
|
||||
"xml_del": 0,
|
||||
"modules": 402,
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
quality._CACHE_DECLARE[18] = self.INDEX
|
||||
from script.analyse import openupgrade_analysis as oa
|
||||
|
||||
self.vrai = oa.analysed_modules
|
||||
oa.analysed_modules = lambda version, root=None: {"account", "sale"}
|
||||
self.oa = oa
|
||||
|
||||
def tearDown(self):
|
||||
quality._CACHE_DECLARE.pop(18, None)
|
||||
self.oa.analysed_modules = self.vrai
|
||||
|
||||
def pose(self, modeles=(), champs=(), origines=None):
|
||||
return quality.overlay_declared(
|
||||
18, list(modeles), list(champs), origines
|
||||
)
|
||||
|
||||
def test_a_declared_obsolete_model_is_not_a_finding(self):
|
||||
d = self.pose(modeles=["account.unreconcile"])
|
||||
self.assertEqual(d["models"]["obsolete"], ["account.unreconcile"])
|
||||
self.assertEqual(d["models"]["undeclared"], [])
|
||||
|
||||
def test_a_renamed_model_names_its_destination(self):
|
||||
d = self.pose(modeles=["pos.combo"])
|
||||
self.assertEqual(
|
||||
d["models"]["renamed"], [("pos.combo", "product.combo")]
|
||||
)
|
||||
|
||||
def test_an_unknown_model_stays_undeclared(self):
|
||||
d = self.pose(modeles=["ma.bebelle"])
|
||||
self.assertEqual(d["models"]["undeclared"], ["ma.bebelle"])
|
||||
|
||||
def test_a_field_that_became_computed_is_its_own_category(self):
|
||||
# NI perte NI gain : le champ demeure, la colonne non. C'est tout
|
||||
# l'objet de l'ajout.
|
||||
d = self.pose(champs=["account.account.code"])
|
||||
self.assertEqual(d["fields"]["unstored"], ["account.account.code"])
|
||||
self.assertEqual(d["fields"]["undeclared"], [])
|
||||
|
||||
def test_declared_removals_and_moves_land_apart(self):
|
||||
d = self.pose(
|
||||
champs=[
|
||||
"account.journal.secure_sequence_id",
|
||||
"pos.combo.base_price",
|
||||
"account.cash.rounding.loss_account_id",
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
d["fields"]["del"], ["account.journal.secure_sequence_id"]
|
||||
)
|
||||
self.assertEqual(
|
||||
d["fields"]["moved"], [("pos.combo.base_price", "product")]
|
||||
)
|
||||
self.assertEqual(
|
||||
d["fields"]["company_dependent"],
|
||||
["account.cash.rounding.loss_account_id"],
|
||||
)
|
||||
|
||||
def test_a_field_whose_model_vanished_is_not_counted_again(self):
|
||||
# 544 champs pour un seul modèle disparu, mesuré sur un vrai
|
||||
# palier : listés un par un, ils cachaient les vingt vraies
|
||||
# trouvailles.
|
||||
d = self.pose(
|
||||
modeles=["account.unreconcile"],
|
||||
champs=["account.unreconcile.name"],
|
||||
)
|
||||
self.assertEqual(
|
||||
d["fields"]["model_gone"], ["account.unreconcile.name"]
|
||||
)
|
||||
self.assertEqual(d["fields"]["undeclared"], [])
|
||||
|
||||
def test_a_field_of_an_unanalysed_module_is_not_accused(self):
|
||||
# OpenUpgrade n'analyse que le cœur : dire « non déclaré » d'un
|
||||
# champ OCA est vrai à la lettre et faux en esprit.
|
||||
d = self.pose(
|
||||
champs=["x.mon_champ"],
|
||||
origines={"x.mon_champ": ["mon_module_oca"]},
|
||||
)
|
||||
self.assertEqual(d["fields"]["not_analysed"], ["x.mon_champ"])
|
||||
self.assertEqual(d["fields"]["undeclared"], [])
|
||||
|
||||
def test_a_core_field_with_no_declaration_SURVIVES_as_undeclared(self):
|
||||
# Le signal ne doit pas se faire absorber par les catégories
|
||||
# rassurantes : c'est la seule ligne qui demande un examen.
|
||||
d = self.pose(
|
||||
champs=["account.account.mystere"],
|
||||
origines={"account.account.mystere": ["account"]},
|
||||
)
|
||||
self.assertEqual(
|
||||
d["fields"]["undeclared"], ["account.account.mystere"]
|
||||
)
|
||||
|
||||
def test_no_analysis_is_said_not_shown_as_zero(self):
|
||||
# « 0 déclaré » et « analyse absente » se ressemblent à l'œil.
|
||||
quality._CACHE_DECLARE[18] = dict(self.INDEX, modules=0)
|
||||
d = self.pose(modeles=["x.y"])
|
||||
self.assertFalse(d["available"])
|
||||
self.assertEqual(d["reason"], "missing")
|
||||
texte = "\n".join(quality.render_declared(d, False))
|
||||
self.assertIn(
|
||||
todo_i18n.t("No OpenUpgrade analysis for this step."), texte
|
||||
)
|
||||
|
||||
def test_an_unknown_version_declares_nothing_quietly(self):
|
||||
d = quality.overlay_declared(None, ["x.y"], [])
|
||||
self.assertFalse(d["available"])
|
||||
self.assertEqual(quality.render_declared(d, False), [])
|
||||
|
||||
|
||||
class TestGroupingByFieldName(unittest.TestCase):
|
||||
def test_one_mixin_field_is_one_finding(self):
|
||||
# `__last_update` s'est compté 391 fois sur un vrai palier : c'est
|
||||
# UN changement.
|
||||
groupes = quality.group_by_field_name(
|
||||
[f"modele{i}.zz_last_update" for i in range(391)]
|
||||
+ ["account.account.aa_autre"]
|
||||
)
|
||||
self.assertEqual(groupes[0][0], "zz_last_update")
|
||||
self.assertEqual(groupes[0][1], 391)
|
||||
|
||||
def test_the_most_widespread_comes_first(self):
|
||||
groupes = quality.group_by_field_name(
|
||||
["a.zzz", "b.zzz", "c.zzz", "d.aaa"]
|
||||
)
|
||||
self.assertEqual([nom for nom, _n, _e in groupes], ["zzz", "aaa"])
|
||||
|
||||
def test_a_lone_field_keeps_its_full_key_as_example(self):
|
||||
groupes = quality.group_by_field_name(["account.account.seul"])
|
||||
self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
186
test/test_openupgrade_analysis.py
Normal file
186
test/test_openupgrade_analysis.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Lire ce qu'OpenUpgrade déclare, sans le déformer.
|
||||
|
||||
La catégorie qui porte tout l'outil est « unstored » : le champ EXISTE
|
||||
encore, il n'a plus de colonne parce qu'il est calculé. Le ranger avec
|
||||
les suppressions ferait crier au loup à chaque palier ; l'oublier
|
||||
masquerait une vraie perte.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
)
|
||||
|
||||
from script.analyse import openupgrade_analysis as oa # noqa: E402
|
||||
|
||||
EXEMPLE = """---Models in module 'account'---
|
||||
obsolete model account.unreconcile [transient]
|
||||
new model account.code.mapping [sql_view]
|
||||
new model product.combo (renamed from pos.combo in module point_of_sale)
|
||||
---Fields in module 'account'---
|
||||
account / account.account / _order : _order is now 'code' ('id')
|
||||
account / account.account / code (char) : not stored anymore
|
||||
account / account.account / code_store (char) : NEW
|
||||
account / account.journal / secure_sequence_id (many2one) : DEL relation: ir.sequence
|
||||
account / account.account / company_id (many2one) : is now stored
|
||||
account / account.cash.rounding / loss_account_id (many2one) : needs conversion to v18-style company dependent
|
||||
account / pos.combo / base_price (float) : module is now 'product' ('point_of_sale')
|
||||
account / account.journal / type (selection) : selection_keys added: [credit]
|
||||
---XML records in module 'account'---
|
||||
NEW ir.ui.view: account.view_x
|
||||
DEL ir.ui.view: account.view_y
|
||||
DEL ir.ui.view: account.view_z
|
||||
"""
|
||||
|
||||
|
||||
class TestParsing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.index = oa.parse(EXEMPLE)
|
||||
|
||||
def test_obsolete_and_new_models(self):
|
||||
self.assertIn("account.unreconcile", self.index["models_obsolete"])
|
||||
self.assertIn("account.code.mapping", self.index["models_new"])
|
||||
|
||||
def test_a_rename_is_read_from_the_NEW_side(self):
|
||||
# OpenUpgrade ne note le renommage que sur le nouveau modèle ;
|
||||
# l'ancien n'apparaît jamais comme « obsolete ». Sans cette
|
||||
# lecture, sa disparition passerait pour une perte sèche.
|
||||
self.assertEqual(
|
||||
self.index["models_renamed"].get("pos.combo"), "product.combo"
|
||||
)
|
||||
|
||||
def test_each_field_lands_in_its_category(self):
|
||||
for cle, categorie in (
|
||||
("account.account.code", "fields_unstored"),
|
||||
("account.account.code_store", "fields_new"),
|
||||
("account.journal.secure_sequence_id", "fields_del"),
|
||||
("account.account.company_id", "fields_stored"),
|
||||
(
|
||||
"account.cash.rounding.loss_account_id",
|
||||
"fields_company_dependent",
|
||||
),
|
||||
):
|
||||
self.assertIn(cle, self.index[categorie], f"{cle} → {categorie}")
|
||||
|
||||
def test_a_moved_field_keeps_its_new_module(self):
|
||||
self.assertEqual(
|
||||
self.index["fields_moved"].get("pos.combo.base_price"), "product"
|
||||
)
|
||||
|
||||
def test_pseudo_fields_are_not_fields(self):
|
||||
# `_order` n'est pas dans `ir_model_fields` : le compter fausserait
|
||||
# le rapprochement avec la base.
|
||||
for cle in list(self.index["fields_other"]) + list(
|
||||
self.index["fields_new"]
|
||||
):
|
||||
self.assertNotIn("._order", cle)
|
||||
|
||||
def test_an_unclassified_change_is_kept_not_dropped(self):
|
||||
# « selection_keys added » n'a pas de catégorie : le perdre
|
||||
# silencieusement ferait mentir les totaux.
|
||||
self.assertIn("account.journal.type", self.index["fields_other"])
|
||||
|
||||
def test_xml_records_are_counted(self):
|
||||
self.assertEqual(self.index["xml_new"], 1)
|
||||
self.assertEqual(self.index["xml_del"], 2)
|
||||
|
||||
|
||||
class TestClassify(unittest.TestCase):
|
||||
def test_new_with_details_is_still_new(self):
|
||||
self.assertEqual(
|
||||
oa.classify("NEW relation: res.company, required"), "new"
|
||||
)
|
||||
|
||||
def test_not_stored_is_not_confused_with_stored(self):
|
||||
# « not stored anymore » CONTIENT « stored » : tester par
|
||||
# appartenance de sous-chaîne inverserait le sens.
|
||||
self.assertEqual(oa.classify("not stored anymore"), "unstored")
|
||||
self.assertEqual(oa.classify("is now stored"), "stored")
|
||||
|
||||
def test_an_unknown_wording_falls_back_to_other(self):
|
||||
self.assertEqual(oa.classify("something brand new"), "other")
|
||||
|
||||
|
||||
class TestFindingTheFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.racine = tempfile.mkdtemp()
|
||||
base = os.path.join(
|
||||
self.racine,
|
||||
"odoo18.0",
|
||||
"OCA_OpenUpgrade",
|
||||
"openupgrade_scripts",
|
||||
"scripts",
|
||||
)
|
||||
for module, release in (
|
||||
("account", "18.0.1.0"),
|
||||
("account", "17.0.1.0"),
|
||||
("sale", "18.0.2.0"),
|
||||
("tests", "18.0.1.0"),
|
||||
):
|
||||
dossier = os.path.join(base, module, release)
|
||||
os.makedirs(dossier)
|
||||
with open(
|
||||
os.path.join(dossier, "upgrade_analysis.txt"),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
handle.write(EXEMPLE)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.racine)
|
||||
|
||||
def test_only_the_target_version_is_read(self):
|
||||
# À côté des `18.0.x` vivent des dossiers d'autres paliers : les
|
||||
# mélanger attribuerait à la 18 des changements de la 17.
|
||||
trouves = oa.analysis_files(18, self.racine)
|
||||
self.assertTrue(all("18.0" in c for c in trouves), trouves)
|
||||
self.assertEqual(len(trouves), 3)
|
||||
|
||||
def test_a_missing_checkout_is_empty_not_a_crash(self):
|
||||
self.assertEqual(oa.analysis_files(13, self.racine), [])
|
||||
self.assertEqual(oa.load(13, self.racine)["modules"], 0)
|
||||
|
||||
def test_the_module_list_comes_from_the_directories(self):
|
||||
modules = oa.analysed_modules(18, self.racine)
|
||||
self.assertIn("account", modules)
|
||||
self.assertIn("sale", modules)
|
||||
|
||||
def test_loading_counts_the_modules_it_read(self):
|
||||
self.assertEqual(oa.load(18, self.racine)["modules"], 3)
|
||||
|
||||
|
||||
class TestTheRealCheckout(unittest.TestCase):
|
||||
"""Le dépôt lui-même, quand il est là. Sinon on ne prétend rien."""
|
||||
|
||||
def setUp(self):
|
||||
if not oa.analysis_files(18):
|
||||
self.skipTest("OCA_OpenUpgrade absent du checkout 18.0")
|
||||
|
||||
def test_it_reads_hundreds_of_core_modules(self):
|
||||
index = oa.load(18)
|
||||
self.assertGreater(index["modules"], 300)
|
||||
|
||||
def test_it_finds_the_company_dependent_conversion(self):
|
||||
# C'est la refonte qui a fait disparaître `ir_property` en 18 :
|
||||
# si l'outil ne la voit pas, il ne sert à rien sur ce palier-là.
|
||||
index = oa.load(18)
|
||||
self.assertTrue(
|
||||
any(
|
||||
cle.startswith("account.cash.rounding.")
|
||||
for cle in index["fields_company_dependent"]
|
||||
),
|
||||
sorted(index["fields_company_dependent"])[:5],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue