Merge commit 'dev_migration_upgrade'

[ADD] release: an Odoo migration that drives itself

A migration used to be a sequence of commands and a person watching. It
now runs: it repairs, replays, takes the default after five seconds, and
names the answer it is about to take before taking it.

What each bump breaks is predicted rather than discovered. Customized
SCSS, themes, DMS visibility lost with MuK's access model, views whose
stored type contradicts their parent, the <tree> tags Odoo 18 renamed to
<list> -- each has a repair offered where the failure is blamed.

Nothing is called done on a promise. Every public URL is requested, /my
and every app are opened as the neutralization test user, and the views
behind a failing URL are named. The OCA database cleanup runs first, so a
page does not fail on leftovers.

Two screens make a long run legible: « t » shows where it stands, with
coloured commands, elapsed time and one log file per step; the quality
report says what the run gained and lost, tells Odoo's own redesigns from
real losses, and lays OpenUpgrade's declared changes over the real ones.

--- FR ---

Une migration était une suite de commandes et quelqu'un pour la
surveiller. Elle s'exécute maintenant : elle répare, rejoue, prend le
défaut au bout de cinq secondes, et nomme la réponse qu'elle va prendre
avant de la prendre.

Ce que chaque palier casse est prévu plutôt que découvert. SCSS
personnalisé, thèmes, visibilité DMS perdue avec le modèle d'accès de MuK,
vues dont le type stocké contredit leur parent, balises <tree> qu'Odoo 18
a renommées en <list> — chacun reçoit une réparation, proposée là où la
panne est imputée.

Rien n'est déclaré terminé sur parole. Chaque URL publique est demandée,
/my et chaque application sont ouvertes sous l'utilisateur de test de
neutralisation, et les vues derrière une URL en échec sont nommées. Le
nettoyage de base OCA passe d'abord, pour qu'une page n'échoue pas sur des
restes.

Deux écrans rendent une longue exécution lisible : « t » montre où elle en
est, commandes en couleur, durée écoulée, un fichier de journal par étape ;
le rapport de qualité dit ce qu'elle a gagné et perdu, distingue les
refontes propres à Odoo des vraies pertes, et superpose les changements
déclarés par OpenUpgrade aux réels.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 02:06:17 -04:00
commit e7c54c0a15
64 changed files with 23598 additions and 293 deletions

View file

@ -95,6 +95,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Application store of a graphical VM: .deb only, Flatpak tooling, or snap
- Remote desktop tunnel: the SSH command is composed from ~/.ssh/config, with the hypervisor console as a third route
- The install dashboard acts on a VM without leaving it: update by parts, restart Odoo, delete; architecture column and resizable columns
- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced
- The website copy analysis diffs each copy against the view it shadows
- A test checks that what the tooling imports is declared
<!-- [fr] -->
@ -155,6 +157,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Magasin d'applications d'une VM graphique : .deb seul, outillage Flatpak, ou snap
- Tunnel vers le bureau distant : la commande SSH est composée depuis ~/.ssh/config, avec la console de l'hyperviseur comme troisième voie
- Le tableau de bord d'installation agit sur une VM sans la quitter : mise à jour par parties, redémarrage d'Odoo, suppression ; colonne d'architecture et colonnes ajustables
- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce
- L'analyse des copies de site compare chaque copie à la vue qu'elle masque
- Un test vérifie que ce qu'importe l'outillage est bien déclaré
<!-- [en] -->
@ -249,6 +253,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- A graphical desktop install froze for thirty minutes on a snap package that could not reach the store
- The compiler was killed for lack of memory while building CPython on a small s390x guest
- The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported
- The COW migration tools and the database upgrade speak the system language
- The analysis and migration tools are executable
<!-- [fr] -->
@ -266,6 +272,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- L'installation d'un bureau graphique figeait trente minutes sur un paquet snap qui ne joignait pas le magasin
- Le compilateur était tué faute de mémoire en bâtissant CPython sur une petite VM s390x
- Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées
- Les outils de migration COW et la mise à niveau de la base parlent la langue du système
- Les outils d'analyse et de migration sont exécutables
<!-- [en] -->
## Security

View file

@ -71,6 +71,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Magasin d'applications d'une VM graphique : .deb seul, outillage Flatpak, ou snap
- Tunnel vers le bureau distant : la commande SSH est composée depuis ~/.ssh/config, avec la console de l'hyperviseur comme troisième voie
- Le tableau de bord d'installation agit sur une VM sans la quitter : mise à jour par parties, redémarrage d'Odoo, suppression ; colonne d'architecture et colonnes ajustables
- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce
- L'analyse des copies de site compare chaque copie à la vue qu'elle masque
- Un test vérifie que ce qu'importe l'outillage est bien déclaré
## Retiré
@ -121,6 +123,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- L'installation d'un bureau graphique figeait trente minutes sur un paquet snap qui ne joignait pas le magasin
- Le compilateur était tué faute de mémoire en bâtissant CPython sur une petite VM s390x
- Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées
- Les outils de migration COW et la mise à niveau de la base parlent la langue du système
- Les outils d'analyse et de migration sont exécutables
## Sécurité

View file

@ -69,6 +69,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
- Application store of a graphical VM: .deb only, Flatpak tooling, or snap
- Remote desktop tunnel: the SSH command is composed from ~/.ssh/config, with the hypervisor console as a third route
- The install dashboard acts on a VM without leaving it: update by parts, restart Odoo, delete; architecture column and resizable columns
- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced
- The website copy analysis diffs each copy against the view it shadows
- A test checks that what the tooling imports is declared
## Removed
@ -119,6 +121,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
- A graphical desktop install froze for thirty minutes on a snap package that could not reach the store
- The compiler was killed for lack of memory while building CPython on a small s390x guest
- The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported
- The COW migration tools and the database upgrade speak the system language
- The analysis and migration tools are executable
## Security

View file

@ -29,6 +29,10 @@ urwid
# Borne recopiée de script/todo/textual_setup.py (TEXTUAL_SPEC) : les écrans TUI
# sont écrits pour Textual 8, qui casse son API entre majeures. Modifier les deux.
textual>=8,<9
# Dépendance DIRECTE, même si Textual l'installe : l'écran d'état décode
# l'ANSI avec rich.text.Text.from_ansi. Compter sur une dépendance
# transitive, c'est dépendre du jour où Textual changera la sienne.
rich
# Dépendance directe : les outils d'analyse comparent des arbres XML avec lxml.
# Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc
# un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame.

315
script/addons/theme_leftover.py Executable file
View file

@ -0,0 +1,315 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qu'un thème laisse derrière lui une fois désinstallé. Lecture seule.
Décharger un thème retire ses copies de vues et rend au site sa configuration
par défaut. Restent des pièces jointes portant son chemin — SCSS compilé,
images téléversées dans ses dossiers — et parfois des vues dont la clé le
nomme encore.
Elles ne cassent rien tant que le module est parti : plus personne ne les
inclut dans un bundle. Mais elles traversent toutes les migrations suivantes,
et l'on finit par tomber sur un `/theme_x/static/...` dont le module n'existe
plus nulle part, sans savoir si c'est grave.
Rien n'est supprimé ici. Le contenu d'une pièce jointe peut être la seule
trace d'une personnalisation, et c'est une décision, pas un ménage.
Codes de sortie : 0 rien à signaler, 1 des restes, 2 l'outil a échoué.
"""
import argparse
import os
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
try:
from script.todo import auto_ask
except Exception: # pragma: no cover - repli si le pilote est absent
auto_ask = None
def can_ask():
"""Peut-on poser une question ICI ?
Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
stdin laisse poser une invite qui part dans un tube : elle reste en
tampon, invisible, pendant que le processus attend — on croit à un
blocage et l'on tape Entrée à l'aveugle.
"""
return sys.stdin.isatty() and sys.stdout.isatty()
def run_psql(database, sql):
"""Interroger la base en lecture seule, garantie côté serveur.
`default_transaction_read_only` est posé par le serveur pour toute la
session : ce n'est pas une promesse de l'outil, c'est PostgreSQL qui
refusera l'écriture même si le SQL en contenait une.
"""
env = os.environ.copy()
env["PGOPTIONS"] = (
"-c default_transaction_read_only=on -c statement_timeout=30000"
)
env["PSQLRC"] = ""
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-v",
"ON_ERROR_STOP=1",
"-d",
database,
"-tAF",
"|",
"-c",
sql,
],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
raise RuntimeError(done.stderr.strip() or "psql failed")
return [line for line in done.stdout.splitlines() if line]
def quote_literal(value):
return "'" + value.replace("'", "''") + "'"
def collect(database, theme):
"""Pièces jointes et vues qui nomment encore ce thème."""
like = quote_literal(f"%/{theme}/%")
key_like = quote_literal(f"{theme}.%")
attachments = run_psql(
database,
"SELECT id, COALESCE(url, name), create_date::date FROM ir_attachment"
f" WHERE url LIKE {like} ORDER BY id;",
)
views = run_psql(
database,
"SELECT id, key, website_id FROM ir_ui_view"
f" WHERE key LIKE {key_like} ORDER BY id;",
)
return attachments, views
def render(theme, attachments, views):
if not attachments and not views:
return f"✅ -> {t('No leftover for theme')} '{theme}'.\n"
lines = []
if attachments:
lines.append(
f"ℹ {len(attachments)} {t('attachment(s) still under')}"
f" /{theme}/ :"
)
for row in attachments[:20]:
lines.append(f" - {row}")
if len(attachments) > 20:
lines.append(f" … {len(attachments) - 20} {t('more')}")
if views:
lines.append(
f"ℹ {len(views)} {t('view(s) whose key still names it')} :"
)
for row in views[:20]:
lines.append(f" - {row}")
if len(views) > 20:
lines.append(f" … {len(views) - 20} {t('more')}")
lines.append(
f" {t('Nothing was deleted: their content may be the only trace')}"
f" {t('of a customization. Read before removing.')}"
)
return "\n".join(lines) + "\n"
def backup_attachments(database, theme, lst_row, filestore=None):
"""Écrire le contenu des pièces jointes AVANT de les supprimer.
C'est la condition pour pouvoir répondre « efface ». Sans elle, on
détruirait ce dont on vient d'écrire qu'il peut être la seule trace
d'une personnalisation.
"""
base = filestore or os.path.join(
os.path.expanduser("~"),
".local",
"share",
"Odoo",
"filestore",
database,
)
directory = os.path.join(
"private", "odoo", "migration", database, "theme_backup", theme
)
os.makedirs(directory, exist_ok=True)
lst_saved = []
for row in lst_row:
att_id = row.split("|")[0]
rows = run_psql(
database,
"SELECT COALESCE(store_fname, '') FROM ir_attachment"
f" WHERE id = {int(att_id)};",
)
store_fname = rows[0].strip() if rows else ""
if not store_fname:
continue
source = os.path.join(base, store_fname)
if not os.path.isfile(source):
continue
target = os.path.join(
directory, f"{att_id}_" + os.path.basename(row.split("|")[1])
)
with open(source, "rb") as src, open(target, "wb") as dst:
dst.write(src.read())
lst_saved.append(target)
return lst_saved
def delete_attachments(database, lst_row, config_path="./config.conf"):
"""Supprimer par le shell d'Odoo, pour qu'il gère aussi le filestore.
Un DELETE en SQL laisserait les fichiers orphelins et les caches
incohérents ; unlink() fait le ménage complet, dans toutes les versions.
"""
# int(), pas la chaîne du psql : browse(['4457']) fait échouer Odoo sur
# « n'a pas les identifiants (('4457',)) et a des identifiants
# supplémentaires ((4457,)) » — il compare des chaînes à des entiers.
# Mesuré sur une vraie base, l'effacement n'a rien retiré.
lst_id = [int(row.split("|")[0]) for row in lst_row]
script = (
f"env['ir.attachment'].browse({lst_id!r}).unlink()\n"
"env.cr.commit()\n"
)
done = subprocess.run(
["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
input=script,
capture_output=True,
text=True,
)
return done.returncode, done.stdout + done.stderr
DEFAULT_ANSWER = "d"
def prompt(database, theme, attachments, views, config_path, ask=None):
"""Effacer ou garder. Effacer par défaut, et la sauvegarde D'ABORD.
Ce qui rend ce défaut tenable, c'est l'ordre : le contenu part dans un
fichier avant que la base ne soit touchée. Sans cette sauvegarde, le
défaut aurait dû rester « garder » — on ne fait pas d'une décision
irréversible la réponse que l'on obtient en ne répondant pas.
"""
if not attachments:
return False
if ask is None:
# Lancé à part par la migration : sans lecteur temporisé, cette
# question arrêtait net une exécution automatique.
ask = (
auto_ask.make_ask(DEFAULT_ANSWER)
if auto_ask
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
)
answer = (
ask(
f"💬 {t('Delete these leftovers, or keep them?')}"
f" ({t('Enter = delete, after saving them')},"
f" k = {t('keep')}) : "
)
.strip()
.lower()
)
if answer != "d":
print(f"ℹ -> {t('Kept. Nothing was deleted.')}")
return False
lst_saved = backup_attachments(database, theme, attachments)
print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
if lst_saved:
print(f" {os.path.dirname(lst_saved[0])}")
status, output = delete_attachments(database, attachments, config_path)
print(output.strip()[-1500:])
if status:
print(f"❌ {t('Deletion failed, nothing was removed.')}")
return False
print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
return True
def main(argv=None):
parser = argparse.ArgumentParser(
description=("List what an uninstalled theme left behind (read-only).")
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-t", "--theme", required=True)
parser.add_argument(
"-c",
"--config",
default="./config.conf",
help="Odoo config used by the shell for --delete",
)
parser.add_argument(
"--delete",
action="store_true",
help="delete them (WRITES; saves their content first)",
)
parser.add_argument(
"--report-only",
action="store_true",
help="never ask anything, even in front of a terminal",
)
config = parser.parse_args(argv)
try:
attachments, views = collect(config.database, config.theme)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
print(render(config.theme, attachments, views))
if not attachments and not views:
return 0
if config.delete:
lst_saved = backup_attachments(
config.database, config.theme, attachments
)
print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
status, output = delete_attachments(
config.database, attachments, config.config
)
print(output.strip()[-1500:])
if status:
print(f"❌ {t('Deletion failed, nothing was removed.')}")
return 2
print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
return 0
# Voir ET pouvoir répondre : une invite dont la sortie part dans un
# tube reste invisible — Python bufferise par blocs — pendant que le
# processus attend. Mesuré : l'utilisateur tape Entrée à l'aveugle.
if not config.report_only and can_ask():
if prompt(
config.database,
config.theme,
attachments,
views,
config.config,
):
return 0
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Désinstaller un thème comme Odoo le fait lui-même.
#
# « ./run.sh --uninstall theme_x » retire le MODULE, pas le THÈME. Choisir un
# thème (button_choose_theme, ce que pose --install-theme) fait DEUX choses :
# copier ses vues et ressources dans chaque site, et écrire dans
# user_values.scss une personnalisation qui DÉFINIT $o-theme-font-number et
# ses trois voisines. Le chemin de retrait d'Odoo, _theme_remove(), défait les
# deux.
#
# Le désinstaller sans lui laisse donc les copies, et surtout n'écrit jamais
# ces définitions. Mesuré sur une migration réelle 12 -> 13 : le bundle
# web.assets_frontend s'arrête sur « Undefined variable: $o-theme-font-number
# ». La variable venait des fichiers option_font_body_* d'Odoo 12, supprimés
# en 13.0 ; seul le thème la redéfinissait encore, et le retirer a mis à nu
# une personnalisation figée depuis des années.
#
# Usage : ./script/addons/uninstall_addons_theme.sh <base> <theme> [config]
Red='\033[0;31m' # Red
Color_Off='\033[0m' # Text Reset
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <database> <theme_module> [config]"
exit 1
fi
DATABASE="$1"
THEME="$2"
CONFIG="${3:-./config.conf}"
if [[ $# -eq 3 ]]; then
./script/addons/check_addons_exist.py -m "$THEME" -c "$3"
else
./script/addons/check_addons_exist.py -m "$THEME"
fi
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo -e "${Red}Error${Color_Off} check_addons_exist.py into uninstall_addons_theme.sh"
exit 1
fi
echo "Unload theme '$THEME' from every website of BD '$DATABASE'"
# Le déchargement passe par le shell : _theme_remove() n'a pas d'option de
# ligne de commande, et l'écrire ici évite d'ajouter une option au fork pour
# chaque version d'Odoo. Les journaux se mêlent à la sortie, d'où la
# sentinelle : on ne conclut que sur ce qui la suit.
./odoo_bin.sh shell -c "$CONFIG" -d "$DATABASE" --no-http --log-level=warn <<PYTHON
theme_name = "$THEME"
Module = env["ir.module.module"]
theme = Module.search([("name", "=", theme_name)], limit=1)
if not theme:
print("ERPLIBRE_THEME_UNLOAD: unknown %s" % theme_name)
else:
lst_website = env["website"].search([])
for website in lst_website:
# _theme_remove décharge le thème COURANT du site et, avant tout,
# rappelle _reset_default_config() : c'est cet appel qui écrit
# font-number & co. dans user_values.scss. Il vaut même quand le
# site n'a plus de thème — c'est précisément le cas à réparer.
theme.with_context(website_id=website.id)._theme_remove(website)
env.cr.commit()
print("ERPLIBRE_THEME_UNLOAD: done %s website(s)" % len(lst_website))
PYTHON
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo -e "${Red}Error${Color_Off} odoo_bin.sh shell into uninstall_addons_theme.sh"
exit 1
fi
echo "Uninstall theme module '$THEME' on BD '$DATABASE'"
if [[ $# -eq 3 ]]; then
./run.sh --no-http --stop-after-init -d "$DATABASE" --uninstall "$THEME" -c "$3"
else
./run.sh --no-http --stop-after-init -d "$DATABASE" --uninstall "$THEME"
fi
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo -e "${Red}Error${Color_Off} run.sh into uninstall_addons_theme.sh"
exit 1
fi
# Ce que le déchargement ne prend pas : les pièces jointes que le thème a
# laissées sous son propre chemin. Elles ne cassent rien tant que ses vues
# sont parties, mais elles survivent à toutes les migrations suivantes et
# personne ne sait plus d'où elles viennent. On les signale, on ne les
# supprime pas : leur contenu peut être la seule trace d'une personnalisation.
# Son code de sortie dit « il reste des choses », pas « la désinstallation a
# échoué » : le laisser devenir celui du script faisait annoncer une erreur
# sur un thème correctement retiré.
./script/addons/theme_leftover.py -d "$DATABASE" -t "$THEME" -c "$CONFIG" || true
exit 0

0
script/analyse/analyse_custom_field.py Normal file → Executable file
View file

0
script/analyse/analyse_diff_tui.py Normal file → Executable file
View file

0
script/analyse/analyse_schema_size.py Normal file → Executable file
View file

0
script/analyse/analyse_view_custom.py Normal file → Executable file
View file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,361 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La qualité d'une migration, palier par palier, en plein écran.
Le rapport texte dit tout d'un coup : sur six paliers il fait plusieurs
centaines de lignes, et ce qu'on cherche — quel palier a perdu quoi — se
trouve quelque part au milieu. Un écran qui se parcourt règle cela.
Les données viennent de `check_migration_quality`, comme le rapport texte.
Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
états contradictoires de la même migration.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import check_migration_quality as quality # noqa: E402
from script.todo import migration_status as status # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 40; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def rows(lst_snapshot):
"""Un palier par ligne, puis le bilan d'ensemble en dernier.
Le bilan EN DERNIER et non en tête : on descend la liste comme on a
vécu la migration, et la question « qu'est-ce qu'il en reste » se pose
une fois qu'on a vu le chemin.
"""
lst = []
presents = [x for x in lst_snapshot if x.get("exists")]
for index, etat in enumerate(lst_snapshot):
if not etat.get("exists"):
lst.append(
{
"kind": "missing",
"label": f"⚠️ {etat['database'][:30]}",
"detail": "",
"data": etat,
}
)
continue
precedent = None
rang = presents.index(etat)
if rang:
precedent = presents[rang - 1]
diff = quality.compare(precedent, etat) if precedent else None
# Le précédent voyage avec la ligne : le panneau en a besoin pour
# écrire « 171 (−43) », et le recalculer là-bas ferait deux
# sources pour la même comparaison.
# La colonne compte les pertes INEXPLIQUÉES : afficher 81 quand
# 79 sont des refontes voulues par Odoo ferait fuir le lecteur du
# seul chiffre qui demande une réponse.
perdu = (
0
if diff is None or diff.get("unavailable")
else len([item for item in diff["rows_lost"] if not item[3]])
)
lst.append(
{
"kind": "step",
"label": f"{etat['odoo']:<6} {etat['database'][:24]}",
"detail": str(perdu) if perdu else "",
"data": etat,
"diff": diff,
"previous": precedent,
}
)
if len(presents) >= 2:
lst.append(
{
"kind": "overall",
"label": f"🏁 {presents[0]['odoo']} → {presents[-1]['odoo']}",
"detail": "",
"data": None,
"diff": quality.overall(lst_snapshot),
}
)
return lst
def head_text(lst_snapshot):
presents = [x for x in lst_snapshot if x.get("exists")]
manquants = len(lst_snapshot) - len(presents)
if not presents:
return t("No migration in progress.")
return (
f"{presents[0]['database']} · {len(presents)} {t('steps')}"
f" · {presents[0]['odoo']} → {presents[-1]['odoo']}"
+ (
f" · ⚠️ {manquants} {t('database not found')}"
if manquants
else ""
)
)
def statistics(etat, precedent):
"""[(libellé, valeur, écart)] pour un palier. L'écart est None au départ.
Un chiffre seul ne dit rien : 2283 vues est un nombre, « +61 » est une
information. Le premier palier n'a pas de précédent, et inventer un
écart de zéro y laisserait croire à une comparaison qui n'existe pas.
"""
lst = [
(
t("modules"),
len(etat["installed"]),
None if not precedent else len(precedent["installed"]),
),
(
t("models"),
len(etat["model"]),
None if not precedent else len(precedent["model"]),
),
(
t("views"),
etat["view"],
None if not precedent else precedent["view"],
),
(
" · COW",
etat["view_cow"],
None if not precedent else precedent["view_cow"],
),
(
t("menus"),
etat["menu"],
None if not precedent else precedent["menu"],
),
(
t("attachments"),
etat["attachment"],
None if not precedent else precedent["attachment"],
),
]
return [
(libelle, valeur, None if avant is None else valeur - avant)
for libelle, valeur, avant in lst
]
def pane_text(lst_snapshot, row, colour=False, mode=None):
"""Le détail du palier choisi, ou le bilan d'ensemble.
UN seul mode, et non deux drapeaux : « montre les fichiers absents »
et « montre la liste des modèles » ne peuvent pas être vrais en même
temps, et deux booléens laissaient écrire cet état impossible.
"""
if row is None:
return t("Nothing to show yet.")
if row["kind"] == "missing":
return f"⚠️ {row['data']['database']} : {t('database not found')}"
etat = row["data"]
if mode == "missing":
if not etat:
return t("Pick a step to see its missing files.")
return quality.render_missing(etat, colour)
if mode in quality.DETAILS:
return quality.render_detail(row.get("diff"), mode, colour)
lignes = []
if etat:
lignes.append(
status.paint(f"{etat['odoo']} {etat['database']}", "step", colour)
)
lignes.append("")
for libelle, valeur, ecart in statistics(etat, row.get("previous")):
if ecart is None:
marque = ""
else:
marque = status.paint(
f"{ecart:+d}", "ok" if ecart >= 0 else "warn", colour
)
lignes.append(f" {libelle:<28} {valeur:>7} {marque}")
if etat.get("attachment_missing"):
lignes.append(
" "
+ status.paint(
f"❌ {etat['attachment_missing']}"
f" {t('attachment files missing from the filestore')}",
"fail",
colour,
)
)
lignes.append(f" {t('press m to list them')}")
lignes.append("")
diff = row.get("diff")
if diff:
lignes.extend(quality.render_compare(diff, colour, limit=40))
return "\n".join(lignes)
def mode_label(mode):
"""Le nom du mode courant, pour le sous-titre.
Un panneau qui change de contenu sans dire pourquoi se lit comme un
écran cassé — et avec sept modes, on ne devine pas.
"""
if mode is None:
return t("summary")
if mode == "missing":
return t("Missing files")
return t(mode)
def build_app(lst_snapshot):
"""Textual est importé ICI : le module reste testable sans lui."""
from rich.text import Text
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
class QualityApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", t("Quit")),
("m", "toggle_missing", t("Missing files")),
("d", "cycle_detail", t("Details")),
]
def __init__(self, lst_snapshot):
super().__init__()
self.lst_snapshot = lst_snapshot
self.lst_row = rows(lst_snapshot)
self.index = 0
self.mode = None
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="left", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = t("Migration quality, step by step")
table = self.query_one("#left", DataTable)
table.add_columns(t("steps"), "▼")
for row in self.lst_row:
table.add_row(row["label"][:34], row["detail"])
self.query_one("#head", Static).update(
head_text(self.lst_snapshot)
)
self._show()
def _show(self):
self.sub_title = mode_label(self.mode)
row = (
self.lst_row[self.index]
if self.lst_row and self.index < len(self.lst_row)
else None
)
self.query_one("#content", Static).update(
Text.from_ansi(
pane_text(
self.lst_snapshot,
row,
colour=True,
mode=self.mode,
)
)
)
def action_toggle_missing(self):
"""Basculer entre les chiffres du palier et ses fichiers absents.
Les métadonnées ne sont lues qu'ICI : une requête de plus par
base allongerait un parcours qui tient en quatre secondes, pour
une information qu'on ne regarde qu'en la demandant.
"""
self.mode = None if self.mode == "missing" else "missing"
self._show()
def action_cycle_detail(self):
"""Parcourir les listes ENTIÈRES, une catégorie à la fois.
Le résumé coupe à huit entrées et il a raison ; mais quand on
cherche si UN module précis a survécu, la liste tronquée ne
répond pas. On enchaîne donc résumé → modules → modèles →
champs → copies COW → tables, et l'on revient.
"""
suite = (None,) + quality.DETAILS
courant = self.mode if self.mode in suite else None
self.mode = suite[(suite.index(courant) + 1) % len(suite)]
self._show()
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "left" and self.lst_row:
self.index = event.cursor_row
self._show()
return QualityApp(lst_snapshot)
def run_tui(lst_snapshot, run_app=True):
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
if not lst_snapshot:
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
try:
app = build_app(lst_snapshot)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
if in_event_loop():
# Filet de sécurité : `app.run()` appelle `asyncio.run()`, qui
# lève dans une boucle déjà en cours. Le dire vaut mieux que la
# trace de quarante lignes que cela produit — et l'appelant doit
# passer par un sous-processus.
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return False
app.run()
return True
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True

0
script/analyse/lib_analyse.py Normal file → Executable file
View file

View 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

0
script/analyse/shell/view_file_arch.py Normal file → Executable file
View file

View file

@ -178,12 +178,22 @@ class Execute:
env=my_env,
)
sink = getattr(self, "log_sink", None)
while True:
line = process.stdout.readline()
if not line:
break
if not quiet:
print(line, end="")
if sink:
# Chaque ligne passe DÉJÀ ici : c'est le seul endroit où
# journaliser sans rien changer à ce que le terminal
# montre. Une erreur d'écriture ne doit jamais faire
# échouer la commande qu'on est en train de suivre.
try:
sink.write(line)
except Exception:
sink = None
if (
return_status_and_output
or return_status_and_output_and_command

View file

@ -50,6 +50,18 @@ import subprocess
import sys
import xml.etree.ElementTree as ET
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# A view whose module counterpart cannot be found at all.
MODE_UNKNOWN = "unknown"
@ -232,6 +244,9 @@ def analyse(database, target_version):
# The decisive test: the target expects inheritance specs but the copy
# holds a standalone template, or the reverse.
if is_spec is not None and target_inherits != is_spec:
# La clé anglaise est stockée, la traduction se fait à
# l'affichage : cow_drift et neutralize la relisent, et une
# valeur déjà traduite les obligerait à traduire en sens inverse.
reason = (
"target inherits, copy holds a standalone template"
if target_inherits
@ -281,24 +296,29 @@ def main():
if not os.path.isdir(config.target_version):
print(
f"❌ Target version directory '{config.target_version}' not found."
f"❌ {t('Target version directory not found')} :"
f" '{config.target_version}'"
)
return 1
return 2
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
config.database, config.target_version
)
database = config.database
target_version = config.target_version
if not lst_at_risk:
print(
"✅ -> No website COW view changes mode in"
f"✅ -> {t('No website COW view changes shape in')}"
f" {config.target_version}."
)
else:
print(
f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving"
f" to {config.target_version}: the copy keeps an arch whose shape"
" no longer matches what the target module view expects."
f"⚠️ {len(lst_at_risk)}"
f" {t('website COW view(s) will break when moving to')}"
f" {config.target_version} :"
f" {t('the copy keeps an arch whose shape no longer matches what')}"
f" {t('the target module view expects.')}"
)
for (
view_id,
@ -310,37 +330,55 @@ def main():
) in lst_at_risk:
print(
f" - id={view_id} website={website_id} {key}"
f" : {mode} -> {target_mode} ({reason})"
f" : {mode} -> {target_mode} ({t(reason)})"
)
# La base et la version cible sont connues ici : les remplacer par
# « DB » et « odooXX.0 » oblige à les retrouver, au moment précis où
# l'on veut juste copier-coller la commande.
print(
" Arbitrate BEFORE launching the migration. To neutralize a copy,"
" rename its key (UPDATE ir_ui_view SET key='zz_cow_archive.'||key,"
" active=false): an unmatched key is never paired with the module"
" view, so the copy never receives the new inherit_id. Setting"
" active=false alone is NOT enough -- an inactive copy that keeps"
" the same key still shadows the module view."
f" {t('The migration offers to neutralize them, and shows what')}"
f" {t('each copy holds before you answer. To look now:')}"
f"\n ./script/odoo/migration/cow_drift.py -d {database}"
f" -t {target_version} ({t('what each copy holds')})"
f"\n ./script/odoo/migration/cow_drift.py -d {database}"
f" -t {target_version} --shape ({t('why it breaks')})"
f"\n ./script/odoo/migration/neutralize_cow_views.py"
f" -d {database} -t {target_version} --apply"
f" ({t('reversible with --restore')})"
)
print(
f" {t('Or by hand. To neutralize a copy, rename its key')}"
" (UPDATE ir_ui_view SET key='zz_cow_archive.'||key,"
f" active=false) : {t('an unmatched key is never paired with the')}"
f" {t('module view, so the copy never receives the new')}"
f" inherit_id. {t('Setting active=false alone is NOT enough:')}"
f" {t('an inactive copy keeping the same key still shadows it.')}"
)
if lst_module_absent:
print(
f"ℹ {len(lst_module_absent)} COW view(s) belong to a module absent"
f" from {config.target_version}:"
f"ℹ {len(lst_module_absent)}"
f" {t('COW view(s) belong to a module absent from')}"
f" {config.target_version} :"
)
for view_id, key, mode, website_id in lst_module_absent:
print(f" - id={view_id} website={website_id} {key} ({mode})")
if lst_no_counterpart:
print(
f"ℹ {len(lst_no_counterpart)} COW view(s) are pages or records made"
" from the website editor (no module view of that name): not at"
" risk." + ("" if config.verbose else " Use -v to list them.")
f"ℹ {len(lst_no_counterpart)}"
f" {t('COW view(s) are pages or records made in the website')}"
f" {t('editor (no module view of that name): not at risk.')}"
+ ("" if config.verbose else f" {t('Use -v to list them.')}")
)
if config.verbose:
for view_id, key, mode, website_id in lst_no_counterpart:
print(f" - id={view_id} website={website_id} {key} ({mode})")
# Informative only: never fail the migration on a warning.
return 0
# 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
# Le pilote lisait le texte anglais de cette sortie pour savoir s'il
# devait poser sa question : traduire le message le rendait aveugle.
return 1 if lst_at_risk else 0
if __name__ == "__main__":

View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modèles qui ont des données que PLUS PERSONNE ne peut voir.
D'où vient ce test
------------------
Après une migration 12 → 18, les documents DMS avaient « disparu ». Ils
n'avaient pas disparu : 69 fichiers et 23 Mo étaient intacts en base. Ce
qui avait changé, c'est le modèle de sécurité — OCA DMS pose une règle
GLOBALE sur une permission accordée par un `dms.access.group`, et la
conversion depuis MuK n'en avait créé aucun, faute d'équivalent à
convertir.
Rien ne pouvait le voir venir. Les comptages de lignes disaient « tout est
là », et c'était vrai. Le test de fumée ouvrait des pages publiques, et
elles répondaient. Le trou est exactement entre les deux : des données
présentes, et une règle qui les masque intégralement.
Ce qu'on cherche
----------------
Un modèle tel que : il a des lignes, il porte au moins une règle GLOBALE,
et AUCUN utilisateur interne actif n'en voit une seule. « Aucun » est le
mot important — un modèle que seul un comptable voit est normal ; un
modèle que personne ne voit est soit une refonte de sécurité ratée, soit
des données devenues inatteignables.
Pourquoi pas en SQL
-------------------
Une règle est un domaine Odoo, parfois avec `user.`, `company_ids`, ou un
champ calculé cherchable — `permission_read` de DMS en est un, et il
déclenche une sous-requête que rien dans `ir_rule` ne laisse deviner. Il
faut l'ORM pour l'évaluer, donc le shell.
Le super-utilisateur est exclu : les règles ne s'appliquent pas à lui, et
un comptage fait en son nom déclarerait saine une base muette. C'est
précisément l'erreur qui aurait laissé passer le cas DMS.
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
"""
import argparse
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
from script.odoo.migration import database_cleanup # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
DEBUT = database_cleanup.START
FIN = database_cleanup.END
# Ces modèles sont invisibles PAR DESSEIN : ils portent des règles globales
# et ne s'adressent à personne en particulier. Les signaler à chaque
# migration noierait la vraie trouvaille sous le bruit, et un rapport
# qu'on apprend à ignorer ne sert plus à rien.
ATTENDUS = (
"ir.rule",
"ir.model.access",
"res.users.log",
"bus.bus",
"bus.presence",
"mail.notification",
"ir.logging",
"ir.autovacuum",
)
SCRIPT = """
import json
LIMITE = {limite}
ATTENDUS = set({attendus!r})
rapport = {{"models": [], "checked": 0, "users": []}}
try:
# Les utilisateurs INTERNES actifs, sans le super-utilisateur : les
# règles ne s'appliquent pas à lui, et compter en son nom
# déclarerait saine une base que personne ne peut lire.
membres = env["res.users"].sudo().search([
("active", "=", True),
("id", "!=", 1),
("share", "=", False),
], limit=LIMITE)
rapport["users"] = membres.mapped("login")
if not membres:
rapport["no_user"] = True
else:
vus = set(
env["ir.rule"].sudo()
.search([("global", "=", True), ("active", "=", True)])
.mapped("model_id.model")
)
for nom in sorted(vus - ATTENDUS):
modele = env.get(nom)
if modele is None or modele._abstract or modele._transient:
continue
try:
total = modele.sudo().search_count([])
except Exception:
continue
if not total:
continue
rapport["checked"] += 1
# Court-circuit : dès qu'UN utilisateur voit une ligne, le
# modèle n'est pas muet. Inutile d'interroger les autres.
visible = False
for membre in membres:
try:
if modele.with_user(membre).search([], limit=1):
visible = True
break
except Exception:
# Un refus d'accès n'est pas une ligne visible.
continue
if not visible:
rapport["models"].append({{"model": nom, "rows": total}})
except Exception as exc:
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
"""
def build_script(limite=25):
return SCRIPT.format(
limite=limite, attendus=list(ATTENDUS), debut=DEBUT, fin=FIN
)
def render(rapport):
if rapport.get("no_user"):
return [f"⚠ {t('No internal user to test visibility with.')}"]
muets = rapport.get("models") or []
lignes = [
f"🔍 {rapport.get('checked', 0)}"
f" {t('model(s) with a global rule and some data,')}"
f" {t('checked against')} {len(rapport.get('users') or [])}"
f" {t('internal user(s)')}"
]
if not muets:
lignes.append(f" ✅ {t('Every one of them is visible to someone.')}")
return lignes
lignes.append(
f" ❌ {len(muets)} {t('model(s) nobody can see a single row of')} :"
)
for entree in sorted(muets, key=lambda item: -item["rows"]):
lignes.append(
f" {entree['model']:<38} {entree['rows']:>8}"
f" {t('row(s)')}"
)
lignes.append(
f" {t('The data is there; a global rule hides all of it.')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report models that hold data no internal user can see,"
" because a global record rule filters every row out."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="config.conf")
parser.add_argument(
"--users",
type=int,
default=25,
help="how many internal users to test with (default: 25)",
)
config = parser.parse_args(argv)
souci = database_cleanup.require_matching_version(config.database)
if souci:
print(f"❌ {souci}")
return 2
try:
rapport = database_cleanup.run_shell(
config.database,
config.config,
build_script(config.users),
echo=lambda texte: print(f"⧖ {texte}", flush=True),
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if rapport.get("error"):
print(f"❌ {rapport['error']}")
return 2
print("\n".join(render(rapport)))
return 1 if rapport.get("models") else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,667 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Predict which customized SCSS will break the next version bump.
Background
----------
Customizing a website writes SCSS into ``ir_attachment``: files whose URL
carries ``.custom.``. That copy is frozen the day it is written, and it keeps
using the variables of THAT version.
A module can rename its variables between two Odoo versions. Measured on a
real database: ``website/static/src/scss/primary_variables.scss`` declared
``$o-theme-font-number`` in 12.0 and replaced the whole mechanism in 13.0 with
a ``$o-website-values-palettes`` map. A customization written in 2020 still
asked for the old name. The bundle then stops on::
Error: Undefined variable: "$o-theme-font-number".
This error occured while compiling the bundle 'web.assets_frontend'
So the rule is the same as for COW views:
a customization breaks when what it USES stops being DEFINED between
version N and version N+1.
Why not just start Odoo and open a page
---------------------------------------
Because that only tells you *after* the bump, on a page that renders a
« Style error » with no name attached: not which attachment, not which
variable, not since when. And a page has to be reached at all — a broken
frontend bundle is exactly when it is not.
Reading the stored SCSS against the target sources answers before the bump,
in a second, and names the attachment. Nothing is written here.
What it can miss
----------------
Which files a bundle really contains is decided by Odoo at runtime. This
compares against every SCSS of the INSTALLED modules of the target version,
which is wider: a variable defined in a module file that the bundle does not
include would be counted as defined. That direction is deliberate — it under-
reports rather than crying wolf on every customization.
Exit codes: 0 nothing to report, 1 customizations at risk, 2 tool failure.
"""
import argparse
import glob
import os
import re
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
try:
from script.todo import auto_ask
except Exception: # pragma: no cover - repli si le pilote est absent
auto_ask = None
def can_ask():
"""Peut-on poser une question ICI ?
Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
stdin laisse poser une invite qui part dans un tube : elle reste en
tampon, invisible, pendant que le processus attend — on croit à un
blocage et l'on tape Entrée à l'aveugle.
"""
return sys.stdin.isatty() and sys.stdout.isatty()
# Une variable SCSS commence par $ ; celles dont le nom commence par un tiret
# sont locales au fichier par convention Odoo ($-seen, $-font-numbers) et se
# définissent toujours au-dessus de leur usage.
RE_ANY = re.compile(r"\$([a-zA-Z][\w-]*)")
def used_names(content):
"""Noms LUS par le fichier, sans les liaisons.
Un « $nom » suivi d'un deux-points est toujours une liaison — déclaration,
valeur par défaut, ou argument nommé d'un @include — jamais la lecture
d'une variable. Sans cette réserve, « @include o-position-absolute(
$right: 50%) » se lisait comme l'usage d'un $right inexistant.
Le test se fait ici et non par une négation dans le motif : une négation
en tête de motif fait rétrograder le nom pour satisfaire la condition, et
rapporte « $botto » au lieu d'écarter « $bottom ». Mesuré.
"""
names = set()
for match in RE_ANY.finditer(content):
if content[match.end() :].lstrip(" \t").startswith(":"):
continue
names.add(match.group(1))
return names
RE_DEF = re.compile(r"^\s*\$([a-zA-Z][\w-]*)\s*:", re.M)
# Une variable peut aussi être LIÉE sans être définie : paramètre de mixin ou
# de fonction, variable de boucle. Les ignorer donnait six faux positifs sur
# le seul fichier mesuré — $bottom, $counter, $off, $on, $right, $value — et
# un détecteur qui crie à tort finit ignoré, ce qui vaut moins que rien.
RE_PARAM = re.compile(r"@(?:mixin|function)\s+[\w-]+\s*\(([^)]*)\)")
RE_EACH = re.compile(r"@each\s+([^i]+?)\s+in\s", re.S)
RE_FOR = re.compile(r"@for\s+\$([\w-]+)\s+from\s")
def bound_names(content):
"""Noms liés par le fichier lui-même, hors définitions « $x: … »."""
names = set()
for group in RE_PARAM.findall(content):
names.update(RE_ANY.findall(group))
for group in RE_EACH.findall(content):
names.update(RE_ANY.findall(group))
names.update(RE_FOR.findall(content))
return names
def run_psql(database, sql):
"""Interroger la base, lecture seule garantie par le serveur."""
env = os.environ.copy()
env["PGOPTIONS"] = (
"-c default_transaction_read_only=on -c statement_timeout=60000"
)
env["PSQLRC"] = ""
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-v",
"ON_ERROR_STOP=1",
"-d",
database,
"-tAF",
"\x1f",
"-c",
sql,
],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
raise RuntimeError(done.stderr.strip() or "psql failed")
return [line.split("\x1f") for line in done.stdout.splitlines() if line]
def filestore_dir(database, override=None):
if override:
return override
return os.path.join(
os.path.expanduser("~"),
".local",
"share",
"Odoo",
"filestore",
database,
)
def custom_scss(database, filestore):
"""[(id, url, contenu)] pour chaque SCSS personnalisé lisible."""
rows = run_psql(
database,
"SELECT id, url, COALESCE(store_fname, ''),"
" COALESCE(encode(db_datas, 'escape'), '') FROM ir_attachment"
" WHERE url LIKE '%.custom.%' AND url NOT LIKE '%.css'"
" ORDER BY id;",
)
lst = []
for row in rows:
if len(row) < 4:
continue
att_id, url, store_fname, db_datas = row[0], row[1], row[2], row[3]
content = ""
if store_fname:
path = os.path.join(filestore, store_fname)
if os.path.isfile(path):
with open(path, "r", encoding="utf-8", errors="replace") as h:
content = h.read()
elif db_datas:
content = db_datas
if content:
lst.append((int(att_id), url, content))
return lst
def installed_modules(database):
return {
row[0]
for row in run_psql(
database,
"SELECT name FROM ir_module_module WHERE state = 'installed';",
)
if row and row[0]
}
def defined_in_sources(version_dir, lst_module):
"""Variables définies par les SCSS des modules installés de la cible.
Le nom du module est le répertoire qui contient « static » : c'est ce qui
permet de ne pas compter les variables d'un module absent de la base, dont
aucune ligne n'atteindra jamais un bundle.
"""
defined = set()
pattern = os.path.join(version_dir, "**", "static", "**", "*.scss")
for path in glob.iglob(pattern, recursive=True):
parts = path.split(os.sep)
try:
module = parts[parts.index("static") - 1]
except (ValueError, IndexError):
continue
if module not in lst_module:
continue
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
defined.update(RE_DEF.findall(handle.read()))
except OSError:
continue
return defined
def module_file(base_url, version_dir):
"""Le fichier du module que la personnalisation masque, ou None.
Le premier segment de l'URL est le nom du module ; le reste est son
chemin. C'est ce fichier que reset_asset rendrait, donc c'est contre lui
que se lit ce que la copie a réellement changé.
"""
parts = base_url.strip("/").split("/")
if len(parts) < 2:
return None
pattern = os.path.join(version_dir, "**", parts[0], *parts[1:])
for candidate in glob.iglob(pattern, recursive=True):
if os.path.isfile(candidate):
return candidate
return None
def analyse(database, version_dir, filestore=None):
"""Un dictionnaire par personnalisation à risque.
Le contenu et le fichier du module y sont joints : l'appelant qui veut
montrer l'écart ne doit pas relire la base ni retrouver le chemin.
"""
filestore = filestore_dir(database, filestore)
lst_module = installed_modules(database)
defined = defined_in_sources(version_dir, lst_module)
lst_finding = []
for att_id, url, content in custom_scss(database, filestore):
own = set(RE_DEF.findall(content)) | bound_names(content)
missing = sorted(
{
name
for name in used_names(content)
if name not in own and name not in defined
}
)
if not missing:
continue
base_url, bundle = split_custom_url(url)
path = module_file(base_url, version_dir)
module_content = ""
if path:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
module_content = handle.read()
lst_finding.append(
{
"id": att_id,
"url": url,
"missing": missing,
"custom": content,
"base_url": base_url,
"bundle": bundle,
"module_path": path,
"module_content": module_content,
"version_dir": version_dir,
"database": database,
}
)
return lst_finding
def render_diff(finding):
"""Ce que la copie a changé, comparée au fichier du module de la cible.
C'est la seule chose qu'abandonner la copie ferait perdre. Sans elle, on
répond « oui, réinitialise » sans savoir si l'on jette trois lignes ou
une page entière.
"""
import difflib
lines = [
f"── id={finding['id']} {finding['url']} ──",
"",
f" {t('Missing:')} "
+ ", ".join("$" + name for name in finding["missing"]),
"",
]
if not finding["module_path"]:
lines += [
f" {t('No module file of that name in')}"
f" {finding['version_dir']} :",
f" {t('the target no longer ships it, so there is nothing to')}",
f" {t('fall back on. Read the copy before dropping it.')}",
]
return "\n".join(lines)
diff = list(
difflib.unified_diff(
finding["module_content"].splitlines(),
finding["custom"].splitlines(),
fromfile=finding["module_path"],
tofile=f"custom id={finding['id']}",
lineterm="",
n=2,
)
)
if len(diff) <= 2:
lines.append(f" {t('The copy is identical to the module file.')}")
return "\n".join(lines)
lines += [f" {line}" for line in diff]
plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
lines += [
"",
f" +{plus}/-{minus} {t('line(s): that is what resetting gives up.')}",
]
return "\n".join(lines)
def running_odoo_dir():
"""Le répertoire de la version qui répondra au shell, d'après le checkout.
Ce n'est PAS la cible : au moment où l'on prédit ce que le palier
cassera, le checkout est encore sur la version d'avant. C'est elle qui
exécutera reset_asset — ou ne le saura pas.
"""
try:
with open(".odoo-version", "r", encoding="utf-8") as handle:
return "odoo" + handle.read().strip()
except OSError:
return None
def reset_supported(odoo_dir=None):
"""Cette version sait-elle faire reset_asset ?
Mesuré : `web_editor.assets` et `reset_asset` n'existent qu'à partir de
13.0. Lancé sous odoo12.0, l'appel lève « KeyError: 'web_editor.assets' »
et ne change rien — c'est arrivé sur une vraie migration, et le correctif
a été cru appliqué alors qu'il avait échoué.
On regarde les sources plutôt qu'un numéro : c'est ce qui répondra.
"""
odoo_dir = odoo_dir or running_odoo_dir()
if not odoo_dir or not os.path.isdir(odoo_dir):
return True # Rien pour trancher : ne pas bloquer sur une supposition.
pattern = os.path.join(odoo_dir, "**", "web_editor", "models", "assets.py")
for path in glob.iglob(pattern, recursive=True):
with open(path, "r", encoding="utf-8", errors="replace") as handle:
if "def reset_asset" in handle.read():
return True
return False
def too_early_message(odoo_dir, database):
"""Pourquoi on ne peut pas encore corriger, et quand on pourra."""
return (
f"⛔ {t('Resetting needs Odoo 13.0 or later; this checkout is on')}"
f" {odoo_dir or '?'}.\n"
f" {t('The prediction stands, the fix does not: run it again once')}"
f" {t('the bump is done, on the upgraded database.')}\n"
f" {t('Applying it from here fails with')}"
" KeyError: 'web_editor.assets'"
f" {t('and changes nothing.')}"
)
def reset_command(lst_finding, database, config_path="./config.conf"):
"""La commande qui rend les fichiers de module. Rien n'est lancé ici."""
lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"]
for finding in lst_finding:
lines.append(
"env['web_editor.assets'].reset_asset"
f"('{finding['base_url']}', '{finding['bundle']}')"
)
lines += ["env.cr.commit()", "PY"]
return "\n".join(lines)
def apply_reset(lst_finding, database, config_path="./config.conf"):
"""Rendre les fichiers de module. ÉCRIT en base — le seul endroit ici.
Réversible seulement au sens où la personnalisation peut être réécrite :
reset_asset supprime la pièce jointe. D'où la sauvegarde préalable, qui
n'est pas une politesse mais la condition pour dire « oui » sans risque.
"""
script = "\n".join(
"env['web_editor.assets'].reset_asset"
f"('{f['base_url']}', '{f['bundle']}')"
for f in lst_finding
)
done = subprocess.run(
["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
input=script + "\nenv.cr.commit()\n",
capture_output=True,
text=True,
)
return done.returncode, done.stdout + done.stderr
def backup_custom(lst_finding, database):
"""Écrire les copies sur disque AVANT de les abandonner.
reset_asset supprime la pièce jointe : sans ceci, les lignes réellement
personnalisées ne seraient plus nulle part.
"""
directory = os.path.join(
"private", "odoo", "migration", database, "scss_backup"
)
os.makedirs(directory, exist_ok=True)
lst_path = []
for finding in lst_finding:
name = f"{finding['id']}_" + finding["url"].strip("/").replace(
"/", "_"
)
path = os.path.join(directory, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(finding["custom"])
lst_path.append(path)
return lst_path
def render(lst_finding, database, version_dir):
if not lst_finding:
return (
f"✅ -> {t('No customized SCSS is at risk in')} {version_dir}.\n"
)
lines = [
f"⚠️ {len(lst_finding)}"
f" {t('customized SCSS use(s) a variable that')} {version_dir}"
f" {t('no longer defines: the bundle will not compile.')}"
]
for finding in lst_finding:
lines.append(f" - id={finding['id']} {finding['url']}")
lines.append(
" " + ", ".join("$" + name for name in finding["missing"])
)
lines += [
f" {t('Each one is a copy frozen on an older version. Dropping it')}"
f" {t('restores the module file:')}",
]
lines += [
" " + line
for line in reset_command(lst_finding, database).splitlines()
]
lines.append(
f" {t('Read it first: what it holds beyond the stale variable is')}"
f" {t('a real customization, to re-apply as a small file.')}"
)
return "\n".join(lines) + "\n"
def prompt(lst_finding, database, config_path="./config.conf", ask=None):
"""Montrer, puis proposer de corriger. Rend True si l'on a écrit.
Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de
perdre on ne sait quoi. L'invite revient donc après chaque lecture :
regarder ne répond pas à la question.
Le défaut suit ce que le checkout SAIT faire : réinitialiser quand
`reset_asset` existe, rien du tout avant la 13.0. Un défaut « a » là où
la remise à zéro n'existe pas ferait boucler l'invite sur elle-même.
"""
odoo_dir = running_odoo_dir()
can_reset = reset_supported(odoo_dir)
if not can_reset:
print(too_early_message(odoo_dir, database))
defaut = "a" if can_reset else "n"
if ask is None:
ask = (
auto_ask.make_ask(defaut)
if auto_ask
else (lambda prompt="": input(prompt) or defaut)
)
while True:
choix = (
f" {t('Enter = reset them onto the module file')},"
if can_reset
else ""
)
answer = (
ask(
f"💬 {t('What do you want to do with these customizations?')}"
f" ({choix}"
f" v = {t('what the copy changed')},"
f" w = {t('full screen')},"
f" n = {t('nothing')}) : "
)
.strip()
.lower()
)
if answer == "v":
for finding in lst_finding:
print(render_diff(finding))
print()
continue
if answer == "w":
try:
from check_stale_scss_tui import run_tui
except ImportError:
print(f"ℹ️ {t('Full screen view unavailable.')}")
continue
if not run_tui(lst_finding):
for finding in lst_finding:
print(render_diff(finding))
continue
if answer == "a" and can_reset:
lst_path = backup_custom(lst_finding, database)
print(f"📦 {t('Saved before resetting')} :")
for path in lst_path:
print(f" {path}")
status, output = apply_reset(lst_finding, database, config_path)
print(output.strip()[-2000:])
if status:
print(f"❌ {t('Reset failed, nothing was changed.')}")
return False
print(f"✅ -> {t('Reset done.')}")
return True
return False
def split_custom_url(url):
"""« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle).
C'est la convention d'Odoo : le nom du bundle est encastré dans celui du
fichier. La reconstruire évite de faire deviner à l'utilisateur les deux
arguments de reset_asset au moment où il veut juste réparer.
"""
directory, name = os.path.split(url)
head, _sep, tail = name.partition(".custom.")
bundle = tail[:-5] if tail.endswith(".scss") else tail
return os.path.join(directory, head + ".scss"), bundle
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Predict which customized SCSS will break on the target version."
" Read-only unless --apply."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"-t",
"--target_version",
required=True,
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"--filestore",
default=None,
help="filestore directory (default: ~/.local/share/Odoo/filestore/<db>)",
)
parser.add_argument(
"-c",
"--config",
default="./config.conf",
help="Odoo config used by the shell for --apply",
)
parser.add_argument(
"--report-only",
action="store_true",
help="never ask anything, even in front of a terminal",
)
parser.add_argument(
"--diff",
action="store_true",
help="print what each copy changed, without asking",
)
parser.add_argument(
"--tui",
action="store_true",
help="browse the differences full screen",
)
parser.add_argument(
"--apply",
action="store_true",
help="reset them onto the module file (WRITES; saves a copy first)",
)
config = parser.parse_args(argv)
if not os.path.isdir(config.target_version):
print(
f"❌ {t('Target version directory not found')} :"
f" '{config.target_version}'"
)
return 2
try:
lst_finding = analyse(
config.database, config.target_version, config.filestore
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
print(render(lst_finding, config.database, config.target_version))
if not lst_finding:
return 0
if config.diff:
for finding in lst_finding:
print(render_diff(finding))
print()
if config.tui:
from check_stale_scss_tui import run_tui
if not run_tui(lst_finding):
for finding in lst_finding:
print(render_diff(finding))
if config.apply:
odoo_dir = running_odoo_dir()
if not reset_supported(odoo_dir):
print(too_early_message(odoo_dir, config.database))
return 2
lst_path = backup_custom(lst_finding, config.database)
print(f"📦 {t('Saved before resetting')} :")
for path in lst_path:
print(f" {path}")
status, output = apply_reset(
lst_finding, config.database, config.config
)
print(output.strip()[-2000:])
if status:
print(f"❌ {t('Reset failed, nothing was changed.')}")
return 2
print(f"✅ -> {t('Reset done.')}")
return 0
# Aucun drapeau : on demande, plutôt que d'imprimer un rapport et de
# laisser retrouver soi-même les deux arguments de reset_asset. Mais
# seulement devant un terminal — dans un tube, une invite bloquerait
# l'appelant sans que personne ne voie la question.
if not (config.diff or config.tui or config.report_only) and can_ask():
if prompt(lst_finding, config.database, config.config):
return 0
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Full-screen browsing of the customized SCSS a version bump would break.
Two things have to be read before answering « reset it »: what the copy
changed — the only thing resetting gives up — and which variables the target
no longer defines, which is why it breaks at all. Space switches between them.
The text report says the same, but a 1000-line diff in a terminal scrollback
is not something anyone reads before deciding.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
from check_stale_scss import render_diff, reset_command # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#files { width: 42; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def render_missing(finding):
"""Pourquoi ça casse : ce que la cible ne définit plus."""
lines = [
f"── id={finding['id']} {finding['url']} ──",
"",
f" {t('This copy uses variables that')} {finding['version_dir']}"
f" {t('no longer defines')} :",
"",
]
for name in finding["missing"]:
lines.append(f" ${name}")
lines += [
"",
f" {t('The copy was written against an older version and frozen')}",
f" {t('there. The module has since renamed what it relies on.')}",
"",
f" {t('Resetting restores')} :",
f" {finding['module_path'] or t('(nothing: the target no longer ships this file)')}",
]
return "\n".join(lines)
def build_app(lst_finding):
"""Build the application. Textual is imported here, not at module level.
The module stays importable — and therefore testable — on a machine
without Textual, which is also what lets the caller fall back to the text
report rather than fail.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
class StaleScssApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", "Quit"),
("space,tab", "toggle", "Diff / why"),
("c", "copy", "Copy reset command"),
]
def __init__(self, lst_finding):
super().__init__()
self.lst_finding = lst_finding
self.why = False
self.index = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="files", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = "Customized SCSS at risk"
table = self.query_one("#files", DataTable)
table.add_columns("file", "+/-", "missing")
for finding in self.lst_finding:
table.add_row(
os.path.basename(finding["url"])[:30],
self._weight(finding),
str(len(finding["missing"])),
key=str(finding["id"]),
)
self._show()
def _weight(self, finding):
"""Combien la copie s'écarte, pour trier d'un coup d'œil."""
if not finding["module_path"]:
return "—"
import difflib
diff = list(
difflib.unified_diff(
finding["module_content"].splitlines(),
finding["custom"].splitlines(),
lineterm="",
n=0,
)
)
plus = sum(
1 for x in diff if x[:1] == "+" and not x.startswith("+++")
)
minus = sum(
1 for x in diff if x[:1] == "-" and not x.startswith("---")
)
return f"+{plus}/-{minus}"
def _show(self):
if not self.lst_finding:
return
finding = self.lst_finding[self.index]
which = "why it breaks" if self.why else "what the copy changed"
self.query_one("#head", Static).update(
f"{finding['url']} · id={finding['id']}\n"
f"[{which}] — space to switch, c to copy the reset command"
)
render = render_missing if self.why else render_diff
self.query_one("#content", Static).update(render(finding))
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "files" and self.lst_finding:
self.index = event.cursor_row
self._show()
def action_toggle(self):
self.why = not self.why
self._show()
def action_copy(self):
"""Mettre la commande de réparation dans le presse-papier.
Sans cela il faut la recopier à la main depuis un plein écran,
avec deux arguments dont une faute ne se voit pas.
"""
finding = self.lst_finding[self.index]
command = reset_command([finding], finding["database"])
try:
self.copy_to_clipboard(command)
self.notify(t("Reset command copied."))
except Exception:
self.notify(command, timeout=20)
return StaleScssApp(lst_finding)
def run_tui(lst_finding, run_app=True):
"""Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
de terminal, ou Textual absent. Se taire ferait réafficher le rapport
texte à la place de l'écran demandé, sans rien qui distingue les deux.
"""
if not lst_finding:
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.available():
if not textual_setup.in_venv():
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(lst_finding)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
app.run()
return True

View file

@ -0,0 +1,390 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Show what a website COW copy holds, and why the next version breaks it.
Read-only. Answers the two questions someone asks before neutralizing a copy,
and neither is answerable from the warning alone:
**What do I lose?** The copy is compared with the module view it shadows. That
is the customization someone made, and often it is three lines — an id, a
container width — for which nobody would hold up a migration.
**Why does it break?** The module declaration is shown in the current version
and in the target. The pair is the whole explanation: a template declared
without ``inherit_id`` is a standalone document, one declared with it must
hold inheritance specs. A copy frozen in the first shape cannot be applied in
the second, and Odoo stops on « cannot be located in parent view ».
Nothing here writes. Neutralizing is ``neutralize_cow_views.py --apply``, and
undoing it is ``--restore``.
"""
import argparse
import difflib
import json
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from check_cow_views import analyse, find_module_dir, t # noqa: E402
# The declaration of a template spans a few lines; showing the opening tag and
# what follows is enough to see its shape, and short enough to compare two
# versions side by side without scrolling.
DECL_LINES = 4
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tAc", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Query failed on '{database}': {result.stderr.strip()}"
)
return result.stdout
def unwrap_arch(value):
"""The arch as text, whatever the column type.
``arch_db`` is text up to 15.0 and jsonb from 16.0. The same unwrapping as
the sibling scripts, kept identical on purpose: a second reading of the
same column that differs on an edge case would report a drift nobody made.
"""
text = (value or "").strip()
if text.startswith("{") and '"' in text:
try:
data = json.loads(text)
except ValueError:
return value or ""
if isinstance(data, dict) and data:
for lang in ("en_US", *sorted(data)):
if isinstance(data.get(lang), str):
return data[lang]
return value or ""
def fetch_arch(database, view_id):
"""The stored arch of one view."""
return unwrap_arch(
run_psql(
database,
f"SELECT arch_db::text FROM ir_ui_view WHERE id = {int(view_id)};",
)
)
def fetch_module_view(database, key):
"""(id, arch) of the module view a copy shadows, or (None, '').
The module view is the one carrying that key WITHOUT a website: that is
exactly the pairing Odoo itself makes, and the reason renaming a key is
enough to unpair a copy.
"""
safe = key.replace("'", "''")
out = run_psql(
database,
"SELECT id, arch_db::text FROM ir_ui_view"
f" WHERE key = '{safe}' AND website_id IS NULL"
" ORDER BY id LIMIT 1;",
).strip()
if not out:
return None, ""
view_id, _, arch = out.partition("|")
return int(view_id), unwrap_arch(arch)
def declaration(version_dir, key):
"""(path, snippet) of how the sources of one version declare this key.
Returns None when the module or the node is not found — a fact worth
showing as such, since « the module no longer declares it » is itself a
reason a copy breaks.
"""
module_name, _, template_id = key.partition(".")
if not template_id:
return None
module_dir = find_module_dir(version_dir, module_name)
if module_dir is None:
return None
try:
from lxml import etree
except ImportError:
return None
pattern = os.path.join(module_dir, "**", "*.xml")
import glob
for path in sorted(glob.glob(pattern, recursive=True)):
try:
tree = etree.parse(path)
except etree.XMLSyntaxError:
continue
for element in tree.getroot().iter():
if element.get("id") != template_id:
continue
if element.tag not in ("template", "record"):
continue
# The source line, not a re-serialization: what the file actually
# says is what a reader will grep for.
with open(path, "r", encoding="utf-8", errors="replace") as handle:
lines = handle.read().splitlines()
start = max((element.sourceline or 1) - 1, 0)
return path, "\n".join(lines[start : start + DECL_LINES])
return None
def database_version_dir(database):
"""The odoo<x>.0 directory matching what the DATABASE says it is.
Not `.odoo-version`: the checkout is switched to the TARGET before this
runs, so reading it would compare the target with itself and show the same
declaration twice — which is what it did until this was measured on a real
migration. The database, at that moment, is still on the previous version
and says so in ir_module_module.
"""
try:
out = run_psql(
database,
"SELECT latest_version FROM ir_module_module"
" WHERE name = 'base';",
).strip()
except RuntimeError:
return None
if not out:
return None
parts = out.split(".")
if len(parts) < 2:
return None
return f"odoo{parts[0]}.{parts[1]}"
def collect(database, target_version, current_version=None):
"""Everything needed to judge each at-risk copy. No writes."""
current_version = current_version or database_version_dir(database)
lst_at_risk, _, _ = analyse(database, target_version)
lst_finding = []
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
module_id, module_arch = fetch_module_view(database, key)
lst_finding.append(
{
"id": view_id,
"key": key,
"mode": mode,
"target_mode": target_mode,
"website_id": website_id,
"reason": reason,
"copy_arch": fetch_arch(database, view_id),
"module_id": module_id,
"module_arch": module_arch,
"decl_current": (
declaration(current_version, key)
if current_version
else None
),
"decl_target": declaration(target_version, key),
"current_version": current_version,
"target_version": target_version,
}
)
return lst_finding
def render_diff(finding):
"""What the copy changed, compared with the module view it shadows."""
lines = [
f"── id={finding['id']} {finding['key']}"
f" (website={finding['website_id']}) ──",
"",
]
if finding["module_id"] is None:
lines += [
f" {t('No module view carries this key, so there is nothing to')}",
f" {t('compare: this copy is a page made in the website editor.')}",
]
return "\n".join(lines)
left = finding["module_arch"].splitlines()
right = finding["copy_arch"].splitlines()
diff = [
line
for line in difflib.unified_diff(
left,
right,
fromfile=f"module id={finding['module_id']}",
tofile=f"copy id={finding['id']}",
lineterm="",
n=1,
)
]
if len(diff) <= 2:
lines.append(f" {t('The copy is identical to the module view.')}")
return "\n".join(lines)
lines += [f" {line}" for line in diff]
n_plus = sum(
1 for x in diff if x.startswith("+") and not x.startswith("+++")
)
n_minus = sum(
1 for x in diff if x.startswith("-") and not x.startswith("---")
)
lines += [
"",
f" {n_plus} {t('line(s) added')}, {n_minus} {t('removed')} —"
f" {t('this is what neutralizing gives up.')}",
]
return "\n".join(lines)
def render_shape(finding):
"""Why it breaks: the declaration in each version, side by side."""
lines = [
f"── id={finding['id']} {finding['key']} ──",
"",
f" {t(finding['reason'])}",
"",
]
for label, decl in (
(finding["current_version"], finding["decl_current"]),
(finding["target_version"], finding["decl_target"]),
):
lines.append(f" <!-- {label or '?'} -->")
if decl is None:
lines += [
f" ({t('the module no longer declares this template')})",
"",
]
continue
path, snippet = decl
lines.append(f" <!-- {path} -->")
lines += [f" {line}" for line in snippet.splitlines()]
shape = (
t("inheritance specs (needs inherit_id)")
if re.search(r"inherit_id\s*=", snippet)
else t("a standalone template")
)
lines += [f" -> {shape}", ""]
lines += [
f" {t('Odoo changing the shape of its own template is NOT the')}",
f" {t('problem: on a database without a copy, the module upgrade')}",
f" {t('rewrites the view and nothing breaks. It breaks here because')}",
f" {t('a COPY exists and froze the old shape — the copy follows the')}",
f" {t('module and becomes an extension, while still holding a')}",
f" {t('standalone template. Odoo then applies that template as an')}",
f" {t('inheritance spec and stops on « cannot be located in parent')}",
f" {t('view ».')}",
"",
]
# Sans ceci, cette vue se lit comme « du code Odoo qui change », et l'on
# se demande pourquoi c'est notre affaire. Ce qui la rend nôtre est ce que
# la copie contient — la seule chose que neutraliser ferait perdre.
lines += _what_the_copy_holds(finding)
return "\n".join(lines)
def _what_the_copy_holds(finding):
"""Ce que la copie porte en propre, dit en une ou deux lignes.
Relie les deux moitiés de la question : le mécanisme explique POURQUOI ça
casse, ceci dit CE QU'IL EN COÛTE. Une copie identique à sa jumelle ne
coûte rien, et le savoir change la décision.
"""
if finding["module_id"] is None:
return [
f" {t('This copy has no module view of that name: it is a page')}",
f" {t('made in the website editor, and nothing holds its content.')}",
]
left = finding["module_arch"].splitlines()
right = finding["copy_arch"].splitlines()
diff = [
line
for line in difflib.unified_diff(left, right, lineterm="", n=0)
if line[:1] in "+-" and not line.startswith(("+++", "---"))
]
if not diff:
return [
f" {t('This copy is IDENTICAL to the module view it shadows: it')}",
f" {t('holds no customization, so neutralizing it loses nothing.')}",
]
n_plus = sum(1 for x in diff if x.startswith("+"))
n_minus = sum(1 for x in diff if x.startswith("-"))
return [
f" {t('This copy differs from the module view by')}"
f" +{n_plus}/-{n_minus} {t('line(s):')}",
f" {t('that is the customization, and all neutralizing gives up.')}",
f" {t('Run without --shape to read it.')}",
]
def render_all(lst_finding, shape=False):
"""The whole report, one block per finding."""
if not lst_finding:
return f"✅ {t('No website COW view is at risk.')}\n"
render = render_shape if shape else render_diff
return "\n\n".join(render(f) for f in lst_finding) + "\n"
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Show what each at-risk website COW copy holds, and why the next"
" version breaks it (read-only)."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"-t",
"--target_version",
required=True,
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"--current",
default=None,
help="current Odoo source directory (default: from .odoo-version)",
)
parser.add_argument(
"--shape",
action="store_true",
help="show the declarations instead of the customization diff",
)
parser.add_argument(
"--tui",
action="store_true",
help="browse full screen, switching between the two views",
)
config = parser.parse_args(argv)
if not os.path.isdir(config.target_version):
print(
f"❌ {t('Target version directory not found')} :"
f" '{config.target_version}'"
)
return 2
try:
lst_finding = collect(
config.database, config.target_version, config.current
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if config.tui and lst_finding:
from cow_drift_tui import run_tui
if run_tui(lst_finding):
return 1
print(render_all(lst_finding, shape=config.shape))
return 1 if lst_finding else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,202 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Full-screen browsing of the at-risk website COW copies.
Read-only, without exception: this screen exists to decide, and deciding
supposes having looked. Neutralizing stays a separate, explicit command.
Two views of the same copy, one key apart
-----------------------------------------
« What do I lose » and « why does it break » are different questions with
different answers, and putting them side by side would halve the width of
each on a screen that already shows XML. They share the pane instead, and
``space`` switches — the header always says which one is showing, because a
diff and a declaration look alike at a glance.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#views { width: 38; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def build_app(lst_finding):
"""Build the application. Textual is imported here, not at module level.
The module stays importable — and therefore testable — on a machine
without Textual, which is also what lets the caller fall back to the text
report rather than fail.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
from cow_drift import render_diff, render_shape
class DriftApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", "Quit"),
("space,tab", "toggle", "Diff / declarations"),
("c", "copy", "Copy"),
]
def __init__(self, lst_finding):
super().__init__()
self.lst_finding = lst_finding
self.shape = False
self.index = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="views", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = "Website COW copies at risk"
table = self.query_one("#views", DataTable)
table.add_columns("copy", "+/-")
for finding in self.lst_finding:
table.add_row(
(finding["key"] or str(finding["id"]))[:28],
self._weight(finding),
key=str(finding["id"]),
)
self._show()
def _weight(self, finding):
"""How much the copy diverges, so the list can be triaged."""
if finding["module_id"] is None:
return "—"
import difflib
diff = list(
difflib.unified_diff(
finding["module_arch"].splitlines(),
finding["copy_arch"].splitlines(),
lineterm="",
n=0,
)
)
plus = sum(
1
for x in diff
if x.startswith("+") and not x.startswith("+++")
)
minus = sum(
1
for x in diff
if x.startswith("-") and not x.startswith("---")
)
return f"+{plus}/-{minus}"
def _show(self):
if not self.lst_finding:
return
finding = self.lst_finding[self.index]
which = "declarations" if self.shape else "what the copy changed"
self.query_one("#head", Static).update(
f"{finding['key']} · id={finding['id']}"
f" · website={finding['website_id']}\n"
f"[{which}] — space to switch"
)
render = render_shape if self.shape else render_diff
self.query_one("#content", Static).update(render(finding))
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "views" and self.lst_finding:
self.index = event.cursor_row
self._show()
def action_toggle(self):
self.shape = not self.shape
self._show()
def action_copy(self):
if not self.lst_finding:
return
from cow_drift import render_diff, render_shape
render = render_shape if self.shape else render_diff
# Truncated keeping the END: that is where the added lines are,
# and what someone is most often after.
self.copy_to_clipboard(
render(self.lst_finding[self.index])[-100_000:]
)
self.notify("Copied.")
return DriftApp(lst_finding)
def run_tui(lst_finding, run_app=True):
"""Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
de terminal, ou Textual absent. Se taire faisait réafficher le rapport
texte à la place de l'écran demandé — la même sortie que la touche
précédente, sans rien qui distingue les deux.
Textual n'est installé que dans `.venv.erplibre`. Lancer ce script
directement, maintenant qu'il porte le bit d'exécution, prend le python
du système : le shebang ne connaît pas le venv. C'est le chemin qui rend
ce message nécessaire.
"""
if not lst_finding:
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.available():
# Le conseil AVANT la proposition d'installer : Textual est déjà là,
# dans le venv. Proposer de l'installer une seconde fois sous
# « --user » répond à côté de la question.
if not textual_setup.in_venv():
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(lst_finding)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
return False
if not run_app:
return app
app.run()
return True

View file

@ -0,0 +1,606 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Run the OCA « Database cleanup » purges, in order, until nothing moves.
Why a tool rather than the eight screens
----------------------------------------
A migration leaves obsolete models, columns, tables, data and menus behind.
The `database_cleanup` module offers one wizard per kind, and they are not
independent: purging a model frees the columns that referenced it, purging a
table frees the data rows pointing at it. One pass is never enough — the
order matters, and so does going round again.
Errors are expected, and are not a reason to stop
-------------------------------------------------
Some entries cannot be purged: a foreign key still holds, a record is
protected, a module refuses. Purging the whole list at once loses everything
to the first failure, so each entry is purged INSIDE ITS OWN SAVEPOINT: a
refusal rolls back that entry alone and the rest of the pass continues. What
one pass could not repair, the next may — once its neighbours are gone.
The loop stops when a full pass repairs nothing new. Whatever remains is
reported as a warning, not as a failure: a database can carry leftovers that
nothing can remove, and refusing to move on would help no one.
Exit codes: 0 nothing left, 1 leftovers remain, 2 the tool failed.
"""
import argparse
import json
import os
import signal
import subprocess
import sys
import threading
import time
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# L'ordre demandé, et il compte : purger un modèle libère les colonnes qui le
# référençaient, purger une table libère les données qui la visaient. Les
# index viennent après les purges — inutile d'indexer ce qu'on va retirer.
#
# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property par une
# colonne jsonb. On le garde dans la liste et on le saute quand il manque,
# plutôt que d'échouer sur une version où il n'a plus lieu d'être.
ORDER = [
("models", "cleanup.purge.wizard.model"),
("modules", "cleanup.purge.wizard.module"),
("columns", "cleanup.purge.wizard.column"),
("tables", "cleanup.purge.wizard.table"),
("data", "cleanup.purge.wizard.data"),
("menus", "cleanup.purge.wizard.menu"),
("indexes", "cleanup.create_indexes.wizard"),
("properties", "cleanup.purge.wizard.property"),
]
LABEL = {
"models": "obsolete models",
"modules": "obsolete modules",
"columns": "obsolete columns",
"tables": "obsolete tables",
"data": "obsolete data entries",
"menus": "obsolete menu entries",
"indexes": "missing indexes",
"properties": "obsolete properties",
}
STEP = "ERPLIBRE_CLEANUP_STEP"
START = "ERPLIBRE_CLEANUP_START"
END = "ERPLIBRE_CLEANUP_END"
SHELL_SCRIPT = """
import json
try:
from odoo.exceptions import UserError
except Exception:
class UserError(Exception):
pass
ORDER = %(order)r
MAX_ROUND = %(max_round)d
DRY_RUN = %(dry_run)s
t_all = "%(all_label)s"
report = {"rounds": [], "missing": [], "failed": []}
def note(label, name, exc):
report["failed"].append([label, name, str(exc)[:200]])
def step(label, name, index=0, total=0):
# Dire ce qu'on fait PENDANT qu'on le fait. Sans cela l'outil se taisait
# jusqu'au rapport final : mesuré, dix-sept minutes de silence complet
# sur une base de 5984 modules, impossible à distinguer d'un blocage.
detail = f" {index}/{total}" if total else ""
print(f"%(step)s {label}{detail} {name}", flush=True)
def recover():
# Rendre la transaction utilisable, quoi qu'il vienne de se passer.
#
# PAS de point de reprise ici, et c'est mesure : purge_modules.find()
# purge lui-meme (purge_modules.py:91), et purge_columns appelle
# cr.commit() (purge_columns.py:57). Or un COMMIT DETRUIT tous les
# points de reprise : le notre disparaissait sous nos pieds, d'ou
# « savepoint ... does not exist », puis tout ce qui suivait mourait
# sur une transaction avortee. Un rollback franc remet les compteurs
# a zero ; ce qu'on perd est borne, puisqu'on valide apres chaque
# entree reussie.
try:
env.cr.rollback()
except Exception:
pass
try:
for index in range(MAX_ROUND):
this_round = []
purged_this_round = 0
step("pass", str(index + 1), index + 1, MAX_ROUND)
for label, model in ORDER:
if model not in env:
if label not in report["missing"]:
report["missing"].append(label)
continue
ok = 0
errors = []
would = []
try:
wizard = env[model].create({})
# Les noms sont matérialisés TOUT DE SUITE : les relire plus
# tard relancerait une requête, et c'est là que le script
# mourait quand la transaction avait été avortée entre-temps.
todo = [
(line, line.name or str(line.id))
for line in wizard.purge_line_ids
]
except UserError:
# « No orphaned models found » : le module signale le VIDE
# en levant. Le compter comme un échec faisait passer une
# base saine pour cassée.
recover()
this_round.append({"kind": label, "purged": 0,
"errors": [], "would": []})
continue
except Exception as exc:
recover()
note(label, "-", exc)
continue
# find() peut avoir purgé de lui-même : on garde ce qu'il a fait.
if not DRY_RUN:
try:
env.cr.commit()
except Exception:
recover()
if DRY_RUN:
would = [name for _line, name in todo]
elif todo:
# LE LOT D'ABORD, et ce n'est pas une optimisation de
# confort. `purge()` d'un module appelle
# button_immediate_uninstall(), qui RECHARGE LE REGISTRE
# ENTIER — 5984 modules à relire. En purgeant ligne par
# ligne j'en faisais un rechargement PAR MODULE : mesuré,
# dix secondes chacun, dix-sept minutes pour neuf modules.
# Le module OCA purge le lot en un seul appel, donc un seul
# rechargement.
#
# L'isolement ligne à ligne garde tout son sens — un refus
# ne doit pas emporter la catégorie — mais il ne coûte que
# lorsqu'il sert vraiment, c'est-à-dire après un échec.
step(label, t_all, 0, len(todo))
try:
wizard.purge_line_ids.purge()
env.cr.commit()
ok = len(todo)
except Exception:
recover()
for index, (line, name) in enumerate(todo, start=1):
step(label, name, index, len(todo))
try:
line.purge()
# Valider ENTRÉE PAR ENTRÉE : un refus plus loin
# ne doit pas emporter ce qui vient d'être
# réparé.
env.cr.commit()
ok += 1
except Exception as exc:
recover()
errors.append([name, str(exc)[:160]])
purged_this_round += ok
this_round.append({"kind": label, "purged": ok,
"errors": errors, "would": would})
report["rounds"].append(this_round)
# On s'arrête quand une passe ENTIÈRE n'a plus rien réparé : ce qui
# résistait au tour d'avant résistera encore. En simulation, une
# seule passe suffit — rien ne change, donc rien ne se libère.
if purged_this_round == 0 or DRY_RUN:
break
except Exception as exc:
# Le rapport de CE QUI A ÉTÉ FAIT vaut plus que la trace de ce qui a
# cassé : sans lui, on ne sait même pas si la base a été touchée.
note("*", "fatal", exc)
if DRY_RUN:
# Une simulation qui écrit n'est pas une simulation. Et celle-ci
# écrivait : purge_modules.find() purge de lui-meme, avant meme
# qu'on ait rien decide. On defait tout ce que la lecture a
# provoque — c'est possible ici, justement parce qu'on n'a valide
# aucune entree.
recover()
print("%(start)s")
print(json.dumps(report))
print("%(end)s")
"""
def build_script(max_round, dry_run):
return SHELL_SCRIPT % {
"order": ORDER,
"max_round": max_round,
"dry_run": "True" if dry_run else "False",
"start": START,
"end": END,
"step": STEP,
"all_label": "the whole batch",
}
def checkout_version():
"""La version d'Odoo que le checkout servira, d'après .odoo-version."""
try:
with open(".odoo-version", "r", encoding="utf-8") as handle:
return handle.read().strip()
except OSError:
return None
def database_version(database):
"""La version que la BASE dit être la sienne, via le module `base`."""
env = os.environ.copy()
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-d",
database,
"-tAc",
"SELECT latest_version FROM ir_module_module WHERE name='base';",
],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return None
parts = done.stdout.strip().split(".")
return ".".join(parts[:2]) if len(parts) >= 2 else None
def require_matching_version(database):
"""Refuser d'ouvrir une base avec un Odoo d'une autre version.
Un Odoo plus ancien qui charge une base plus récente ne se contente pas
d'échouer : il ÉCRIT en chemin — mesuré, un shell en 14.0 lancé sur une
base 17.0 est parti réécrire ir_model avant de mourir sur un jsonb qu'il
ne connaissait pas. Le checkout suit la migration, et rien ne garantit
qu'il soit resté sur la version de la base qu'on veut nettoyer.
"""
checkout = checkout_version()
database_side = database_version(database)
if not checkout or not database_side:
return None # Rien pour trancher : ne pas bloquer sur une supposition.
if checkout != database_side:
return (
f"{t('The checkout is on Odoo')} {checkout}"
f" {t('and the database on')} {database_side}."
f"\n {t('Opening it with the wrong version writes to it before')}"
f" {t('failing. Switch the checkout first.')}"
)
return None
def module_state(database, module="database_cleanup"):
"""L'état du module dans cette base, ou None si on ne peut pas lire."""
env = os.environ.copy()
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-d",
database,
"-tAc",
f"SELECT state FROM ir_module_module WHERE name='{module}';",
],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return None
return done.stdout.strip() or None
def install_module(database, module="database_cleanup", timeout=1800):
"""Poser le module avant de s'en servir.
Sans lui, aucun assistant n'existe et l'outil rend « rien à faire » sur
une base qui en aurait eu besoin — un silence qu'on prend pour un
succès. La migration l'installait plus tard, à l'étape 3 ; l'attendre
revenait à nettoyer trop tard.
"""
done = subprocess.run(
["./script/addons/install_addons.sh", database, module],
capture_output=True,
text=True,
timeout=timeout,
)
return done.returncode, done.stdout + done.stderr
def kill_group(process):
"""Tuer le GROUPE : « ./odoo_bin.sh » est un script bash.
Un terminate() sur lui tue le script et laisse odoo-bin vivant, sur la
base, avec ses verrous. La leçon a déjà été payée une fois par six
serveurs orphelins.
"""
try:
group = os.getpgid(process.pid)
except OSError:
group = None
for signal_number in (signal.SIGTERM, signal.SIGKILL):
if group is None:
break
try:
os.killpg(group, signal_number)
except OSError:
break
try:
process.wait(timeout=20)
return
except subprocess.TimeoutExpired:
continue
def run_shell(database, config_path, script, timeout=3600, echo=None):
"""Pousser le script dans « odoo-bin shell » et rendre son rapport.
On LIT au fil de l'eau plutôt que de tout capturer : ce nettoyage dure
des minutes — dix-sept, mesurées, sur une base de 5984 modules — et la
version précédente n'affichait rien avant la fin. Un travail qui avance
et un blocage se ressemblent alors trait pour trait, et c'est ainsi
qu'on interrompt une réparation à moitié faite.
Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne
lit comme rapport que ce qui est entre elles. Leur absence est une
erreur franche, pas un rapport vide qu'on prendrait pour « rien à
faire ».
"""
process = subprocess.Popen(
[
"./odoo_bin.sh",
"shell",
"-c",
config_path,
"-d",
database,
"--log-level=warn",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
# Le délai ne peut pas vivre dans la boucle de lecture : celle-ci BLOQUE
# tant qu'aucune ligne n'arrive, et c'est exactement le cas qu'il faut
# couvrir. Un minuteur à part, donc.
expire = {"fired": False}
def couper():
expire["fired"] = True
kill_group(process)
minuteur = threading.Timer(timeout, couper)
minuteur.daemon = True
minuteur.start()
lst_line = []
try:
process.stdin.write(script)
process.stdin.close()
for line in process.stdout:
lst_line.append(line)
if echo and line.startswith(STEP):
echo(line[len(STEP) :].strip())
process.wait()
finally:
minuteur.cancel()
if process.poll() is None:
kill_group(process)
output = "".join(lst_line)
if expire["fired"]:
raise RuntimeError(
f"{t('The cleanup was still running after')} {timeout}"
f" {t('seconds and was stopped.')}"
)
if START not in output or END not in output:
raise RuntimeError(
f"{t('The cleanup produced no report.')}\n{output.strip()[-1500:]}"
)
body = output.split(START, 1)[1].split(END, 1)[0].strip()
try:
return json.loads(body)
except ValueError as exc:
raise RuntimeError(f"{t('Unreadable report')} : {exc}")
def make_echo(depart):
"""Relayer la progression du script poussé, horodatée.
Le temps écoulé n'est pas décoratif : c'est lui qui distingue « ça
avance lentement » de « ça ne bouge plus », et c'est précisément la
question qu'on se pose devant un écran muet.
"""
def echo(ligne):
morceaux = ligne.split(" ", 1)
etiquette = morceaux[0]
reste = morceaux[1] if len(morceaux) > 1 else ""
secondes = int(time.monotonic() - depart)
if etiquette == "pass":
print(f" [{secondes:>4}s] {t('pass')} {reste}")
else:
print(
f" [{secondes:>4}s] {t(LABEL.get(etiquette, etiquette))}"
f" — {t(reste) if reste == 'the whole batch' else reste}"
)
return echo
def leftovers(report):
"""[(kind, name, message)] de ce que la DERNIÈRE passe n'a pas pu purger."""
if not report.get("rounds"):
return []
lst = []
for entry in report["rounds"][-1]:
for name, message in entry.get("errors", []):
lst.append((entry["kind"], name, message))
for kind, name, message in report.get("failed", []):
lst.append((kind, name, message))
return lst
def render(report, database):
lines = [f"🧹 {t('Database cleanup on')} '{database}'"]
# La simulation ne répare rien : la présenter comme un échec ferait
# croire à 585 refus là où il n'y a que 585 candidats.
lst_would = [
(entry["kind"], name)
for this_round in report.get("rounds", [])
for entry in this_round
for name in entry.get("would", [])
]
if lst_would:
lines.append(
f"ℹ {len(lst_would)} {t('entries would be purged')}"
f" ({t('nothing was changed')}) :"
)
by_kind = {}
for kind, _name in lst_would:
by_kind[kind] = by_kind.get(kind, 0) + 1
for kind, count in by_kind.items():
lines.append(f" - {t(LABEL.get(kind, kind))} : {count}")
# Les échecs comptent AUSSI en simulation : une catégorie qui ne
# s'ouvre même pas est une information, pas un silence.
for kind, name, message in report.get("failed", []):
lines.append(f" ⚠ [{kind}] {name} : {message[:90]}")
for kind in report.get("missing", []):
lines.append(
f" ℹ {t(LABEL.get(kind, kind))} :"
f" {t('no such wizard in this version, skipped.')}"
)
return "\n".join(lines) + "\n"
total = 0
for index, this_round in enumerate(report.get("rounds", []), start=1):
purged = sum(entry["purged"] for entry in this_round)
total += purged
detail = ", ".join(
f"{t(LABEL[entry['kind']])} {entry['purged']}"
for entry in this_round
if entry["purged"]
)
lines.append(
f" {t('pass')} {index} : {purged} {t('purged')}"
+ (f" — {detail}" if detail else "")
)
for kind in report.get("missing", []):
lines.append(
f" ℹ {t(LABEL.get(kind, kind))} :"
f" {t('no such wizard in this version, skipped.')}"
)
lst_left = leftovers(report)
if not lst_left:
lines.append(f"✅ -> {total} {t('entries purged, nothing left.')}")
return "\n".join(lines) + "\n"
lines.append(
f"⚠️ {total} {t('purged;')} {len(lst_left)}"
f" {t('could not be, and are left as they are')} :"
)
for kind, name, message in lst_left[:20]:
lines.append(f" - [{kind}] {name} : {message[:90]}")
if len(lst_left) > 20:
lines.append(f" … {len(lst_left) - 20} {t('more')}")
lines.append(
f" {t('A database can carry leftovers nothing can remove.')}"
f" {t('This is a warning, not a failure.')}"
)
return "\n".join(lines) + "\n"
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Run the OCA database_cleanup purges in order, repeating until"
" a full pass repairs nothing new."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="./config.conf")
parser.add_argument(
"--max-round",
type=int,
default=10,
help="how many full passes at most (default 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="list what would be purged, purge nothing",
)
parser.add_argument("--timeout", type=int, default=3600)
config = parser.parse_args(argv)
mismatch = require_matching_version(config.database)
if mismatch:
print(f"⛔ {mismatch}")
return 2
state = module_state(config.database)
if state != "installed":
print(
f"⧖ {t('database_cleanup is not installed on this base;')}"
f" {t('installing it first.')}"
)
code, output = install_module(config.database)
if code:
print(output.strip()[-1500:])
print(f"❌ {t('Could not install database_cleanup.')}")
return 2
print(f"⧖ {t('Cleaning')} '{config.database}'…")
print(
f" {t('Purging modules reloads the whole registry: on a big')}"
f" {t('addons path this takes minutes, not seconds.')}"
)
depart = time.monotonic()
try:
report = run_shell(
config.database,
config.config,
build_script(config.max_round, config.dry_run),
timeout=config.timeout,
echo=make_echo(depart),
)
except (RuntimeError, subprocess.SubprocessError, OSError) as exc:
print(f"❌ {exc}")
return 2
print(render(report, config.database))
print(f"⌛ {t('Took')} {int(time.monotonic() - depart)} {t('seconds.')}")
return 1 if leftovers(report) else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,249 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Rendre visibles les documents DMS qu'une migration a rendus muets.
Le symptôme : après le palier 13, les documents DMS « ont disparu ». Ils
n'ont pas disparu. Mesuré sur une base migrée : 69 fichiers, 16 dossiers,
23 Mo de `content_binary`, présents à l'identique de la 12 à la 18. Rien
n'a été perdu.
Ce qui a changé, c'est le MODÈLE DE SÉCURITÉ. En 12, MuK DMS ne filtrait
que sur la société :
['|', ('company','=',False), ('company','child_of',[...])]
OCA DMS, à partir de la 13, ajoute des règles GLOBALES sur une permission
calculée :
[('permission_read', '=', user.id)]
`_search_permission_read` n'accorde l'accès que par deux portes :
1. Une `dms.access.group` dont l'utilisateur est membre, reliée au
dossier — la migration n'en a créé AUCUNE, parce que MuK n'en avait
aucune à convertir.
2. L'héritage depuis l'enregistrement lié, qui exige
`storage.save_type = 'attachment'` — or les stockages migrés sont en
`database`.
Les deux portes fermées, la règle globale filtre TOUT. Pas un dossier,
pas un fichier, pour personne — l'administrateur compris, car `admin`
n'est pas le super-utilisateur au sens d'Odoo.
Ce que fait la réparation
-------------------------
Une seule `dms.access.group`, adossée au groupe Odoo `dms.group_dms_user`
plutôt qu'à une liste d'utilisateurs — ainsi l'appartenance continue de
suivre le groupe, comme avant la migration —, reliée aux dossiers
RACINES. Les enfants héritent : ils portent tous
`inherit_group_ids = true`.
Ce qu'elle ne fait pas : inventer une granularité que MuK n'avait pas.
L'ancien modèle ne connaissait pas d'ACL par dossier ; en fabriquer une
ici serait décider à la place de l'utilisateur, sur des données qu'il
n'a jamais saisies.
Codes de sortie : 0 rien à faire, 1 il y a à faire (ou c'est fait), 2 échec.
"""
import argparse
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
from script.odoo.migration import database_cleanup # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et
# elles ne servent qu'à isoler le rapport des journaux d'Odoo. En
# inventer une seconde paire aurait créé un contrat parallèle à tenir.
DEBUT = database_cleanup.START
FIN = database_cleanup.END
NOM_GROUPE = "Migration MuK → DMS"
# Le script poussé dans `odoo-bin shell`. Il compte AVANT et APRÈS avec un
# vrai utilisateur : c'est la seule preuve qui vaille — la règle globale
# ne s'applique pas au super-utilisateur, donc un compte fait en sudo
# dirait « tout va bien » alors que personne ne voit rien.
SCRIPT = """
import json
DRY = {dry}
NOM = {nom!r}
rapport = {{"dry_run": DRY}}
try:
# Sans DMS, il n'y a rien à réparer et ce n'est pas une erreur : cet
# outil tourne à chaque migration vers la 13, et la plupart des bases
# n'ont jamais eu de DMS. Lever ici les ferait toutes échouer.
if "dms.file" not in env:
rapport["absent"] = True
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
raise SystemExit(0)
Dossier = env["dms.directory"].sudo()
Fichier = env["dms.file"].sudo()
Groupe = env["dms.access.group"].sudo()
rapport["files"] = Fichier.search_count([])
rapport["directories"] = Dossier.search_count([])
rapport["access_groups_before"] = Groupe.search_count([])
# Un vrai utilisateur, pas le super-utilisateur : la règle globale ne
# s'applique qu'à lui, et c'est justement ce qu'on mesure.
membres = env.ref("dms.group_dms_user").users.filtered(
lambda u: u.id != 1 and u.active
)
temoin = membres[:1]
rapport["witness"] = temoin.login if temoin else None
def visible():
if not temoin:
return None
return {{
"directories": Dossier.with_user(temoin).search_count([]),
"files": Fichier.with_user(temoin).search_count([]),
}}
rapport["before"] = visible()
racines = Dossier.search([("is_root_directory", "=", True)])
rapport["roots"] = racines.mapped("name")
deja = Groupe.search([("name", "=", NOM)])
rapport["already_repaired"] = bool(deja)
if not DRY and not deja and racines:
Groupe.create({{
"name": NOM,
"perm_create": True,
"perm_write": True,
"perm_unlink": True,
"group_ids": [(6, 0, [env.ref("dms.group_dms_user").id])],
"directory_ids": [(6, 0, racines.ids)],
}})
env.cr.commit()
env.registry.clear_cache()
rapport["created"] = True
rapport["after"] = visible()
rapport["access_groups_after"] = Groupe.search_count([])
except Exception as exc:
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
"""
def build_script(dry_run):
return SCRIPT.format(
dry="True" if dry_run else "False",
nom=NOM_GROUPE,
debut=DEBUT,
fin=FIN,
)
def render(rapport, dry_run):
if rapport.get("absent"):
return [f"ℹ️ {t('No DMS in this database, nothing to repair.')}"]
lignes = [
f"📁 {t('DMS documents in the database')} :"
f" {rapport.get('files', '?')} {t('file(s)')},"
f" {rapport.get('directories', '?')} {t('folder(s)')}"
]
avant = rapport.get("before")
temoin = rapport.get("witness")
if avant is None:
lignes.append(f" ⚠ {t('No DMS user to test visibility with.')}")
else:
lignes.append(
f" {t('Visible to')} {temoin} :"
f" {avant['directories']} {t('folder(s)')},"
f" {avant['files']} {t('file(s)')}"
)
lignes.append(
f" {t('DMS access groups:')} {rapport.get('access_groups_before', '?')}"
)
if rapport.get("already_repaired"):
lignes.append(f" ℹ️ {t('Already repaired: the group exists.')}")
return lignes
if dry_run:
lignes.append(
f" → {t('Would create one access group over')}"
f" {len(rapport.get('roots') or [])} {t('root folder(s)')} :"
f" {', '.join(rapport.get('roots') or [])}"
)
lignes.append(f" {t('Nothing written. Re-run with --apply.')}")
return lignes
apres = rapport.get("after")
if apres:
lignes.append(
f" ✅ {t('Now visible to')} {temoin} :"
f" {apres['directories']} {t('folder(s)')},"
f" {apres['files']} {t('file(s)')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Restore visibility of DMS documents after a MuK DMS to OCA DMS"
" migration. Reports without writing unless --apply is given."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="config.conf")
parser.add_argument(
"--apply",
action="store_true",
help="actually create the access group (default: report only)",
)
config = parser.parse_args(argv)
# Le même garde-fou que partout ailleurs : un Odoo d'une autre version
# ÉCRIT dans la base avant d'échouer.
souci = database_cleanup.require_matching_version(config.database)
if souci:
print(f"❌ {souci}")
return 2
try:
rapport = database_cleanup.run_shell(
config.database,
config.config,
build_script(not config.apply),
echo=lambda texte: print(f"⧖ {texte}", flush=True),
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if rapport.get("error"):
print(f"❌ {rapport['error']}")
return 2
print("\n".join(render(rapport, not config.apply)))
if rapport.get("absent"):
return 0
avant = rapport.get("before") or {}
if config.apply:
# Une réparation RÉUSSIE doit rendre 0. Rendre « il y a des
# trouvailles » après avoir tout remis en place ferait échouer
# n'importe quelle chaîne make qui appelle l'outil.
apres = rapport.get("after") or {}
if rapport.get("already_repaired"):
return 0
return 0 if apres.get("files") else 2
return 1 if not avant.get("files") else 0
if __name__ == "__main__":
sys.exit(main())

View file

View file

@ -0,0 +1,36 @@
-- © 2021-2026 TechnoLibre (http://www.technolibre.ca)
-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
--
-- Correctifs à appliquer AVANT qu'OpenUpgrade ne migre vers Odoo 18.
--
-- En SQL et non en Python : ce fichier tourne sur une base encore en
-- 17, que le code de la 18 ne saurait pas charger.
-- forum_tag_rel : la colonne qui pointe vers forum.post s'appelait
-- `forum_id` — un nom trompeur, elle ne désignait pas un forum. La 18 la
-- nomme `forum_post_id`.
--
-- OpenUpgrade le DÉCLARE dans son analyse :
-- website_forum / forum.post / tag_ids (many2many)
-- : column1 is now 'forum_post_id' ('forum_id') [forum_tag_rel]
-- mais `website_forum/18.0.1.2/` ne contient aucun script : rien ne
-- l'applique. Le chargement casse alors sur
-- column "forum_post_id" referenced in foreign key constraint does not exist
-- et la base reste à moitié migrée.
--
-- Les deux conditions rendent l'ordre rejouable : rien à faire si la
-- table n'existe pas (website_forum non installé) ni si le renommage a
-- déjà eu lieu.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'forum_tag_rel' AND column_name = 'forum_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'forum_tag_rel' AND column_name = 'forum_post_id'
) THEN
ALTER TABLE forum_tag_rel RENAME COLUMN forum_id TO forum_post_id;
RAISE NOTICE 'forum_tag_rel.forum_id renommee en forum_post_id';
END IF;
END $$;

View file

@ -0,0 +1,364 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Deux données de vue qu'Odoo refuse, et qu'aucune mise à jour ne répare.
A — le `type` stocké contredit l'héritage
=========================================
Le symptôme est brutal : Odoo refuse de charger la base.
Le nœud racine d'une vue tree devrait être <tree>, et non <search>
view: ir.ui.view(637,) view.parent: ir.ui.view(636,)
La 637 hérite d'une vue `search` et son `type` en base dit `tree`. Odoo
valide alors un `<search>` avec les règles d'un `<tree>`, et échoue.
Pourquoi aucune mise à jour ne le répare
----------------------------------------
`type` n'est PAS un champ calculé. Dans `ir_ui_view.py`, il est rempli
dans `create` — et seulement s'il est absent :
if not values.get('type'):
if values.get('inherit_id'):
values['type'] = self.browse(values['inherit_id']).type
`write` ne le recalcule jamais. Une valeur fausse reste donc fausse pour
toujours, et `-u module` ne la corrigera pas — il échoue avant, sur la
validation qu'elle provoque.
Pourquoi en SQL et pas par l'ORM
--------------------------------
Parce que le registre ne charge plus. Un outil qui aurait besoin d'Odoo
pour réparer ce qui empêche Odoo de démarrer ne servirait à rien.
Ce que la règle garantit
------------------------
Une vue héritée prend le type de son ancêtre — c'est la règle d'Odoo
lui-même, citée plus haut. Vérifié : zéro écart sur une installation 18
neuve et sur quatre bases migrées, un seul sur celle qui refusait de
charger. Le détecteur ne crie pas au loup.
B — un `<tree>` survit dans une base Odoo 18
============================================
La 18 a renommé le type `tree` en `list`, balise comprise. OpenUpgrade
convertit la COLONNE :
def _fix_list_view_type(cr):
'''Former tree views have view type list now.'''
cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'")
…et jamais l'ARCH. Le chargement meurt alors sur
Le nœud racine d'une vue list devrait être <list>, et non <tree>
Une vue de module s'en remet à la première mise à jour du module, qui
réécrit son arch depuis le XML. Une vue SANS xmlid — une liste faite à
la main, une copie de site — n'est réécrite par rien : elle reste
cassée pour toujours.
Le remplacement porte sur TOUTES les occurrences, pas seulement la
racine : un `<tree>` imbriqué dans un formulaire (une liste one2many)
est tout aussi refusé. Vérifié : les addons d'Odoo 18 n'en contiennent
plus une seule, donc tout `<tree>` d'une base 18 est un reste.
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
"""
import argparse
import os
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
SEP = "\x1f"
# La profondeur borne la récursion : un `inherit_id` circulaire ferait
# tourner PostgreSQL sans fin, et une chaîne de vues n'a jamais vingt
# maillons.
PROFONDEUR_MAX = 20
DETECTION = f"""
WITH RECURSIVE racine AS (
SELECT id, type, 0 AS prof FROM ir_ui_view WHERE inherit_id IS NULL
UNION ALL
SELECT v.id, r.type, r.prof + 1
FROM ir_ui_view v JOIN racine r ON v.inherit_id = r.id
WHERE r.prof < {PROFONDEUR_MAX}
)
SELECT v.id::text, v.type, r.type,
coalesce(v.mode, '-'),
coalesce(d.module || '.' || d.name, '-'),
coalesce(v.model, '-')
FROM ir_ui_view v
JOIN racine r ON r.id = v.id
LEFT JOIN ir_model_data d
ON d.model = 'ir.ui.view' AND d.res_id = v.id
WHERE v.inherit_id IS NOT NULL AND v.type <> r.type
ORDER BY v.id
"""
# La 18 renomme `tree` en `list`. Avant, la balise est légitime.
PREMIERE_VERSION_LIST = 18
def run_psql(database, sql, read_only=True):
"""Interroger la base. En lecture seule sauf demande explicite."""
env = os.environ.copy()
if read_only:
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return None
return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
def find(database):
"""Les vues fautives. None si la base ne répond pas."""
lignes = run_psql(database, DETECTION)
if lignes is None:
return None
return [
{
"id": int(ligne[0]),
"type": ligne[1],
"expected": ligne[2],
"mode": ligne[3],
"xmlid": ligne[4],
"model": ligne[5],
}
for ligne in lignes
if len(ligne) >= 6
]
def db_major(database):
"""La version majeure d'Odoo de cette base, ou None.
Celle qu'Odoo inscrit, pas celle du checkout : on répare une base,
pas un répertoire.
"""
lignes = run_psql(
database,
"SELECT latest_version FROM ir_module_module WHERE name = 'base'",
)
if not lignes or not lignes[0][0]:
return None
try:
return int(lignes[0][0].split(".")[0])
except ValueError:
return None
def arch_is_jsonb(database):
"""`arch_db` est un jsonb depuis la 16, un texte avant."""
lignes = run_psql(
database,
"SELECT data_type FROM information_schema.columns"
" WHERE table_name = 'ir_ui_view' AND column_name = 'arch_db'",
)
return bool(lignes) and lignes[0][0] == "jsonb"
def arch_as_text(jsonb):
"""L'expression SQL qui rend l'arch en texte, quelle que soit sa forme."""
return "arch_db::text" if jsonb else "coalesce(arch_db, '')"
def find_tree(database, jsonb):
"""Les vues qui portent encore un `<tree>`. [] hors des bases 18+."""
texte = arch_as_text(jsonb)
lignes = run_psql(
database,
f"SELECT v.id::text, v.type,"
f" coalesce(d.module || '.' || d.name, '-'),"
f" coalesce(v.model, '-'),"
f" CASE WHEN d.id IS NULL THEN 'custom' ELSE 'module' END"
f" FROM ir_ui_view v"
f" LEFT JOIN ir_model_data d"
f" ON d.model = 'ir.ui.view' AND d.res_id = v.id"
f" WHERE {texte} LIKE '%<tree%'"
f" ORDER BY v.id",
)
if lignes is None:
return None
return [
{
"id": int(ligne[0]),
"type": ligne[1],
"xmlid": ligne[2],
"model": ligne[3],
"origin": ligne[4],
}
for ligne in lignes
if len(ligne) >= 5
]
def repair_tree_sql(vues, jsonb):
"""Le SQL qui remplace `<tree>` par `<list>`, ou "".
TOUTES les occurrences : un `<tree>` imbriqué dans un formulaire est
refusé autant que celui de la racine. Les addons d'Odoo 18 n'en
contiennent plus une seule, donc il n'y a rien de légitime à épargner.
"""
if not vues:
return ""
liste = ", ".join(str(vue["id"]) for vue in vues)
if jsonb:
# jsonb : reconstruire l'objet, langue par langue. Une traduction
# oubliée laisserait la vue cassée dans cette langue seulement —
# une panne qui ne se montre qu'à certains.
remplace = (
"(SELECT jsonb_object_agg(cle,"
" replace(replace(valeur, '<tree', '<list'),"
" '</tree>', '</list>'))"
" FROM jsonb_each_text(arch_db) AS paires(cle, valeur))"
)
else:
remplace = (
"replace(replace(arch_db, '<tree', '<list'),"
" '</tree>', '</list>')"
)
return f"UPDATE ir_ui_view SET arch_db = {remplace} WHERE id IN ({liste})"
def repair_sql(vues):
"""Le SQL de correction, ou "" s'il n'y a rien à corriger.
Un `CASE` plutôt qu'un ordre par vue : une seule instruction, donc
une seule transaction, donc pas de base à moitié réparée si l'on
coupe au milieu.
"""
if not vues:
return ""
cas = " ".join(
f"WHEN {vue['id']} THEN '{vue['expected']}'" for vue in vues
)
liste = ", ".join(str(vue["id"]) for vue in vues)
return (
f"UPDATE ir_ui_view SET type = CASE id {cas} END"
f" WHERE id IN ({liste})"
)
def render(vues, applique=False):
if not vues:
return []
lignes = [
f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}"
f" {t('their inheritance')} :"
]
for vue in vues:
lignes.append(
f" {vue['id']:<7} {vue['xmlid'][:46]:<48}"
f" {vue['type']} → {vue['expected']} {vue['model']}"
)
if applique:
lignes.append(f" ✅ {t('Corrected.')}")
else:
lignes.append(
f" {t('No module update will fix this: the type is set once,')}"
f" {t('at creation. Re-run with --apply.')}"
)
return lignes
def render_tree(vues, applique=False):
if not vues:
return []
lignes = [
f"🩹 {len(vues)} {t('view(s) still carry a <tree> tag Odoo 18')}"
f" {t('renamed to <list>')} :"
]
for vue in vues:
# L'ORIGINE est le renseignement utile : une vue de module se
# répare d'une mise à jour, une vue sans xmlid n'est réécrite
# par rien et reste cassée pour toujours.
marque = (
t("custom — nothing will ever rewrite it")
if vue["origin"] == "custom"
else t("from a module — a module update also fixes it")
)
lignes.append(
f" {vue['id']:<7} {vue['xmlid'][:40]:<42}"
f" {vue['type']:<8} {vue['model'][:22]:<24} {marque}"
)
if applique:
lignes.append(f" ✅ {t('Corrected.')}")
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report views whose stored type contradicts the type of the"
" view they inherit from, and optionally fix them."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--apply",
action="store_true",
help="actually correct the type (default: report only)",
)
config = parser.parse_args(argv)
vues = find(config.database)
if vues is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
# Le renommage `tree` → `list` n'a de sens qu'à partir de la 18 :
# avant, la balise est parfaitement légitime et la « corriger »
# casserait des vues saines.
jsonb = arch_is_jsonb(config.database)
majeure = db_major(config.database)
arbres = []
if majeure and majeure >= PREMIERE_VERSION_LIST:
arbres = find_tree(config.database, jsonb) or []
if not vues and not arbres:
print(f"✅ {t('Every view agrees with what Odoo expects.')}")
return 0
if not config.apply:
print("\n".join(render(vues) + render_tree(arbres)))
return 1
for sql in (repair_sql(vues), repair_tree_sql(arbres, jsonb)):
if sql and run_psql(config.database, sql, read_only=False) is None:
print(f"❌ {t('The correction failed.')}")
return 2
# Relire APRÈS : annoncer « corrigé » sans vérifier ferait relancer
# la migration sur le même mur.
if find(config.database) or (arbres and find_tree(config.database, jsonb)):
print(f"❌ {t('The correction failed.')}")
return 2
print(
"\n".join(
render(vues, applique=True) + render_tree(arbres, applique=True)
)
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -32,7 +32,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from check_cow_views import analyse # noqa: E402
from check_cow_views import analyse, t # noqa: E402
DEFAULT_PREFIX = "zz_cow_archive"
@ -40,7 +40,7 @@ DEFAULT_PREFIX = "zz_cow_archive"
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
["psql", "-d", database, "-tAc", sql],
["psql", "-X", "-w", "-d", database, "-tAF", "|", "-c", sql],
capture_output=True,
text=True,
)
@ -66,6 +66,39 @@ def neutralize(database, lst_view_id, prefix):
return int(output or 0)
def list_archived(database, prefix):
"""The copies a previous neutralization put aside.
Knowing what was archived is the other half of --restore: a key renamed
months ago is invisible in the interface — the copy is inactive and no
longer pairs with anything — so without this listing the only trace is
someone's memory of having run --apply.
"""
output = run_psql(
database,
"SELECT id, substring(key from " + str(len(prefix) + 2) + "),"
" COALESCE(website_id::text, ''), active,"
" octet_length(arch_db::text)"
f" FROM ir_ui_view WHERE key LIKE '{prefix}.%' ORDER BY id;",
)
lst_row = []
for line in output.splitlines():
if not line.strip():
continue
parts = line.split("|")
if len(parts) >= 5:
lst_row.append(
{
"id": int(parts[0]),
"key": parts[1],
"website_id": parts[2] or None,
"active": parts[3] == "t",
"arch_bytes": int(parts[4] or 0),
}
)
return lst_row
def restore(database, prefix):
"""Undo a neutralization: strip the prefix and reactivate."""
output = run_psql(
@ -105,51 +138,97 @@ def main():
action="store_true",
help="undo a previous neutralization and reactivate the copies",
)
parser.add_argument(
"--list",
dest="list_archived",
action="store_true",
help="list the copies a previous neutralization put aside",
)
config = parser.parse_args()
try:
return _run(config, parser)
except RuntimeError as exc:
# Une base absente ou un PostgreSQL arrêté est une erreur d'usage, pas
# un défaut de l'outil : une trace d'appel ferait chercher le bogue au
# mauvais endroit.
print(f"❌ {exc}")
return 2
def _run(config, parser):
if config.list_archived:
lst_row = list_archived(config.database, config.prefix)
if not lst_row:
print(
f"✅ -> {t('No view with this prefix on')}"
f" '{config.database}' : '{config.prefix}.'"
)
return 0
print(
f"ℹ {len(lst_row)} {t('archived COW view(s) on')}"
f" '{config.database}' :"
)
for row in lst_row:
state = "active" if row["active"] else "inactive"
print(
f" - id={row['id']} website={row['website_id'] or '-'}"
f" {row['key']} ({state}, {row['arch_bytes']} B)"
)
print(
f" {t('Their arch is intact. Restore them all with --restore,')}"
f" {t('once the module view they shadow has the right shape.')}"
)
return 0
if config.restore:
count = restore(config.database, config.prefix)
print(f"✅ -> {count} COW view(s) restored on '{config.database}'.")
print(
f"✅ -> {count} {t('COW view(s) restored on')} '{config.database}'."
)
return 0
if not config.target_version:
parser.error("--target_version is required unless --restore is used")
if not os.path.isdir(config.target_version):
print(
f"❌ Target version directory '{config.target_version}' not found."
f"❌ {t('Target version directory not found')} :"
f" '{config.target_version}'"
)
return 1
return 2
lst_at_risk, _, _ = analyse(config.database, config.target_version)
if not lst_at_risk:
print("✅ -> No website COW view to neutralize.")
print(f"✅ -> {t('No website COW view to neutralize.')}")
return 0
print(
f"⚠️ {len(lst_at_risk)} website COW view(s) would break the bump to"
f" {config.target_version}:"
f"⚠️ {len(lst_at_risk)} {t('website COW view(s) would break the bump')}"
f" {t('to')} {config.target_version} :"
)
lst_view_id = []
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
lst_view_id.append(view_id)
print(
f" - id={view_id} website={website_id} {key}"
f" : {mode} -> {target_mode} ({reason})"
f" : {mode} -> {target_mode} ({t(reason)})"
)
if not config.apply:
print(
"ℹ Dry-run. Add --apply to rename their key to"
f" '{config.prefix}.<key>' and deactivate them. Reversible with"
" --restore; the arch stays in database."
f"ℹ {t('Dry-run. Add --apply to rename their key to')}"
f" '{config.prefix}.<key>' {t('and deactivate them. Reversible')}"
f" {t('with --restore; the arch stays in database.')}"
)
return 0
# 1 = des copies sont à neutraliser. Le pilote lisait une phrase
# anglaise pour le savoir ; il devenait aveugle une fois traduite.
return 1
count = neutralize(config.database, lst_view_id, config.prefix)
print(
f"✅ -> {count} COW view(s) neutralized (key prefixed with"
f" '{config.prefix}.', deactivated). The arch is kept as an archive;"
" use --restore to undo."
f"✅ -> {count} {t('COW view(s) neutralized (key prefixed with')}"
f" '{config.prefix}.', {t('deactivated). The arch is kept as an')}"
f" {t('archive; use --restore to undo.')}"
)
return 0

View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Full-screen browsing of the COW copies that drifted from their module view.
Two things decide whether to reset a copy, and the text report puts them a
thousand lines apart: what the copy holds that the module view does not — the
only thing a reset gives up — and which child no longer finds its anchor,
which is why anything breaks at all. Space switches between them.
The reset command is on « c »: it takes a key, and copying a key by hand out
of a scrolled diff is where a character goes missing. A key matching no copy
is not an error for the tool — it runs and does nothing.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
from reset_stale_cow_views import render_broken, render_diff # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#copies { width: 44; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def weight(cow_view, module_view):
"""Combien la copie s'écarte, pour trier d'un coup d'œil."""
if not module_view:
return "—"
import difflib
diff = list(
difflib.unified_diff(
module_view["arch"].splitlines(),
cow_view["arch"].splitlines(),
lineterm="",
n=0,
)
)
plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
return f"+{plus}/-{minus}"
def build_app(lst_finding, database):
"""Build the application. Textual is imported here, not at module level.
The module stays importable — and therefore testable — on a machine
without Textual, which is also what lets the caller fall back to the text
report rather than fail.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
class StaleCowApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", "Quit"),
("space,tab", "toggle", "Diff / why"),
("c", "copy", "Copy reset command"),
]
def __init__(self, lst_finding, database):
super().__init__()
self.lst_finding = lst_finding
self.database = database
self.why = False
self.index = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="copies", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = "COW copies drifted from their module view"
table = self.query_one("#copies", DataTable)
table.add_columns("copy", "+/-", "broken")
for cow_view, module_view, broken in self.lst_finding:
table.add_row(
(cow_view["key"] or str(cow_view["id"]))[:30],
weight(cow_view, module_view),
str(len(broken)),
key=str(cow_view["id"]),
)
self._show()
def _show(self):
if not self.lst_finding:
return
cow_view, module_view, broken = self.lst_finding[self.index]
which = "why it breaks" if self.why else "what the copy holds"
self.query_one("#head", Static).update(
f"{cow_view['key']} · id={cow_view['id']}"
f" · website={cow_view.get('website_id')}\n"
f"[{which}] — space to switch, c to copy the reset command"
)
if self.why:
text = render_broken(cow_view, module_view, broken)
elif module_view:
text = render_diff(module_view, cow_view, indent=" ")
else:
text = (
f" {t('No module view carries this key: nothing to reset')}"
f" {t('onto.')}"
)
self.query_one("#content", Static).update(text)
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "copies" and self.lst_finding:
self.index = event.cursor_row
self._show()
def action_toggle(self):
self.why = not self.why
self._show()
def action_copy(self):
"""Mettre la commande de réinitialisation dans le presse-papier.
Elle prend une CLÉ, et recopier une clé à la main depuis un diff
défilé est l'endroit où un caractère se perd — l'outil ne dirait
rien, il ne trouverait simplement aucune copie.
"""
cow_view = self.lst_finding[self.index][0]
command = (
"./script/odoo/migration/reset_stale_cow_views.py"
f" -d {self.database} --reset {cow_view['key']} --apply"
)
try:
self.copy_to_clipboard(command)
self.notify(t("Reset command copied."))
except Exception:
self.notify(command, timeout=20)
return StaleCowApp(lst_finding, database)
def run_tui(lst_finding, database, run_app=True):
"""Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
de terminal, ou Textual absent. Se taire ferait réafficher le rapport
texte à la place de l'écran demandé, sans rien qui distingue les deux.
"""
if not lst_finding:
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.available():
if not textual_setup.in_venv():
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(lst_finding, database)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
app.run()
return True

View file

@ -61,6 +61,17 @@ import re
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
@ -142,7 +153,7 @@ def require_lxml():
return etree
except ImportError:
sys.exit(
"❌ lxml is required to resolve the xpath expressions.\n"
f"❌ {t('lxml is required to resolve the xpath expressions.')}\n"
" Run this with an interpreter that has it, e.g.\n"
" ./.venv.erplibre/bin/python3 " + os.path.relpath(__file__)
)
@ -216,8 +227,34 @@ def analyse(database):
return findings
def show_diff(module_view, cow_view):
"""Module arch vs copy: what the copy would gain and lose on a reset."""
def find_copy_by_key(views, key):
"""(copie COW, jumelle module) pour cette clé, ou (None, None).
La détection différentielle ne voit qu'une copie dont un ENFANT casse.
Une copie périmée sans enfant lui échappe — mesuré : la copie de
`website_crm.contactus_form` était plus petite d'un tiers que sa
jumelle, et c'est elle qui rendait /contactus en 500. Demander sa
réinitialisation par clé doit donc marcher même hors détection.
"""
copy = twin = None
for row in views.values():
if row.get("key") != key:
continue
if row.get("website_id"):
if copy is None or row["id"] < copy["id"]:
copy = row
elif twin is None or row["id"] < twin["id"]:
twin = row
return copy, twin
def render_diff(module_view, cow_view, indent=" "):
"""Module arch vs copy : ce qu'une réinitialisation rend et abandonne.
Rendu en texte plutôt qu'imprimé : la TUI montre exactement le même
diff que la ligne de commande. Deux rendus séparés dériveraient sans
que rien ne le signale.
"""
diff = difflib.unified_diff(
module_view["arch"].splitlines(),
cow_view["arch"].splitlines(),
@ -225,8 +262,44 @@ def show_diff(module_view, cow_view):
tofile=f"cow id={cow_view['id']}",
lineterm="",
)
for line in diff:
print(f" {line}")
return "\n".join(f"{indent}{line}" for line in diff)
def render_broken(cow_view, module_view, broken):
"""Pourquoi ça casse : les enfants dont l'xpath ne trouve plus son point."""
lines = [
f"── id={cow_view['id']} {cow_view['key']}"
f" (website={cow_view.get('website_id')}) ──",
"",
]
if not broken:
lines.append(
f" {t('No child fails on this copy: it drifted without')}"
f" {t('breaking anything yet.')}"
)
else:
lines.append(
f" {t('These children no longer find their anchor in the copy')}"
" :"
)
for child_id, expr in broken:
lines.append(f" {t('child')} {child_id} : {expr}")
lines += [
"",
f" {t('The anchor exists in the module view')}"
f" (id={module_view['id'] if module_view else '?'})"
f" {t('but not in this copy, frozen on an older version.')}",
"",
f" {t('Resetting restores the module arch; the customization the')}",
f" {t('copy carried is saved first, and is yours to re-apply as an')}",
f" {t('INHERITING view with its own key.')}",
]
return "\n".join(lines)
def show_diff(module_view, cow_view):
"""Imprimer le diff, pour la ligne de commande."""
print(render_diff(module_view, cow_view))
def backup(database, cow_view, directory):
@ -272,6 +345,16 @@ def main():
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--tui",
action="store_true",
help="browse the differences full screen",
)
parser.add_argument(
"--list-keys",
action="store_true",
help="print one drifted key per line, and nothing else",
)
parser.add_argument(
"--reset",
metavar="KEY",
@ -293,72 +376,141 @@ def main():
config = parser.parse_args()
findings = analyse(config.database)
if not findings:
print("✅ No COW copy has drifted from its module view.")
return 0
# Une sortie sans décor, pour que l'appelant construise un menu : on ne
# connaît pas les clés de tête, et les recopier depuis un diff de mille
# lignes est le genre de recopie où l'on se trompe.
if config.list_keys:
for cow_view, _module_view, _broken in findings:
print(cow_view["key"])
return 1 if findings else 0
print(
f"⚠️ {len(findings)} COW copy(ies) drifted from their module view"
f" in {config.database}"
)
print(
" Odoo surfaces this when the module view is rewritten (a version"
" bump) or when the page is rendered.\n"
)
# « Rien détecté » ne veut pas dire « rien à faire » quand une clé est
# demandée : la détection différentielle ne voit qu'une copie dont un
# ENFANT casse, et une copie périmée sans enfant lui échappe. Sortir ici
# faisait taire la demande — on croyait la copie réinitialisée.
if not findings and not (set(config.reset) - {"all"}):
print(f"✅ {t('No COW copy has drifted from its module view.')}")
return 0
if not findings:
# Le même texte que ci-dessus se lisait comme une contradiction :
# « aucune copie n'a dérivé », puis « ✅ réinitialisé id=2656 ».
# Les deux sont vrais — la détection différentielle ne voit qu'une
# copie dont un ENFANT casse — mais mis côte à côte sans un mot,
# on croit l'outil incohérent et l'on cesse de le lire.
print(
f"ℹ {t('The differential detection found nothing; resetting')}"
f" {t('the requested key(s) anyway.')}"
)
if findings:
print(
f"⚠️ {len(findings)}"
f" {t('COW copy(ies) drifted from their module')}"
f" {t('view in')} {config.database}"
)
print(
f" {t('Odoo surfaces this when the module view is rewritten')}"
f" {t('(a version bump) or when the page is rendered.')}\n"
)
for cow_view, module_view, broken in findings:
twin = (
f"module id={module_view['id']}"
if module_view
else "NO module view with this key"
else t("NO module view with this key")
)
print(
f" id={cow_view['id']} key={cow_view['key']}"
f" website_id={cow_view['website_id']} [{twin}]"
)
for child_id, expr in broken:
print(f" child {child_id} cannot apply: {expr}")
print(f" {t('child')} {child_id} {t('cannot apply')}: {expr}")
if module_view:
show_diff(module_view, cow_view)
print()
if not config.reset:
print(
"Nothing changed. Re-run with --reset <key> --apply to reset a"
" copy onto its module view."
f"{t('Nothing changed. Re-run with --reset <key> --apply to')}"
f" {t('reset a copy onto its module view.')}"
)
return 1
if config.tui and findings:
from reset_stale_cow_tui import run_tui
# False n'est pas un échec : l'écran a dit pourquoi, et le rapport
# texte ci-dessus porte déjà la même information.
run_tui(findings, config.database)
wanted = set(config.reset)
directory = config.backup_dir or os.path.join(
"private", "odoo", "migration", config.database, "cow_reset"
)
done = 0
honoured = set()
missed = []
# Les vues brutes servent aux clés que la détection ne voit pas ; on ne
# les lit que si l'on va s'en servir.
views = fetch_views(config.database) if wanted - {"all"} else {}
for cow_view, module_view, _broken in findings:
if "all" not in wanted and cow_view["key"] not in wanted:
continue
honoured.add(cow_view["key"])
if not module_view:
print(
f"⏭ {cow_view['key']}: no module view to reset onto,"
" skipped."
f"⏭ {cow_view['key']} :"
f" {t('no module view to reset onto, skipped.')}"
)
continue
if not config.apply:
print(
f"[dry-run] would reset id={cow_view['id']}"
f" ({cow_view['key']}) onto id={module_view['id']}"
f"[{t('dry-run')}] {t('would reset')} id={cow_view['id']}"
f" ({cow_view['key']}) {t('onto')} id={module_view['id']}"
)
continue
path = backup(config.database, cow_view, directory)
reset(config.database, cow_view, module_view)
done += 1
print(f"✅ reset id={cow_view['id']} ({cow_view['key']})")
print(f" previous arch saved to {path}")
print(f"✅ {t('reset')} id={cow_view['id']} ({cow_view['key']})")
print(f" {t('previous arch saved to')} {path}")
# Une clé demandée qui ne correspond à rien N'EST PAS un succès. Elle
# l'était : la commande tournait, ne faisait rien, et se taisait. On a
# donc cru une copie réinitialisée alors que /contactus rendait encore
# 500 — mesuré sur une vraie migration.
for key in sorted(wanted - honoured - {"all"}):
copy, twin = find_copy_by_key(views, key)
if copy is None:
print(f"⚠️ {t('No COW copy carries this key')} : {key}")
missed.append(key)
continue
if twin is None:
print(f"⚠️ {key} : {t('no module view to reset onto, skipped.')}")
missed.append(key)
continue
if copy["arch"] == twin["arch"]:
print(f"ℹ {key} : {t('already identical to the module view.')}")
continue
if not config.apply:
print(
f"[{t('dry-run')}] {t('would reset')} id={copy['id']}"
f" ({key}) {t('onto')} id={twin['id']}"
)
continue
path = backup(config.database, copy, directory)
reset(config.database, copy, twin)
done += 1
print(f"✅ {t('reset')} id={copy['id']} ({key})")
print(f" {t('previous arch saved to')} {path}")
if config.apply and done:
print(
f"\n{done} copy(ies) reset. Re-apply any real customisation as an"
" INHERITING view, not a copy, so it cannot go stale again."
f"\n{done} {t('copy(ies) reset. Re-apply any real customisation')}"
f" {t('as an INHERITING view, not a copy, so it cannot go stale')}"
f" {t('again.')}"
)
return 0
# Une demande non honorée doit se voir jusque dans le code de sortie :
# l'appelant qui enchaîne ne lit pas le texte.
return 2 if missed else 0
if __name__ == "__main__":

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,914 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Request every public URL of a migrated database, and report what breaks.
Why this exists
---------------
A migration can finish, load every module, and still serve a 500 on a page
nobody thought to open. Measured on a real one: ``/blog/<blog>/post/<post>``
answered 500 because a COW copy frozen on the previous version no longer held
the section a child view xpaths into. Nothing in the migration log said so —
the module loading had succeeded.
Where the list comes from
-------------------------
``/sitemap.xml``: the list Odoo itself publishes for search engines, built by
``website.enumerate_pages()``. It covers the controller routes declared with
``sitemap=True`` and the records behind them — pages, blog posts, products.
It cannot be read from ``odoo-bin shell``: ``enumerate_pages()`` asks for
``http.root.get_db_router(request.db)`` and raises « object unbound » without
a real request. So the server is started, asked, and stopped.
What counts as a failure
------------------------
Any status >= 400. The sitemap is the PUBLIC list: a page listed there and
answering 403 or 404 is as wrong as one answering 500, just less loud.
Exit codes: 0 every URL answered, 1 some failed, 2 the tool failed.
"""
import argparse
import os
import re
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
try:
from script.todo import auto_ask
except Exception: # pragma: no cover - repli si le pilote est absent
auto_ask = None
def can_ask():
"""Peut-on poser une question ICI ?
Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
stdin laisse poser une invite qui part dans un tube : elle reste en
tampon, invisible, pendant que le processus attend — on croit à un
blocage et l'on tape Entrée à l'aveugle.
"""
return sys.stdin.isatty() and sys.stdout.isatty()
RE_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.I)
# « [view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281] ». Seule la
# phrase qui précède est traduite ; cette ligne-ci ne l'est pas, donc la lire
# marche dans les deux langues. Le PARENT est le coupable : c'est la copie
# figée dans laquelle l'enfant ne trouve plus son xpath.
RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]")
# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le
# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie
# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans
# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue
# fautive.
RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M)
# Un port à part : la migration tourne souvent à côté d'une instance vivante,
# et lui voler 8069 ferait échouer le test pour une raison sans rapport.
DEFAULT_PORT = 8169
def run_psql(database, sql):
"""Lecture seule, garantie par le serveur PostgreSQL lui-même."""
env = os.environ.copy()
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tAF", "\x1f", "-c", sql],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return []
return [line.split("\x1f") for line in done.stdout.splitlines() if line]
def require_matching_version(database):
"""Refuser de démarrer sur une base d'une autre version qu'un Odoo.
Sans cela le rapport ment de la pire façon : mesuré ici même, un
checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
trente-sept URL et sur /web/login, et l'on conclut à un site
entièrement cassé alors que rien ne l'est. Le checkout suit la
migration ; rien ne garantit qu'il soit resté sur la version de la
base qu'on veut interroger.
On délègue à `database_cleanup`, qui porte déjà cette garde : deux
implémentations divergeraient, et une garde qui diverge ne garde rien.
"""
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from database_cleanup import require_matching_version as verifier
except ImportError:
return None
return verifier(database)
def start_server(database, port, config_path="./config.conf", log_path=None):
"""Démarrer Odoo, son journal dans un FICHIER.
Pas un tube : Python bufferise par blocs quand sa sortie n'est pas un
terminal, et un fil de lecture court alors après des lignes qui n'ont
pas encore été écrites. Mesuré — 24 lignes vues sur 198, et la trace qui
nomme la vue fautive faisait partie des absentes. Un fichier se relit
entièrement, quand on veut.
"""
handle = open(log_path, "w", encoding="utf-8") if log_path else None
server = subprocess.Popen(
[
"./run.sh",
"-c",
config_path,
"-d",
database,
"--http-port",
str(port),
"--log-level=warn",
# Deux lignes par requête, et elles portent le CHEMIN. Sans
# elles une trace du journal ne peut être rattachée à rien :
# on l'attribuait à la dernière requête, qui n'était pas la
# sienne. Mesuré — /my accusé d'une panne appartenant à une
# application testée après lui.
"--log-handler=werkzeug:INFO",
],
stdout=handle or subprocess.DEVNULL,
stderr=subprocess.STDOUT,
text=True,
# Son propre groupe de processus : « ./run.sh » est un script bash
# qui ne transmet rien à son enfant. Un terminate() sur lui tuait le
# script et laissait odoo-bin vivant, tenant le port. L'essai suivant
# démarrait alors un serveur qui ne pouvait pas se lier, et
# interrogeait sans le savoir CELUI D'AVANT — mesuré, deux fois.
start_new_session=True,
)
server.erplibre_log = handle
return server
def read_log(log_path):
"""Le journal du serveur, tel qu'écrit jusqu'ici."""
if not log_path or not os.path.isfile(log_path):
return []
with open(log_path, "r", encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()
def fetch(url, timeout=30):
"""(statut, corps). Statut 0 quand la connexion elle-même échoue."""
try:
with urllib.request.urlopen(url, timeout=timeout) as answer:
return answer.getcode(), answer.read().decode(
"utf-8", errors="replace"
)
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
except Exception:
return 0, ""
def wait_ready(base_url, timeout=180, sleep=2):
"""Attendre que le serveur réponde. False s'il n'est jamais venu."""
deadline = time.time() + timeout
while time.time() < deadline:
status, _body = fetch(base_url + "/web/login", timeout=5)
if status:
return True
time.sleep(sleep)
return False
def sitemap_urls(base_url):
"""Les URL du sitemap, index compris, ramenées sur l'hôte local.
Le sitemap porte le domaine du site (technolibre.ca) ; on teste une base
servie en local. Garder le domaine ferait interroger la production —
c'est le genre d'erreur qui ne se voit qu'après.
"""
status, body = fetch(base_url + "/sitemap.xml")
if not status or status >= 400:
return [], status
lst_loc = RE_LOC.findall(body)
# Un index de sitemaps ne contient que des sitemaps : on descend d'un cran.
if "<sitemapindex" in body.lower():
lst_page = []
for loc in lst_loc:
_status, sub = fetch(local_url(base_url, loc))
lst_page.extend(RE_LOC.findall(sub))
lst_loc = lst_page
seen, lst_url = set(), []
for loc in lst_loc:
url = local_url(base_url, loc)
if url not in seen:
seen.add(url)
lst_url.append(url)
return lst_url, status
def local_url(base_url, loc):
"""Remplacer le schéma et l'hôte du sitemap par ceux qu'on teste."""
parsed = urllib.parse.urlparse(loc)
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
return base_url.rstrip("/") + path
# Odoo écrit sa trace APRÈS avoir répondu, et par le tube d'un script shell :
# mesuré, elle peut arriver près de trois secondes plus tard. Une demi-seconde
# concluait « aucune vue en cause » sur des pages qui en nommaient une.
LOG_DELAY = 3.0
def attach_missing_parents(lst_failure, lst_log):
"""Repêcher les contextes arrivés trop tard pour leur tranche.
Le découpage par URL est une commodité, pas une garantie : le journal est
asynchrone. Ce second passage lit TOUT ce qui a été capturé et rattache
ce qui n'avait été rattaché à rien — mieux vaut un coupable mal attribué
qu'un coupable perdu.
"""
known = {pid for _u, _s, lst in lst_failure for pid in lst}
extra = []
for line in lst_log:
# LES DEUX : le parent ET l'enfant. Mesuré sur /contactus — le
# parent était identique à sa vue module, et c'est l'ENFANT qui
# portait l'arch périmée. Ne proposer que le parent envoyait
# réinitialiser une copie qui allait déjà bien.
for view_id, parent_id in RE_CONTEXT.findall(line):
for candidate in (parent_id, view_id):
if candidate not in known and candidate not in extra:
extra.append(candidate)
if not extra:
return lst_failure
rebuilt = []
placed = False
for url, status, lst_parent in lst_failure:
if not lst_parent and not placed:
lst_parent = list(extra)
placed = True
rebuilt.append((url, status, lst_parent))
if not placed and rebuilt:
url, status, lst_parent = rebuilt[0]
rebuilt[0] = (url, status, lst_parent + extra)
return rebuilt
def check_urls(lst_url, timeout=30):
"""[(url, statut, [])] pour celles qui ont échoué.
Les vues en cause sont rattachées après coup, en relisant le journal du
serveur : elles y arrivent quand Odoo vide son tampon, pas quand la
requête revient.
"""
lst_failure = []
for url in lst_url:
status, _body = fetch(url, timeout=timeout)
if status == 0 or status >= 400:
lst_failure.append((url, status, []))
return lst_failure
def template_keys(lst_log, database):
"""Les gabarits nommés par une QWebException, s'ils ont une copie COW.
Nommer une clé sans copie enverrait réinitialiser une vue module — donc
ne rien faire, en silence. On ne propose que ce qui peut l'être.
"""
lst_key = []
for line in lst_log:
for key in RE_TEMPLATE.findall(line):
if key not in lst_key:
lst_key.append(key)
if not lst_key:
return []
quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key)
rows = run_psql(
database,
"SELECT DISTINCT key FROM ir_ui_view"
f" WHERE website_id IS NOT NULL AND key IN ({quoted});",
)
with_copy = {row[0] for row in rows if row and row[0]}
return [key for key in lst_key if key in with_copy]
def culprit_keys(database, lst_failure):
"""Les clés des vues parentes mises en cause, sans doublon.
C'est ce qu'on passe à reset_stale_cow_views : il travaille par clé, et
relever un identifiant dans une trace pour le traduire à la main est
exactement la recopie où l'on se trompe.
"""
lst_id = []
for _url, _status, lst_parent in lst_failure:
for parent_id in lst_parent:
if parent_id not in lst_id:
lst_id.append(parent_id)
if not lst_id:
return []
ids = ",".join(str(int(x)) for x in lst_id)
rows = run_psql(
database,
f"SELECT id, key, website_id FROM ir_ui_view WHERE id IN ({ids})"
" AND key IS NOT NULL ORDER BY id;",
)
lst_key = []
for row in rows:
if len(row) >= 2 and row[1] and row[1] not in lst_key:
lst_key.append(row[1])
return lst_key
def render(lst_url, lst_failure, lst_key=None):
if not lst_url:
return f"⚠️ {t('The sitemap listed no URL: nothing was tested.')}\n"
if not lst_failure:
return (
f"✅ -> {len(lst_url)} {t('public URL(s) answered without error.')}"
"\n"
)
lines = [
f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}"
f" {t('public URL(s) failed')} :"
]
for url, status, lst_parent in lst_failure:
label = status or t("no answer")
lines.append(f" [{label}] {url}")
if lst_parent:
lines.append(
f" {t('parent view(s) in cause')} :"
f" {', '.join(lst_parent)}"
)
if lst_key:
lines.append(
f" {t('Those parents are copies frozen on an older version;')}"
f" {t('resetting them onto the module view is the fix')} :"
)
for key in lst_key:
lines.append(f" {key}")
lines.append(
f" {t('A page listed for search engines that does not answer is')}"
f" {t('a page your visitors do not reach either.')}"
)
return "\n".join(lines) + "\n"
def apply_reset(database, lst_key):
"""Réinitialiser ces copies sur leur vue module. ÉCRIT en base.
On délègue à reset_stale_cow_views : il sauvegarde l'arch précédente
avant d'écrire, et c'est déjà lui qu'on documente partout ailleurs. En
refaire une seconde version ici, c'est se donner deux comportements à
tenir d'accord.
"""
cmd = [
sys.executable,
os.path.join(
"script", "odoo", "migration", "reset_stale_cow_views.py"
),
"-d",
database,
]
for key in lst_key:
cmd += ["--reset", key]
cmd.append("--apply")
done = subprocess.run(cmd, capture_output=True, text=True)
return done.returncode, done.stdout + done.stderr
DEFAULT_ANSWER = "a"
def prompt(database, lst_failure, lst_key, ask=None):
"""Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées.
Détecter sans offrir le geste, c'est laisser relever des identifiants
dans une trace pour les traduire en clés à la main — au moment précis où
l'on veut juste que la page réponde.
"""
if not lst_key:
print(f"ℹ -> {t('No parent view named: nothing to offer.')}")
return []
if ask is None:
# Lancé à part par la migration : sans lecteur temporisé, cette
# question arrêtait net une exécution automatique.
ask = (
auto_ask.make_ask(DEFAULT_ANSWER)
if auto_ask
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
)
print(f"\n✨ {t('Copies to reset onto their module view')} :")
for index, key in enumerate(lst_key, start=1):
print(f" [{index}] {key}")
print(f" [a] {t('All of the list above')}")
answer = (
ask(
f"💬 {t('Which one(s) to reset?')}"
f" ({t('numbers separated by commas, Enter = all, n =')}"
f" {t('nothing')}) : "
)
.strip()
.lower()
)
# « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une
# sortie sans mot pour dire non serait une sortie sans issue.
if not answer or answer == "n":
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
return []
if answer == "a":
lst_chosen = list(lst_key)
else:
lst_chosen = []
for part in answer.replace(" ", "").split(","):
if part.isdigit() and 1 <= int(part) <= len(lst_key):
lst_chosen.append(lst_key[int(part) - 1])
if not lst_chosen:
print(f"⚠️ {t('Unknown choice, nothing was reset.')}")
return []
status, output = apply_reset(database, lst_chosen)
print(output.strip()[-2000:])
if status == 2:
print(f"❌ {t('Reset failed, nothing was changed.')}")
return []
return lst_chosen
def render_internal(internal):
"""Afficher le rapport du back-office. Rend True s'il a échoué.
Ne rien afficher quand la base n'a pas été neutralisée serait laisser
croire que le back-office a été testé et qu'il va bien.
"""
if internal is None:
return False
reprise = f" ({t('after the reset')})" if internal.get("retried") else ""
if "skipped" in internal:
if internal.get("loud"):
# Un saut ATTENDU se dit à voix basse ; un saut qui trahit une
# panne doit compter comme un échec, sinon le code de sortie
# annonce que tout va bien.
print(
f"\n⚠️ {t('Back office NOT browsed')}{reprise} :"
f" {internal['skipped']}"
)
return True
print(
f"\nℹ️ {t('Back office not browsed')}{reprise} :"
f" {internal['skipped']}"
)
return False
import smoke_internal_ui
print(smoke_internal_ui.render(internal["results"], internal["failures"]))
return bool(internal["failures"])
def attach_internal_log(internal_report, log_path):
"""Donner à la passe back-office la trace que sa page ne montrait pas.
APRÈS l'arrêt du serveur, et pas avant : un serveur qui écrit dans un
fichier bufferise et ne vide qu'en s'arrêtant. La page d'erreur d'Odoo
en production, elle, se contente de « 500: Internal Server Error » —
de quoi constater, pas de quoi réparer.
"""
if not internal_report or not internal_report.get("failures"):
return
try:
import smoke_internal_ui
except ImportError:
return
smoke_internal_ui.attach_log_reason(
internal_report["failures"], read_log(log_path)
)
def internal_needs_retry(internal_report):
"""Le back-office mérite-t-il un second essai après la réparation ?
Oui s'il a échoué, et oui aussi s'il a été SAUTÉ : « connexion
impossible » est justement le symptôme d'un site cassé, et c'est ce
que la réinitialisation vient de réparer.
"""
if not internal_report:
return False
return bool(
internal_report.get("failures") or internal_report.get("skipped")
)
def internal_phase(
base_url,
database,
enabled=True,
login="test",
password="test",
limit=20,
every_menu=False,
lst_portal=None,
required=False,
):
"""Le back-office, si la base a été neutralisée. Rend None sinon.
La condition n'est pas un drapeau mais l'utilisateur lui-même : la
neutralisation pose un compte `test` avec les groupes du
superutilisateur, et c'est le seul moyen d'entrer sans connaître le mot
de passe de quelqu'un. Une migration reprise a pu sauter l'étape, et le
fichier de progression dirait quand même « fait ».
Un échec ICI ne doit pas emporter le test public : il est mesuré, il
est rapporté, mais le rapport des URL publiques a sa propre valeur.
"""
if not enabled:
return None
try:
import smoke_internal_ui
except ImportError:
# Se taire ici ferait disparaître la passe ENTIÈRE sans un mot, et
# l'on croirait le back-office testé. Un outil absent est une
# panne d'installation, pas une base saine.
return {"skipped": t("smoke_internal_ui.py is missing"), "loud": True}
etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
if etat == "absent":
# `required` dit que la migration a NEUTRALISÉ cette base : le
# compte devrait donc y être. Mesuré — il survit jusqu'au palier
# 15 puis disparaît, et la passe s'arrêtait sans bruit exactement
# là où une migration fait le plus de dégâts.
if required:
return {
"skipped": t(
"the test user is gone from a neutralized"
" database: the back office was NOT checked"
),
"loud": True,
}
return {"skipped": t("no test user: the database was not neutralized")}
if etat != "present":
# « je ne sais pas » n'est pas « tout va bien » : le dire autrement
# ferait passer un back-office jamais ouvert pour un back-office sain.
return {
"skipped": t("could not tell whether the test user exists"),
"loud": True,
}
try:
lst_result, lst_failure = smoke_internal_ui.run(
base_url,
database,
login,
password,
limit,
every_menu=every_menu,
lst_portal=(
smoke_internal_ui.DEFAULT_PORTAL_PATHS
if lst_portal is None
else lst_portal
),
)
except RuntimeError as exc:
return {"skipped": str(exc)}
return {"results": lst_result, "failures": lst_failure}
def run(
database,
port,
config_path,
limit=None,
timeout=30,
boot=180,
interactive=False,
auto_apply=False,
ask=None,
internal=True,
internal_login="test",
internal_password="test",
internal_limit=20,
every_menu=False,
portal=None,
internal_required=False,
):
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
L'ordre porte tout le correctif : un serveur qui écrit dans un fichier
bufferise et ne vide qu'en s'arrêtant. Lire avant l'arrêt donnait
vingt-quatre lignes de démarrage et zéro trace — donc « aucune vue en
cause » sur des pages qui en nommaient une. Mesuré deux fois avant d'être
compris.
"""
import tempfile
base_url = f"http://127.0.0.1:{port}"
log_path = os.path.join(
tempfile.gettempdir(), f"erplibre_smoke_{database}_{port}.log"
)
mismatch = require_matching_version(database)
if mismatch:
raise RuntimeError(mismatch)
if port_is_taken(port):
raise RuntimeError(
f"{t('Something already listens on port')} {port} :"
f" {t('it would be tested instead of this database.')}"
)
server = start_server(database, port, config_path, log_path=log_path)
try:
if not wait_ready(base_url, timeout=boot):
raise RuntimeError(
f"{t('The server never answered on')} {base_url}"
)
lst_url, status = sitemap_urls(base_url)
if not status:
raise RuntimeError(f"{t('Could not read')} {base_url}/sitemap.xml")
if limit:
lst_url = lst_url[:limit]
lst_failure = check_urls(lst_url, timeout=timeout)
# ICI, pendant que le serveur tourne : le démarrage d'Odoo est ce
# qui coûte des minutes, pas les requêtes. Un deuxième outil avec
# son propre serveur doublerait l'attente pour rien.
internal_report = internal_phase(
base_url,
database,
enabled=internal,
login=internal_login,
password=internal_password,
limit=internal_limit,
every_menu=every_menu,
lst_portal=portal,
required=internal_required,
)
finally:
stop_server(server)
attach_internal_log(internal_report, log_path)
lst_key = []
if lst_failure:
lst_log = read_log(log_path)
lst_failure = attach_missing_parents(lst_failure, lst_log)
lst_key = culprit_keys(database, lst_failure)
# Les deux sources : le contexte d'héritage quand il existe, le nom
# du gabarit quand l'échec vient du rendu.
for key in template_keys(lst_log, database):
if key not in lst_key:
lst_key.append(key)
if not lst_failure or not (interactive or auto_apply):
return lst_url, lst_failure, lst_key, None, internal_report
print(render(lst_url, lst_failure, lst_key))
if auto_apply:
lst_done = lst_key
if lst_done:
code, output = apply_reset(database, lst_done)
print(output.strip()[-2000:])
if code == 2:
lst_done = []
else:
# `ask=None` et non `input` : c'est `prompt` qui sait quel défaut
# lui appartient, et qui pose alors la question par le lecteur
# temporisé. Forcer `input` ici court-circuitait le mode auto —
# mesuré, l'invite attendait indéfiniment une frappe pendant une
# migration lancée en auto-exécution.
lst_done = prompt(database, lst_failure, lst_key, ask=ask)
if not lst_done:
return lst_url, lst_failure, lst_key, None, internal_report
lst_again, internal_report = recheck_after_reset(
database,
port,
config_path,
base_url,
log_path,
lst_failure,
internal_report,
timeout=timeout,
boot=boot,
internal=internal,
internal_login=internal_login,
internal_password=internal_password,
internal_limit=internal_limit,
every_menu=every_menu,
portal=portal,
internal_required=internal_required,
)
return lst_url, lst_failure, lst_key, lst_again, internal_report
def recheck_after_reset(
database,
port,
config_path,
base_url,
log_path,
lst_failure,
internal_report,
timeout=30,
boot=180,
internal=True,
internal_login="test",
internal_password="test",
internal_limit=20,
every_menu=False,
portal=None,
internal_required=False,
):
"""Redémarrer, et revoir ce qui avait échoué. Rend (URL, back-office).
Le back-office est REJUGÉ lui aussi, et ce n'est pas une symétrie
gratuite : la copie COW qui casse le site casse AUSSI /web/login — les
deux passent par le même gabarit. La passe interne tournait donc avant
la réparation et rapportait « connexion impossible » sur une base que
la réinitialisation remettait d'aplomb quelques secondes plus tard.
"""
server = start_server(database, port, config_path, log_path=log_path)
try:
if not wait_ready(base_url, timeout=boot):
raise RuntimeError(
f"{t('The server never answered on')} {base_url}"
)
lst_again = check_urls(
[url for url, _s, _p in lst_failure], timeout=timeout
)
if internal_needs_retry(internal_report):
reprise = internal_phase(
base_url,
database,
enabled=internal,
login=internal_login,
password=internal_password,
limit=internal_limit,
every_menu=every_menu,
lst_portal=portal,
required=internal_required,
)
if reprise is not None:
internal_report = reprise
internal_report["retried"] = True
finally:
stop_server(server)
return lst_again, internal_report
def stop_server(server):
"""Arrêter tout le GROUPE : sinon odoo-bin survit à son script."""
try:
group = os.getpgid(server.pid)
except OSError:
group = None
if group is not None:
try:
os.killpg(group, signal.SIGTERM)
except OSError:
pass
try:
server.wait(timeout=30)
except subprocess.TimeoutExpired:
if group is not None:
try:
os.killpg(group, signal.SIGKILL)
except OSError:
pass
server.kill()
handle = getattr(server, "erplibre_log", None)
if handle:
handle.close()
def port_is_taken(port, host="127.0.0.1"):
"""Quelqu'un écoute-t-il déjà là ?
Sans cette question, un serveur resté d'un essai précédent répond à
« le serveur est-il prêt ? », et l'on teste sa base à lui en croyant
tester la sienne. C'est exactement ce qui est arrivé ici.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
return sock.connect_ex((host, port)) == 0
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Start Odoo on a database, request every URL of its sitemap, and"
" report those that fail."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="./config.conf")
parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT)
parser.add_argument(
"--limit", type=int, default=None, help="test only the first N URLs"
)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument(
"--apply",
action="store_true",
help="reset the views in cause without asking (WRITES)",
)
parser.add_argument(
"--report-only",
action="store_true",
help="never ask anything, even in front of a terminal",
)
parser.add_argument(
"--boot-timeout",
type=int,
default=180,
help="how long to wait for the server to answer",
)
parser.add_argument(
"--no-internal",
action="store_true",
help="skip the back-office pass done as the neutralization test user",
)
parser.add_argument("--login", default="test")
parser.add_argument("--password", default="test")
parser.add_argument(
"--record-limit",
type=int,
default=20,
help="how many records each app loads on its first page",
)
parser.add_argument(
"--all-menus",
action="store_true",
help="open every menu with an action, not just each app's first page",
)
parser.add_argument(
"--internal-required",
action="store_true",
help="the database WAS neutralized: a missing test user is a failure",
)
parser.add_argument(
"--portal",
default="/my",
help="portal paths opened while signed in (comma separated, empty"
" to skip)",
)
config = parser.parse_args(argv)
print(
f"⧖ {t('Starting Odoo on')} '{config.database}'"
f" ({t('port')} {config.port})…"
)
interactive = not config.report_only and can_ask()
try:
lst_url, lst_failure, lst_key, lst_again, internal = run(
config.database,
config.port,
config.config,
limit=config.limit,
timeout=config.timeout,
boot=config.boot_timeout,
interactive=interactive,
auto_apply=config.apply,
internal=not config.no_internal,
internal_login=config.login,
internal_password=config.password,
internal_limit=config.record_limit,
every_menu=config.all_menus,
internal_required=config.internal_required,
portal=[
path.strip()
for path in (config.portal or "").split(",")
if path.strip()
],
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
internal_failed = render_internal(internal)
if lst_again is None:
print(render(lst_url, lst_failure, lst_key))
return 1 if (lst_failure or internal_failed) else 0
# Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE.
print(
f"\n↻ {t('Re-checked the')} {len(lst_failure)}"
f" {t('failing URL(s) after the reset')} :"
)
print(render([url for url, _s, _p in lst_failure], lst_again, None))
return 1 if (lst_again or internal_failed) else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -32,6 +32,18 @@ import os
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Columns worth recording. ir_ui_view does not expose the same set across 12.0
# to 18.0, so the query keeps only those that actually exist.
WANTED_COLUMN = [
@ -115,7 +127,7 @@ def save(database, label, output_dir):
file_path = os.path.join(directory, f"{label}.json")
with open(file_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
print(f"✅ -> {len(lst_view)} COW view(s) recorded in {file_path}")
print(f"✅ -> {len(lst_view)} {t('COW view(s) recorded in')} {file_path}")
return file_path
@ -136,18 +148,18 @@ def diff(path_before, path_after):
common = sorted(set(map_before) & set(map_after))
print(
f"📊 {before.get('label')} ({before.get('count')} views)"
f" -> {after.get('label')} ({after.get('count')} views)"
f"📊 {before.get('label')} ({before.get('count')} {t('views')})"
f" -> {after.get('label')} ({after.get('count')} {t('views')})"
)
if removed:
print(f"❌ {len(removed)} COW view(s) disappeared:")
print(f"❌ {len(removed)} {t('COW view(s) disappeared')} :")
for view_id in removed:
view = map_before[view_id]
print(f" - id={view_id} {view.get('key')}")
if added:
print(f"➕ {len(added)} COW view(s) appeared:")
print(f"➕ {len(added)} {t('COW view(s) appeared')} :")
for view_id in added:
view = map_after[view_id]
print(f" - id={view_id} {view.get('key')} ({view.get('mode')})")
@ -160,8 +172,8 @@ def diff(path_before, path_after):
if old.get(field) != new.get(field):
if field == "arch_md5":
lst_field.append(
f"arch rewritten ({old.get('arch_len')} ->"
f" {new.get('arch_len')} chars)"
f"{t('arch rewritten')} ({old.get('arch_len')} ->"
f" {new.get('arch_len')} {t('chars')})"
)
else:
lst_field.append(
@ -171,14 +183,14 @@ def diff(path_before, path_after):
lst_changed.append((view_id, new.get("key"), lst_field))
if lst_changed:
print(f"✏️ {len(lst_changed)} COW view(s) changed:")
print(f"✏️ {len(lst_changed)} {t('COW view(s) changed')} :")
for view_id, key, lst_field in lst_changed:
print(f" - id={view_id} {key}")
for change in lst_field:
print(f" {change}")
if not (removed or added or lst_changed):
print("✅ -> No change on the website COW views.")
print(f"✅ -> {t('No change on the website COW views.')}")
return 0

128
script/todo/auto_ask.py Normal file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Lire une réponse, ou prendre le défaut au bout du délai.
Le mode auto de la migration ne vaut que si TOUTES les invites le
connaissent. Or six d'entre elles vivent dans des outils lancés en
sous-processus — le désinstalleur de thème, le détecteur de SCSS figé, le
test de fumée. Elles ne partagent ni l'objet du pilote ni sa mémoire ; sans
rien de commun, elles attendraient indéfiniment une frappe qui ne vient
pas, et l'automatisation s'arrêterait là sans rien dire.
Ce qui traverse un `fork`, c'est l'environnement. Le pilote y pose
`ERPLIBRE_AUTO_EXECUTE`, chaque outil le lit, et il n'existe qu'une seule
implémentation du compte à rebours.
`select` plutôt qu'un fil ou une alarme : la question est « quelque chose
est-il LISIBLE maintenant », et il faut pouvoir renoncer. Un fil laisserait
derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE"
ENV_DELAY = "ERPLIBRE_AUTO_DELAY"
DEFAULT_DELAY = 5
def enabled():
"""Le mode auto est-il actif pour CE processus ?"""
return os.environ.get(ENV_ENABLED, "") == "1"
def delay():
"""Combien de secondes attendre. Une valeur illisible ne bloque rien."""
try:
value = float(os.environ.get(ENV_DELAY, DEFAULT_DELAY))
except (TypeError, ValueError):
return DEFAULT_DELAY
# Un délai nul ou négatif rendrait la reprise en main impossible : on
# ne peut pas interrompre ce qui ne laisse aucune fenêtre.
return value if value > 0 else DEFAULT_DELAY
def export(on, seconds=None):
"""Poser le mode auto dans l'environnement, pour tout ce qu'on lancera.
C'est le seul canal qu'un sous-processus partage avec nous. Le retirer
quand on l'éteint, plutôt que d'y écrire « 0 », évite qu'un reste de
session précédente décide à notre place.
"""
if on:
os.environ[ENV_ENABLED] = "1"
os.environ[ENV_DELAY] = str(
seconds if seconds is not None else DEFAULT_DELAY
)
else:
os.environ.pop(ENV_ENABLED, None)
os.environ.pop(ENV_DELAY, None)
def ask(prompt, default="", seconds=None):
"""Poser la question. Rendre `default` si rien n'arrive à temps.
Hors mode auto, c'est un `input()` ordinaire — sauf qu'une réponse vide
vaut le défaut, comme partout ailleurs en ligne de commande. C'est ce
qui permet d'écrire l'attendu dans la question : « (Y/n) » doit dire la
vérité pour la personne qui appuie sur Entrée, pas seulement pour le
compte à rebours.
"""
if not enabled():
return input(prompt) or default
import select
attente = delay() if seconds is None else seconds
# DIRE qu'on répondra tout seul, et dans combien de temps. Une invite
# qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et
# c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt
# qu'après : elle se lit avant qu'on décide de partir.
sys.stdout.write(f"⏱{attente:g}s {prompt}")
sys.stdout.flush()
try:
ready, _, _ = select.select([sys.stdin], [], [], attente)
except (OSError, ValueError):
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
return input("") or default
if ready:
answer = sys.stdin.readline().rstrip("\n")
# Une réponse VIDE vaut « prends le défaut » : c'est tout le propos
# du mode auto. Un stdin fermé — exécution non interactive — est
# lisible tout de suite et rend justement une ligne vide ; sans
# ceci, le défaut ne servirait jamais là.
return answer or default
# « Entrée », parce que c'est ce que l'invite a proposé. Elle annonce
# « Entrée = effacer, k = garder » et ne mentionne nulle part « d » :
# afficher la valeur brute nommait donc une réponse qui n'était pas au
# menu, et l'on se demandait ce que l'outil venait de décider. La
# valeur reste, entre parenthèses, pour qui veut la précision.
detail = f" ({default})" if default else ""
print(f" ⏱ → {t('Enter')}{detail}")
return default
def make_ask(default, seconds=None):
"""Un `ask` à un seul argument, pour les outils qui en injectent un.
Ils appellent `ask(question)` sans savoir ce qu'est un défaut : celui-ci
appartient à l'outil, pas à l'appelant, et se fige donc ici.
"""
def ask_one(prompt=""):
return ask(prompt, default=default, seconds=seconds)
return ask_one

View file

@ -68,6 +68,7 @@ def run_resume_tui(ctx, run_app: bool = True):
"""
BINDINGS = [
("c", "cont", t("Continue where it stopped")),
("b", "back_step", t("Go back to a step")),
("n", "new", t("New migration, erase everything")),
("r", "keep_zip", t("Keep the zip only")),
("q", "quit_nothing", t("Quit without doing anything")),
@ -99,10 +100,12 @@ def run_resume_tui(ctx, run_app: bool = True):
id="a_cont",
)
yield Button(t("New migration"), id="a_new")
yield Button(t("Go back to a step"), id="a_back")
yield Button(t("Keep the zip only"), id="a_keep")
yield Button(t("Quit"), id="a_quit")
yield Static(
f" {t('Enter on a step or a version = replay from there')}",
f" {t('Enter on a step or a version = replay from there')}"
f" · b = {t('Go back to a step')}",
id="hint",
)
yield Footer()
@ -156,6 +159,9 @@ def run_resume_tui(ctx, run_app: bool = True):
self._answer(f"4.{event.option.id}")
def on_button_pressed(self, event) -> None:
if event.button.id == "a_back":
self.action_back_step()
return
mapping = {
"a_cont": "c",
"a_new": "n",
@ -166,6 +172,18 @@ def run_resume_tui(ctx, run_app: bool = True):
if value:
self._answer(value)
def action_back_step(self) -> None:
"""Amener au tableau des étapes, où le choix se fait déjà.
Le mécanisme existait — Entrée sur une ligne — mais vivait dans une
ligne d'astuce sous quatre boutons nommés. Une capacité qu'il faut
deviner n'est pas offerte ; celle-ci porte donc une touche, un
bouton et un message, comme les autres.
"""
table = self.query_one("#steps", DataTable)
table.focus()
self.notify(t("Choose a step, Enter replays from there."))
def action_cont(self) -> None:
self._answer("c")

533
script/todo/migration_status.py Executable file
View file

@ -0,0 +1,533 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Où en est cette migration, et qu'est-ce qui a mal tourné.
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal existant dit ce qui a été LANCÉ ; il ne dit
jamais ce que cela a donné. Après trois heures on relit deux cents lignes
de commandes sans savoir laquelle a échoué, ni ce que le test de fumée a
conclu, ni à quelle étape on se trouve.
Ce module assemble la réponse à partir du fichier de progression — le
même que la migration écrit après chaque geste — et la rend en texte. Le
plein écran, lui, n'est qu'une autre vue de CES données : deux rendus
séparés dériveraient l'un de l'autre sans que rien ne le signale.
Rien n'est lu ailleurs que dans ce fichier : l'écran d'état ne doit jamais
toucher une base ni lancer un serveur. On l'ouvre en pleine migration.
"""
import json
import os
import re
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
DEFAULT_PATH = ".venv.erplibre/odoo_database_migration_log.json"
PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration")
STEP_LOG_DIR = "step_log"
EVENT_FILE = "events.jsonl"
# Les codes de sortie que TOUS les outils de migration partagent. Les
# traduire ici plutôt qu'à l'affichage évite qu'un « 1 » passe pour une
# panne alors qu'il annonce des trouvailles.
VERDICT = {
0: ("✅", "nothing to report"),
1: ("⚠️", "findings to look at"),
2: ("❌", "the tool itself failed"),
}
# Les couleurs ANSI, et le droit de s'en passer. `NO_COLOR` est une
# convention respectée par la plupart des outils : la contredire oblige à
# nettoyer une sortie à la main, ce qui est exactement ce qu'on cherchait à
# éviter en la coloriant.
ANSI = {
"cmd": "\033[36m", # cyan : ce qui a été LANCÉ
"step": "\033[1;34m", # bleu gras : les étapes
"ok": "\033[32m",
"warn": "\033[33m",
"fail": "\033[31m",
"dim": "\033[2m",
}
RESET = "\033[0m"
def supports_colour(stream=None):
"""Peut-on colorier CETTE sortie ?
Trois refus, et chacun a coûté à quelqu'un : un fichier de journal
truffé de codes d'échappement, un `grep` qui ne trouve plus rien, un
terminal qui les affiche en clair. Un tube n'est pas un écran.
"""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("TERM", "") in ("", "dumb"):
return False
stream = stream or sys.stdout
try:
return bool(stream.isatty())
except Exception:
return False
def paint(texte, couleur, actif=True):
"""Colorier, ou rendre le texte tel quel. Jamais d'à-peu-près."""
if not actif or couleur not in ANSI:
return texte
return f"{ANSI[couleur]}{texte}{RESET}"
VERDICT_COLOUR = {0: "ok", 1: "warn", 2: "fail"}
def read(path=DEFAULT_PATH):
"""La progression, complétée par ce qui a été écrit SUR DISQUE.
Le fichier de progression est archivé puis remis à zéro quand on
recommence une migration : tout ce qu'il contenait disparaissait alors
de cet écran. Le journal permanent, lui, ne fait que s'allonger — et
c'est justement en revenant après une interruption qu'on a besoin de
ce qui s'est passé avant.
"""
try:
with open(path, "r", encoding="utf-8") as handle:
dct = json.load(handle)
except (OSError, ValueError):
return {}
dct["lst_event"] = merge_events(dct)
return dct
def log_dir(dct):
"""Le répertoire des journaux de cette migration, ou None."""
database = (dct or {}).get("config_database_name")
if not database:
return None
chemin = os.path.join(PATH_MIGRATION_PRIVATE, database, STEP_LOG_DIR)
return chemin if os.path.isdir(chemin) else None
def read_event_file(dct):
"""Les événements du journal permanent, en ignorant les lignes cassées.
Une écriture interrompue laisse une ligne tronquée ; la refuser en bloc
perdrait tout le reste du fichier pour une seule ligne.
"""
chemin = log_dir(dct)
if not chemin:
return []
lst = []
try:
with open(
os.path.join(chemin, EVENT_FILE), "r", encoding="utf-8"
) as handle:
for ligne in handle:
ligne = ligne.strip()
if not ligne:
continue
try:
lst.append(json.loads(ligne))
except ValueError:
continue
except OSError:
return []
return lst
def merge_events(dct):
"""Le disque d'abord, la mémoire ensuite, sans doublon.
Les deux sources se recouvrent : ce qui vient d'être enregistré est aux
deux endroits. On dédoublonne sur (horodatage, nom, type) — trois
champs qu'un même événement porte à l'identique dans les deux.
"""
vus = set()
fusion = []
for item in read_event_file(dct) + list(dct.get("lst_event") or []):
cle = (item.get("at"), item.get("name"), item.get("kind"))
if cle in vus:
continue
vus.add(cle)
fusion.append(item)
return fusion
def step_log_path(dct, step):
"""Le fichier de journal d'une étape, s'il existe."""
chemin = log_dir(dct)
if not chemin or not step:
return None
fichier = os.path.join(chemin, f"{step_slug(step)}.log")
return fichier if os.path.isfile(fichier) else None
def step_slug(msg):
"""Le même nom que celui écrit par la migration. Un seul calcul.
Deux formules séparées dériveraient, et l'écran chercherait alors un
fichier que personne n'écrit — sans rien signaler, puisqu'un fichier
absent se lit comme une étape sans journal.
"""
import re
prefix, sep, label = (msg or "").partition(" - ")
propre = re.sub(r"[^A-Za-z0-9._-]+", "-", (label or prefix)).strip("-")
tete = re.sub(r"[^A-Za-z0-9._-]+", "-", prefix).strip("-") if sep else ""
nom = f"{tete}_{propre}" if tete else propre
return (nom or "step")[:80].lower()
# Le format d'une ligne de journal Odoo :
# 2026-08-19 09:21:07,074 132948 ERROR <base> odoo.tools.translate: message
# Le nom de la base en fait partie, et c'est ce qui permet de séparer six
# paliers écrits dans un même fichier — ce qui était le cas avant qu'une
# étape n'ouvre son propre journal.
RE_LOG_LINE = re.compile(
r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d+ \d+ (\w+) (\S+) ([\w.]+): (.*)$"
)
GRAVE = ("ERROR", "CRITICAL")
_CACHE = {}
def scan_log(chemin):
"""Compter les sévérités et relever les erreurs DISTINCTES d'un journal.
Mis en cache sur (taille, date) : l'écran se redessine à chaque touche,
et un journal d'étape atteint treize mégaoctets — mesuré. Le relire à
chaque frappe rendrait l'écran inutilisable.
Les erreurs sont dédoublonnées avec leur nombre : quarante-huit fois
« Model X has no table » est UN problème vu quarante-huit fois, pas
quarante-huit problèmes, et la liste brute noie tout le reste.
"""
try:
stat = os.stat(chemin)
except OSError:
return {"count": {}, "errors": []}
cle = (chemin, stat.st_size, int(stat.st_mtime))
if cle in _CACHE:
return _CACHE[cle]
compte = {}
distinct = {}
try:
with open(chemin, "r", encoding="utf-8", errors="replace") as handle:
for ligne in handle:
trouve = RE_LOG_LINE.match(ligne)
if not trouve:
if ligne.startswith("Traceback"):
compte["TRACEBACK"] = compte.get("TRACEBACK", 0) + 1
continue
niveau, base, logger, message = trouve.groups()
compte[niveau] = compte.get(niveau, 0) + 1
if niveau in GRAVE:
signature = (base, logger, message.strip()[:120])
distinct[signature] = distinct.get(signature, 0) + 1
except OSError:
return {"count": {}, "errors": []}
resultat = {
"count": compte,
"errors": [
{
"database": base,
"logger": logger,
"message": message,
"times": nombre,
}
for (base, logger, message), nombre in sorted(
distinct.items(), key=lambda item: -item[1]
)
],
}
# Un seul journal en cache : ils pèsent des mégaoctets, et l'écran ne
# regarde qu'une étape à la fois.
_CACHE.clear()
_CACHE[cle] = resultat
return resultat
def step_log_scan(dct, step):
"""Ce que le journal de cette étape contient de grave."""
chemin = step_log_path(dct, step)
return scan_log(chemin) if chemin else {"count": {}, "errors": []}
def severe_count(scan):
"""Combien d'ERROR et de CRITICAL, en un seul nombre."""
return sum(scan["count"].get(niveau, 0) for niveau in GRAVE)
def step_log_tail(dct, step, lines=400):
"""Les dernières lignes du journal de cette étape, et le compte total.
Rend (lignes, total). Le total n'est pas décoratif : une mise à jour de
modules écrit des dizaines de milliers de lignes, et montrer les
dernières SANS dire qu'on en cache se lit comme « il manque des logs ».
"""
chemin = step_log_path(dct, step)
if not chemin:
return [], 0
try:
with open(chemin, "r", encoding="utf-8", errors="replace") as handle:
lst = handle.read().splitlines()
except OSError:
return [], 0
return lst[-lines:], len(lst)
def journal_by_step(dct):
"""Le journal découpé par étape, dans l'ordre.
Les entrées commençant par « # » sont les en-têtes d'étape que la
migration y dépose elle-même ; tout ce qui suit appartient à l'étape
ouverte. On réutilise donc un marquage qui existe déjà plutôt que d'en
inventer un second, qui divergerait.
"""
lst_section = []
courante = None
for entry in dct.get("command_executed") or []:
texte = str(entry)
if texte.startswith("#"):
courante = {"step": texte.lstrip("# ").strip(), "lst_cmd": []}
lst_section.append(courante)
continue
if courante is None:
courante = {"step": t("before the first step"), "lst_cmd": []}
lst_section.append(courante)
courante["lst_cmd"].append(texte)
return lst_section
def events(dct, kind=None):
"""Ce qui a mal tourné, et ce que les outils ont conclu."""
lst = [
item
for item in (dct.get("lst_event") or [])
if kind is None or item.get("kind") == kind
]
return lst
def verdict(status):
"""(icône, phrase) pour un code de sortie d'outil."""
icone, phrase = VERDICT.get(status, ("❔", "unknown result"))
return icone, t(phrase)
def tests_summary(dct):
"""Le dernier verdict de chaque outil, PAR ÉTAPE, et son nombre de passages.
Par étape, car c'est la question qu'on pose. Une migration lance le
test de fumée à CHAQUE palier ; regrouper sur le seul nom d'outil n'en
laissait qu'une ligne, et l'on lisait « smoke_public_url ✅ » sans voir
que le palier 14 était passé et le 17 tombé.
Dans une étape, le DERNIER verdict l'emporte : un outil relancé après
correction a deux verdicts contradictoires, et c'est le second qui
décrit la base telle qu'elle est. Les afficher tous deux sans les
distinguer ferait lire une réparation comme un échec persistant.
L'ordre est celui du journal, donc celui de la migration. Trier les
étapes par leur nom mettrait « 4.10 » avant « 4.2 ».
"""
dernier = {}
for item in events(dct, kind="test"):
cle = (item.get("step") or "", item.get("name") or "?")
entree = dernier.setdefault(
cle, {"name": cle[1], "step": cle[0], "runs": 0}
)
entree["runs"] += 1
entree["status"] = item.get("status")
entree["at"] = item.get("at")
return list(dernier.values())
def tests_by_step(dct):
"""Les verdicts groupés sous leur étape, dans l'ordre de la migration."""
par_etape = {}
for item in tests_summary(dct):
par_etape.setdefault(item["step"], []).append(item)
return list(par_etape.items())
def failures(dct):
"""Les commandes qui ont échoué, la plus récente d'abord."""
return list(reversed(events(dct, kind="command")))
def elapsed(dct):
"""Combien de temps la migration a duré, ou dure encore.
Du premier écrit à la DERNIÈRE écriture du journal : la progression est
réécrite après chaque geste, donc sa date de mise à jour EST la fin —
ou l'instant présent si la migration tourne toujours.
On délègue à `migration_stats.fmt_delay`, qui porte déjà ce calcul pour
l'écran de statistiques. Deux formules donneraient deux durées pour la
même migration selon l'écran qu'on ouvre.
"""
try:
from script.todo.migration_stats import fmt_delay
except Exception:
return "?"
return fmt_delay(dct.get("date_create"), dct.get("date_update"))
def overview(dct):
"""L'en-tête : de quelle migration parle-t-on."""
return {
"elapsed": elapsed(dct),
"file": os.path.basename(dct.get("migration_file") or "?"),
"database": dct.get("config_database_name") or "?",
"target": dct.get("target_odoo_version") or "?",
"started": dct.get("date_create") or "?",
"updated": dct.get("date_update") or "?",
"step": (
(dct.get("lst_event") or [{}])[-1].get("step")
or _last_step(dct)
or "?"
),
}
def _last_step(dct):
lst = journal_by_step(dct)
return lst[-1]["step"] if lst else None
def bumps(dct):
"""Les paliers, et lesquels sont faits."""
done = dct.get("state_4_upgrade_odoo_lst") or []
lst = dct.get("lst_version_bump") or []
if not lst:
# Le nom des bases de palier porte la version : c'est la seule
# source disponible quand la liste n'a pas été écrite.
lst = list(range(len(done)))
return [
{"version": version, "done": bool(i < len(done) and done[i])}
for i, version in enumerate(lst)
]
def render_text(dct, limit_cmd=12, colour=None):
"""Le rapport complet, en texte. C'est aussi le repli du plein écran.
`colour` à None laisse la sortie décider : un écran est colorié, un
tube ne l'est pas. Le forcer sert aux tests, qui doivent pouvoir
vérifier les deux sans dépendre de l'endroit où ils tournent.
"""
if colour is None:
colour = supports_colour()
if not dct:
return f"ℹ️ {t('No migration in progress.')}"
info = overview(dct)
lignes = [
f"📍 {t('Migration state')}",
f" {t('database')} : {info['database']}",
f" {t('image')} : {info['file']}",
f" {t('started')} : {info['started']}",
f" {t('finished')} : {info['updated']}",
f" {t('duration')} : {info['elapsed']}",
f" {t('current step')} : {info['step']}",
]
lst_test = tests_summary(dct)
lignes.append(f"\n🧪 {t('Test results')}")
if not lst_test:
lignes.append(f" {t('No tool has run yet.')}")
for etape, lst_item in tests_by_step(dct):
lignes.append(
f" {paint(etape or t('before the first step'), 'step', colour)}"
)
for item in lst_item:
icone, phrase = verdict(item.get("status"))
rejeu = (
f" ({item['runs']} {t('runs')})"
if item.get("runs", 1) > 1
else ""
)
teinte = VERDICT_COLOUR.get(item.get("status"), "dim")
nom = f"{item['name']:<24}"
lignes.append(
f" {icone} {paint(nom, teinte, colour)} {phrase}{rejeu}"
)
lst_failure = failures(dct)
lignes.append(f"\n❌ {t('Commands that failed')} : {len(lst_failure)}")
for item in lst_failure[:10]:
lignes.append(
f" · [{item.get('step') or '?'}]"
f" {paint(item.get('name') or '', 'fail', colour)}"
)
lignes.append(f"\n🔷 {t('What was done, step by step')}")
for section in journal_by_step(dct):
lst_cmd = section["lst_cmd"]
journal = " 📄" if step_log_path(dct, section["step"]) else ""
scan = step_log_scan(dct, section["step"])
graves = severe_count(scan)
# Le nombre d'ERROR à côté de l'étape : c'est la seule façon de
# voir d'un coup d'œil OÙ la migration a souffert, sans ouvrir
# treize mégaoctets de journal.
alerte = f" {paint(f'❌ {graves}', 'fail', colour)}" if graves else ""
lignes.append(
f" {paint(section['step'], 'step', colour)}"
f" ({len(lst_cmd)}){journal}{alerte}"
)
for item in scan["errors"][:3]:
lignes.append(
f" ×{item['times']:<3}"
f" {paint(item['message'][:100], 'warn', colour)}"
)
for cmd in lst_cmd[:limit_cmd]:
# LA demande : distinguer d'un coup d'œil ce qui a été lancé
# du reste du rapport. Une liste de commandes en texte plat se
# confond avec ses titres dès qu'elle dépasse l'écran.
lignes.append(f" {paint(cmd[:120], 'cmd', colour)}")
if len(lst_cmd) > limit_cmd:
reste = len(lst_cmd) - limit_cmd
lignes.append(f" … {reste} {t('more')}")
return "\n".join(lignes)
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(
description="Show what this migration has done, and what failed."
)
parser.add_argument("-f", "--file", default=DEFAULT_PATH)
parser.add_argument(
"--text",
action="store_true",
help="print the report instead of opening the full screen",
)
config = parser.parse_args(argv)
dct = read(config.file)
if not config.text:
try:
from script.todo.migration_status_tui import run_tui
except Exception:
run_tui = None
if run_tui and run_tui(dct, path=config.file):
return 0
print(render_text(dct))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,420 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'état d'une migration, en plein écran.
Le rapport texte dit tout, mais il dit tout D'UN COUP : sur une migration
de six paliers il fait plusieurs centaines de lignes, et ce qu'on cherche —
l'étape où ça a cassé, ce que le test de fumée a conclu — se trouve
quelque part au milieu. Un écran qui se parcourt règle exactement cela.
Les données viennent de `migration_status`, comme le rapport texte. Deux
assemblages séparés dériveraient l'un de l'autre sans que rien ne le dise,
et l'on finirait par lire deux états contradictoires de la même migration.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.todo import migration_status as status # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 4; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 46; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def head_text(dct):
"""Ce qui identifie la migration. Toujours visible, jamais à chercher."""
info = status.overview(dct)
lst_test = status.tests_summary(dct)
casse = [x for x in lst_test if x.get("status")]
return (
f"{info['database']} · {info['file']}\n"
f"{t('current step')} : {info['step']}\n"
f"{t('finished')} : {info['updated']}"
f" · {t('duration')} {info['elapsed']}"
f" · {len(status.failures(dct))} {t('failed')}"
f" · {len(casse)}/{len(lst_test)} {t('Test results')}"
)
def rows(dct):
"""Les lignes du panneau de gauche : tests d'abord, puis les étapes.
Les tests en tête parce que c'est la question qu'on se pose en ouvrant
cet écran ; les étapes ensuite parce que c'est là qu'on cherche le
détail une fois qu'on sait QUOI chercher.
"""
lst = []
for item in status.tests_summary(dct):
icone, _phrase = status.verdict(item.get("status"))
# Le NUMÉRO de l'étape en tête : une migration lance le même outil
# à chaque palier, et sans lui la liste montrait six lignes
# identiques sans dire laquelle appartenait à quel palier.
numero = (item.get("step") or "").split(" - ")[0]
tete = f"{numero} " if numero else ""
lst.append(
{
"kind": "test",
"label": f"{icone} {tete}{item['name']}",
"detail": str(item.get("runs", 1)),
"data": item,
}
)
for section in status.journal_by_step(dct):
graves = status.severe_count(
status.step_log_scan(dct, section["step"])
)
lst.append(
{
"kind": "step",
"label": section["step"],
"detail": str(len(section["lst_cmd"])),
"severe": str(graves) if graves else "",
"data": section,
}
)
return lst
def pane_text(dct, row, colour=False, show_log=True):
"""Le détail de la ligne choisie.
Le coloriage passe par de l'ANSI, que Rich sait décoder — et qui rend
au passage le texte LITTÉRAL : une commande contenant « [1] » était
jusqu'ici prise pour du balisage Rich et avalée sans un mot.
"""
if row is None:
return t("Nothing to show yet.")
if row["kind"] == "test":
item = row["data"]
icone, phrase = status.verdict(item.get("status"))
teinte = status.VERDICT_COLOUR.get(item.get("status"), "dim")
lignes = [
f"{icone} {status.paint(item['name'], teinte, colour)}",
f" {status.paint(item.get('step') or '?', 'step', colour)}",
f" {phrase} ({t('exit code')} {item.get('status')})",
f" {t('runs')} : {item.get('runs')}",
f" {item.get('at') or ''}",
]
return "\n".join(lignes)
section = row["data"]
lignes = [status.paint(section["step"], "step", colour), ""]
lst_failure = [
item
for item in status.failures(dct)
if (item.get("step") or "") == section["step"]
]
if lst_failure:
lignes.append(f"❌ {t('Commands that failed')} :")
for item in lst_failure:
lignes.append(
f" {status.paint(item.get('name') or '', 'fail', colour)}"
)
lignes.append("")
for cmd in section["lst_cmd"]:
lignes.append(f"· {status.paint(cmd, 'cmd', colour)}")
# La SORTIE des commandes, relue sur disque. C'est ce qui manquait :
# la liste des commandes dit ce qui a été lancé, jamais ce que cela a
# répondu. Mais les deux mélangés dans un même panneau se confondent —
# d'où « l », qui les sépare.
# Les erreurs DISTINCTES d'abord, le journal brut ensuite : quarante-
# huit fois le même message est un problème vu quarante-huit fois, et
# la liste brute le noie au milieu de cent mille lignes.
scan = status.step_log_scan(dct, section["step"])
if scan["errors"]:
lignes.append("")
lignes.append(
f"── {t('errors in the log')} :" f" {status.severe_count(scan)} ──"
)
for item in scan["errors"][:15]:
lignes.append(
f" ×{item['times']:<4}"
f" {status.paint(item['message'], 'fail', colour)}"
)
lignes.append(
f" {status.paint(item['logger'], 'dim', colour)}"
f" · {item['database']}"
)
tail, total = status.step_log_tail(dct, section["step"])
if not show_log:
if total:
lignes.append("")
lignes.append(
f"── {t('server log')} : {total} {t('lines')}"
f" ({t('press l to show')}) ──"
)
return "\n".join(lignes)
if tail:
lignes.append("")
cache = (
f" — {t('last')} {len(tail)} {t('of')} {total}"
if total > len(tail)
else ""
)
lignes.append(f"── {t('server log')}{cache} ──")
lignes.extend(tail)
elif not section["lst_cmd"]:
lignes.append(t("No tool has run yet."))
return "\n".join(lignes)
PANELS = ("all", "no-summary", "detail")
def panel_label(state):
"""Le nom de l'état courant, tel qu'il s'affiche dans le sous-titre."""
return {
"all": t("summary + list + detail"),
"no-summary": t("list + detail"),
"detail": t("detail only"),
}[PANELS[state % len(PANELS)]]
def panel_visibility(state):
"""(résumé visible, liste visible) pour cet état.
Trois états, du plus complet au plus dépouillé. Un simple bascule ne
libérait que quatre lignes ; ce qui prend la place, c'est la COLONNE de
gauche — et sur un journal de serveur, chaque caractère gagné en
largeur compte.
"""
etat = PANELS[state % len(PANELS)]
return etat == "all", etat != "detail"
def current_row(lst_row, index):
"""La ligne choisie, ou None quand il n'y en a aucune."""
return lst_row[index] if lst_row and 0 <= index < len(lst_row) else None
def reread(path):
"""Relire la progression, ou None si l'on ne sait pas d'où."""
return status.read(path) if path else None
def apply_panels(app):
"""Poser la visibilité des panneaux, et NOMMER l'état choisi.
Le sous-titre reste visible dans les trois : un panneau qui disparaît
sans un mot se lit comme un écran cassé.
"""
resume, liste = panel_visibility(app.panel_state)
app.query_one("#head").display = resume
app.query_one("#left").display = liste
app.sub_title = panel_label(app.panel_state)
def apply_width(app, delta):
"""Déplacer la séparation, bornée des deux côtés.
Une colonne de zéro ne se retrouve plus ; une qui mange tout l'écran ne
laisse rien à lire.
"""
app.left_width = max(
app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + delta)
)
app.query_one("#left").styles.width = app.left_width
def fill_table(app):
"""(Re)construire la liste de gauche depuis les données courantes."""
table = app.query_one("#left")
table.clear(columns=True)
table.add_columns(t("Test results"), "#", "❌")
for row in app.lst_row:
table.add_row(row["label"][:38], row["detail"], row.get("severe", ""))
table.styles.width = app.left_width
app.query_one("#head").update(head_text(app.dct))
def show_pane(app):
"""Peindre le détail de la ligne choisie.
`from_ansi` fait DEUX choses : il rend les couleurs, et il traite le
reste comme du texte LITTÉRAL. Sans lui, une commande contenant
« [1] » passait pour du balisage Rich et disparaissait sans un mot.
"""
from rich.text import Text
row = current_row(app.lst_row, app.index)
app.query_one("#content").update(
Text.from_ansi(
pane_text(app.dct, row, colour=True, show_log=app.show_log)
)
)
def build_app(dct, path=None):
"""Textual est importé ICI, pas au chargement du module.
Le module reste importable — donc testable — sur une machine sans
Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le
rapport texte plutôt que d'échouer.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
class StatusApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", t("Quit")),
("r", "refresh", t("Refresh")),
("l", "toggle_log", t("Logs")),
("p", "cycle_panels", t("Panels")),
("k", "quality", t("Quality")),
("plus,equal", "wider", t("Wider")),
("minus,underscore", "narrower", t("Narrower")),
]
LEFT_MIN = 16
LEFT_MAX = 110
LEFT_STEP = 6
def __init__(self, dct, path=None):
super().__init__()
self.dct = dct
self.path = path
self.lst_row = rows(dct)
self.index = 0
self.show_log = True
self.left_width = 46
self.panel_state = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="left", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = t("Migration state")
fill_table(self)
apply_panels(self)
show_pane(self)
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "left" and self.lst_row:
self.index = event.cursor_row
show_pane(self)
def action_refresh(self):
"""Relire le disque. La migration écrit PENDANT qu'on regarde.
L'écran s'ouvre au milieu d'une migration qui continue : sans
cela, il fallait le fermer et le rouvrir pour voir le palier
suivant.
"""
neuf = reread(self.path)
if neuf is None:
return
self.dct = neuf
self.lst_row = rows(self.dct)
self.index = min(self.index, max(0, len(self.lst_row) - 1))
fill_table(self)
show_pane(self)
def action_quality(self):
"""Ouvrir le rapport de qualité, sans quitter celui-ci.
Les deux écrans répondent à deux questions voisines : « où en
est-on » et « qu'a-t-on gagné ou perdu en chemin ».
En SOUS-PROCESSUS, et ce n'est pas un choix de confort :
`app.run()` appelle `asyncio.run()`, qui refuse de tourner
dans une boucle déjà en cours — et nous sommes justement
dedans. Mesuré, la trace est
« asyncio.run() cannot be called from a running event loop ».
`suspend()` rend le terminal ; le sous-processus a sa propre
boucle et le repeint.
"""
import subprocess
with self.suspend():
subprocess.call(
[
sys.executable,
os.path.join(
"script", "analyse", "check_migration_quality.py"
),
]
)
def action_toggle_log(self):
self.show_log = not self.show_log
show_pane(self)
def action_cycle_panels(self):
"""Trois états, du plus complet au plus dépouillé.
Un simple bascule ne libérait que quatre lignes. Ce qui prend
la place, c'est la COLONNE de gauche : sur un journal de
serveur, chaque caractère gagné en largeur compte. On enchaîne
donc tout → sans résumé → détail seul, et l'on revient.
L'état est écrit dans le sous-titre, qui reste visible dans les
trois : un panneau qui disparaît sans un mot se lit comme un
écran cassé.
"""
self.panel_state = (self.panel_state + 1) % len(PANELS)
apply_panels(self)
def action_wider(self):
apply_width(self, self.LEFT_STEP)
def action_narrower(self):
apply_width(self, -self.LEFT_STEP)
return StatusApp(dct, path)
def run_tui(dct, run_app=True, path=None):
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.
Se taire ferait réafficher le rapport texte à la place de l'écran
demandé, sans rien qui distingue les deux.
"""
if not dct:
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
try:
app = build_app(dct, path=path)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
app.run()
return True

View file

@ -889,7 +889,17 @@ class TODO:
return False
elif status == str(len(choices) - 1):
upgrade = todo_upgrade.TodoUpgrade(self)
upgrade.execute_odoo_upgrade()
try:
upgrade.execute_odoo_upgrade()
except todo_upgrade.MigrationRewind:
# L'état est déjà rembobiné et écrit : il ne reste qu'à
# relancer, et l'écran de reprise repartira de l'étape
# choisie. Sortir d'ici plutôt que de rappeler la méthode
# évite de la reprendre au milieu de son état local.
print(
f"\n⏪ {t('Rewound.')}"
f" {t('Relaunch the migration to resume from there.')}"
)
elif status == str(len(choices)):
self.upgrade_poetry()
else:
@ -7702,6 +7712,8 @@ class TODO:
)
},
{"prompt_description": t("Studio and hand-made x_ fields")},
{"section": t("Migration")},
{"prompt_description": t("Quality of a migration, step by step")},
]
help_info = self.fill_help_info(choices)
@ -7716,9 +7728,41 @@ class TODO:
self.execute_analyse_view_custom()
elif status == "3":
self.execute_analyse_custom_field()
elif status == "4":
self.execute_analyse_migration_quality()
else:
print(t("Command not found !"))
def execute_analyse_migration_quality(self):
"""Ce qu'une migration a gagné et perdu, palier par palier.
Appelé comme les autres analyses — même interpréteur, aucun
sous-processus — mais l'écran plein est ouvert par l'outil
lui-même, qui sait retomber sur son rapport texte s'il ne peut pas.
Lecture seule de bout en bout : les bases de palier sont parfois la
seule copie qui reste d'un état intermédiaire.
"""
from script.analyse import check_migration_quality as quality
dct = quality.read_progression()
if not quality.chain(dct):
print(f"\nℹ️ {t('No migration in progress.')}")
return
try:
lst = quality.survey(
dct, echo=lambda texte: print(f"⧖ {texte}", flush=True)
)
except Exception as exc:
print(f"❌ {t('Analysis failed: ')}{exc}")
return
try:
from script.analyse.check_migration_quality_tui import run_tui
except Exception:
run_tui = None
if not (run_tui and run_tui(lst)):
print(quality.render_text(lst))
def _analyse_select_source(self):
"""(est_une_sauvegarde, cible), ou None si l'on renonce.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

271
test/test_auto_execute.py Executable file
View file

@ -0,0 +1,271 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Enchaîner la migration sans rester devant à taper Entrée.
Une migration pose des dizaines de questions dont la réponse est presque
toujours celle proposée. Le mode auto attend cinq secondes puis prend le
défaut — assez pour reprendre la main, assez peu pour ne pas immobiliser
quelqu'un des heures.
La question est posée AVANT le choix de version, donc avant la première
décision : activée, elle vaut pour toutes les suivantes, celle-là comprise.
Le test qui compte le plus est le dernier : une invite écrite plus tard en
`input()` nu ne saurait rien du mode auto, et bloquerait la migration sans
que rien ne le signale.
"""
import ast
import inspect
import io
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from script.todo import todo_i18n # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
def upgrade(auto, delay=0.2):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.auto_execute = auto
obj.AUTO_DELAY = delay
return obj
class EnvCase(unittest.TestCase):
"""Rendre l'environnement comme on l'a trouvé.
`ask` pose le mode auto dans l'environnement — c'est ainsi qu'il atteint
les outils lancés à part. Le laisser posé le ferait fuir dans TOUTE la
suite : une invite sans rapport prendrait son défaut au lieu d'attendre,
et le test qui échouerait ne serait pas celui qui a fauté.
"""
def setUp(self):
from script.todo import auto_ask
avant = {
key: os.environ.get(key)
for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
}
def remettre():
for key, value in avant.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(remettre)
class TestTheTimedRead(EnvCase):
def setUp(self):
super().setUp()
self.original = sys.stdin
self.addCleanup(setattr, sys, "stdin", self.original)
def pipe_stdin(self, payload=b""):
"""Un vrai descripteur : `select` ne sait pas lire un faux objet."""
read_fd, write_fd = os.pipe()
if payload:
os.write(write_fd, payload)
os.close(write_fd)
handle = os.fdopen(read_fd)
self.addCleanup(handle.close)
sys.stdin = handle
def test_without_auto_it_just_asks(self):
import builtins
original = builtins.input
builtins.input = lambda prompt="": "tapé"
self.addCleanup(setattr, builtins, "input", original)
self.assertEqual(upgrade(False).ask("q : ", default="6"), "tapé")
def test_with_auto_an_answer_wins(self):
self.pipe_stdin(b"2\n")
out = io.StringIO()
real = sys.stdout
sys.stdout = out
try:
got = upgrade(True).ask("q : ", default="6")
finally:
sys.stdout = real
self.assertEqual(got, "2")
def test_a_closed_stdin_gives_the_default(self):
# Exécution non interactive : le tuyau fermé est lisible TOUT DE
# SUITE et rend une ligne vide. Sans traiter le vide comme le
# défaut, le mode auto n'y servirait à rien.
self.pipe_stdin()
out = io.StringIO()
real = sys.stdout
sys.stdout = out
try:
got = upgrade(True).ask("q : ", default="6")
finally:
sys.stdout = real
self.assertEqual(got, "6")
def test_silence_gives_the_default_after_the_delay(self):
# Le tuyau reste OUVERT : rien à lire, et select doit rendre la main
# au bout du délai plutôt que d'attendre indéfiniment.
import time
read_fd, write_fd = os.pipe()
self.addCleanup(os.close, write_fd)
handle = os.fdopen(read_fd)
self.addCleanup(handle.close)
sys.stdin = handle
out = io.StringIO()
real = sys.stdout
sys.stdout = out
debut = time.time()
try:
got = upgrade(True, delay=0.3).ask("q : ", default="6")
finally:
sys.stdout = real
self.assertEqual(got, "6")
self.assertGreaterEqual(time.time() - debut, 0.25)
def test_an_unselectable_stdin_falls_back_to_asking(self):
# Ne pas deviner quand on ne peut pas mesurer : mieux vaut demander
# que rendre un défaut que personne n'a voulu.
import builtins
class NotSelectable:
def fileno(self):
raise ValueError("pas de descripteur")
sys.stdin = NotSelectable()
original = builtins.input
builtins.input = lambda prompt="": "demandé"
self.addCleanup(setattr, builtins, "input", original)
out = io.StringIO()
real = sys.stdout
sys.stdout = out
try:
got = upgrade(True).ask("q : ", default="6")
finally:
sys.stdout = real
self.assertEqual(got, "demandé")
class TestTheQuestionThatEnablesIt(EnvCase):
def setUp(self):
super().setUp()
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def answer(self, typed):
import builtins
import contextlib
obj = TodoUpgrade.__new__(TodoUpgrade)
original = builtins.input
builtins.input = lambda prompt="": typed
self.addCleanup(setattr, builtins, "input", original)
out = io.StringIO()
with contextlib.redirect_stdout(out):
obj.prompt_auto_execute()
return obj.auto_execute, out.getvalue()
def test_yes_turns_it_on(self):
on, text = self.answer("y")
self.assertTrue(on)
self.assertIn("Auto-run on", text)
def test_the_default_is_off(self):
# Elle prend des décisions à votre place : ce n'est pas le défaut.
on, text = self.answer("")
self.assertFalse(on)
self.assertEqual(text, "")
def test_anything_else_is_off(self):
self.assertFalse(self.answer("peut-être")[0])
def test_the_delay_is_five_seconds(self):
self.assertEqual(TodoUpgrade.AUTO_DELAY, 5)
def test_yes_also_arms_the_tools_launched_apart(self):
# La moitié des invites d'une migration sont posées par d'autres
# processus. Sans cette variable, ils attendraient une frappe qui
# ne vient jamais — et l'automatisation s'arrêterait là, en
# silence, puisque la question a bien été posée.
from script.todo import auto_ask
self.answer("y")
self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
def test_no_leaves_nothing_behind(self):
from script.todo import auto_ask
os.environ[auto_ask.ENV_ENABLED] = "1"
self.answer("")
self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
class TestWhereItIsAsked(unittest.TestCase):
def source(self):
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_it_comes_before_the_version_choice(self):
# « avant de choisir la version d'Odoo » : activée, elle vaut aussi
# pour ce choix-là, qui a désormais un défaut.
source = self.source()
self.assertLess(
source.index("prompt_auto_execute"),
source.index("Which version do you want to upgrade to?"),
)
def test_the_version_choice_goes_through_it(self):
# click.prompt ne sait pas rendre la main après un délai : le laisser
# là aurait fait un mode auto qui s'arrête à la première question.
source = self.source()
self.assertNotIn("click.prompt(", source)
class TestNoPromptEscapesIt(unittest.TestCase):
"""Une invite en `input()` nu ne saurait rien du mode auto.
Elle bloquerait la migration sans rien signaler — et c'est précisément
le genre de chose qu'on n'ajoute pas exprès, mais par habitude.
"""
def test_every_prompt_of_the_migration_uses_ask(self):
path = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
with open(path) as handle:
tree = ast.parse(handle.read())
target = [
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef)
and node.name == "execute_odoo_upgrade"
]
self.assertEqual(len(target), 1, "fonction introuvable")
bare = [
node.lineno
for node in ast.walk(target[0])
if isinstance(node, ast.Call)
and getattr(node.func, "id", "") == "input"
]
self.assertEqual(bare, [], "invites hors du mode auto")
def test_the_gate_uses_it_too(self):
# `ask_gate` porte le retour en arrière : la laisser en input() nu
# aurait fait un mode auto qui s'arrête à chaque « b = revenir ».
source = inspect.getsource(TodoUpgrade.ask_gate)
self.assertIn("self.ask(", source)
self.assertNotIn("= input(", source)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le test qui aurait vu venir la disparition des documents DMS.
Une propriété porte tout : le comptage se fait avec de VRAIS utilisateurs
internes, jamais avec le super-utilisateur. Les règles ne s'appliquent pas
à lui ; un comptage fait en son nom déclarerait saine une base que
personne ne peut lire — exactement l'erreur qui a laissé passer le cas
DMS pendant six paliers.
"""
import ast
import io
import os
import sys
import unittest
from contextlib import redirect_stdout
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import check_hidden_models as check # noqa: E402
from script.todo import todo_i18n # noqa: E402
class TestTheGeneratedScript(unittest.TestCase):
def test_it_is_valid_python(self):
ast.parse(check.build_script())
def test_the_superuser_is_excluded(self):
# Sans cette exclusion, tout paraît visible et le test ne sert à
# rien : c'est le cœur du sujet.
code = check.build_script()
self.assertIn('("id", "!=", 1)', code)
def test_only_internal_users_count(self):
# Un portail ne voit presque rien par construction : l'inclure
# ferait crier au loup sur des modèles parfaitement sains.
self.assertIn('("share", "=", False)', check.build_script())
def test_only_active_users_count(self):
self.assertIn('("active", "=", True)', check.build_script())
def test_it_looks_at_global_rules_only(self):
# Une règle non globale ne s'applique qu'à certains groupes :
# qu'elle masque tout pour eux est normal.
code = check.build_script()
self.assertIn('("global", "=", True)', code)
self.assertIn('("active", "=", True)', code)
def test_empty_models_are_skipped(self):
# Un modèle sans ligne n'a rien à cacher ; le signaler noierait la
# vraie trouvaille.
self.assertIn("if not total:", check.build_script())
def test_it_stops_at_the_first_user_who_sees_something(self):
# Sans court-circuit, le coût est modèles × utilisateurs sur une
# base où presque tout est visible.
self.assertIn("break", check.build_script())
def test_the_user_limit_is_honoured(self):
code = check.build_script(limite=7)
self.assertIn("LIMITE = 7", code)
self.assertIn("limit=LIMITE", code)
def test_technical_models_are_excluded_by_name(self):
code = check.build_script()
for nom in ("ir.rule", "ir.model.access"):
self.assertIn(nom, code)
def test_the_exclusion_list_never_swallows_business_models(self):
# Une exclusion trop large rendrait le test muet sans le dire.
for nom in check.ATTENDUS:
self.assertTrue(
nom.startswith(("ir.", "bus.", "res.users.log", "mail.")),
f"exclusion suspecte : {nom}",
)
def test_it_reuses_the_shared_sentinels(self):
from script.odoo.migration import database_cleanup
self.assertEqual(check.DEBUT, database_cleanup.START)
self.assertEqual(check.FIN, database_cleanup.END)
class TestTheReport(unittest.TestCase):
def test_a_clean_database_says_so(self):
texte = "\n".join(
check.render({"models": [], "checked": 85, "users": ["a", "b"]})
)
self.assertIn(
todo_i18n.t("Every one of them is visible to someone."), texte
)
def test_a_finding_names_the_model_and_the_volume(self):
rapport = {
"models": [
{"model": "dms.file", "rows": 69},
{"model": "dms.directory", "rows": 16},
],
"checked": 85,
"users": ["a"],
}
texte = "\n".join(check.render(rapport))
self.assertIn("dms.file", texte)
self.assertIn("69", texte)
self.assertIn(
todo_i18n.t("The data is there; a global rule hides all of it."),
texte,
)
def test_the_biggest_loss_comes_first(self):
rapport = {
"models": [
{"model": "aaa.petit", "rows": 3},
{"model": "zzz.gros", "rows": 900},
],
"checked": 2,
"users": ["a"],
}
texte = "\n".join(check.render(rapport))
self.assertLess(texte.index("zzz.gros"), texte.index("aaa.petit"))
def test_no_internal_user_is_flagged_not_called_clean(self):
# Sans utilisateur, on ne SAIT pas. Dire « tout va bien » serait
# un mensonge tranquille.
texte = "\n".join(check.render({"no_user": True}))
self.assertIn(
todo_i18n.t("No internal user to test visibility with."), texte
)
self.assertNotIn(
todo_i18n.t("Every one of them is visible to someone."), texte
)
def test_every_translation_key_exists(self):
with io.open(check.__file__, encoding="utf-8") as handle:
src = handle.read()
for node in ast.walk(ast.parse(src)):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "t"
and node.args
and isinstance(node.args[0], ast.Constant)
):
cle = node.args[0].value
self.assertTrue(
cle in todo_i18n.TRANSLATIONS,
f"clé sans traduction : {cle!r}",
)
class TestTheExitCodes(unittest.TestCase):
def setUp(self):
from script.odoo.migration import database_cleanup
self.cleanup = database_cleanup
self.vraie = database_cleanup.require_matching_version
self.vrai_shell = database_cleanup.run_shell
database_cleanup.require_matching_version = lambda base: None
def tearDown(self):
self.cleanup.require_matching_version = self.vraie
self.cleanup.run_shell = self.vrai_shell
def lance(self, rapport):
self.cleanup.run_shell = lambda *a, **k: rapport
tampon = io.StringIO()
with redirect_stdout(tampon):
code = check.main(["-d", "db"])
return code, tampon.getvalue()
def test_nothing_hidden_exits_zero(self):
code, _ = self.lance({"models": [], "checked": 3, "users": ["a"]})
self.assertEqual(code, 0)
def test_something_hidden_exits_one(self):
code, _ = self.lance(
{
"models": [{"model": "dms.file", "rows": 69}],
"checked": 3,
"users": ["a"],
}
)
self.assertEqual(code, 1)
def test_a_shell_error_exits_two(self):
# 2 dit « l'outil a échoué », pas « rien trouvé » : les confondre
# ferait conclure qu'une migration est saine sans l'avoir vérifiée.
code, _ = self.lance({"error": "boom"})
self.assertEqual(code, 2)
def test_a_version_mismatch_stops_before_opening_the_database(self):
self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
appels = []
self.cleanup.run_shell = lambda *a, **k: appels.append(a) or {}
with redirect_stdout(io.StringIO()):
code = check.main(["-d", "db"])
self.assertEqual(code, 2)
self.assertEqual(appels, [])
class TestTheWiring(unittest.TestCase):
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def source(self):
with io.open(
os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
return handle.read()
def test_the_detector_runs_at_every_bump(self):
src = self.source()
self.assertIn("check_hidden_models.py", src)
def test_the_dms_repair_runs_at_the_13_bump_only(self):
# MuK devient OCA DMS à ce palier-là et à aucun autre. Le lancer
# partout coûterait un démarrage d'Odoo par palier pour rien.
src = self.source()
self.assertIn(
"if next_version == 13:", src, "le garde de palier a disparu"
)
debut = src.index("if next_version == 13:")
fin = src.index("dms_access_repair.py")
self.assertLess(debut, fin)
self.assertLess(fin - debut, 700, "le garde de palier s'est éloigné")
def test_the_repair_is_applied_not_only_reported(self):
# Sans --apply il n'écrit rien : câblé sans, il ne réparerait
# jamais et la migration resterait cassée en silence.
src = self.source()
debut = src.index("dms_access_repair.py")
self.assertIn("--apply", src[debut : debut + 200])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

423
test/test_check_stale_scss.py Executable file
View file

@ -0,0 +1,423 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un SCSS personnalisé est une copie figée, comme une vue COW.
Personnaliser un site écrit du SCSS dans `ir_attachment`. Cette copie est
figée le jour où elle est écrite et continue d'employer les variables de
CETTE version. Un module peut les renommer d'un palier à l'autre : mesuré,
`website/.../primary_variables.scss` déclarait `$o-theme-font-number` en 12.0
et a remplacé tout le mécanisme en 13.0. Une personnalisation de 2020
demandait encore l'ancien nom, et le bundle s'arrêtait dessus.
Ce qui rend un tel détecteur utilisable n'est pas de trouver — c'est de ne
pas crier à tort. Sur le seul fichier mesuré, la première version rapportait
dix noms dont six n'étaient pas des manques : paramètres de mixin, variables
de boucle, arguments nommés d'`@include`. Ces tests portent surtout là-dessus.
"""
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
import check_stale_scss as scss # noqa: E402
class TestWhatCountsAsAUse(unittest.TestCase):
def test_a_plain_dereference_is_a_use(self):
self.assertIn("color", scss.used_names("a { b: $color; }"))
def test_a_declaration_is_not_a_use(self):
self.assertNotIn("color", scss.used_names("$color: red;"))
def test_a_named_include_argument_is_not_a_use(self):
# LE faux positif mesuré : « @include o-position-absolute(
# $right: 50%) » se lisait comme l'usage d'un $right inexistant.
source = "@include o-position-absolute($right: 50%, $left: 50%);"
self.assertEqual(scss.used_names(source), set())
def test_a_default_value_is_not_a_use(self):
self.assertNotIn("size", scss.used_names("$size: 3 !default;"))
def test_the_name_is_not_truncated_to_dodge_the_colon(self):
# Une négation en tête de motif fait rétrograder le nom : le premier
# essai rapportait « $botto » au lieu d'écarter « $bottom ».
names = scss.used_names("a { b: $bottom-margin; }")
self.assertEqual(names, {"bottom-margin"})
def test_a_use_after_a_declaration_still_counts(self):
self.assertEqual(scss.used_names("$a: $b;"), {"b"})
class TestWhatTheFileBindsItself(unittest.TestCase):
def test_mixin_parameters_are_bound(self):
source = "@mixin thing($on, $off: 2) { a: $on; b: $off; }"
self.assertEqual(scss.bound_names(source), {"on", "off"})
def test_function_parameters_are_bound(self):
source = "@function f($value) { @return $value; }"
self.assertIn("value", scss.bound_names(source))
def test_each_loop_variables_are_bound(self):
source = "@each $key, $val in $map { a: $key; }"
bound = scss.bound_names(source)
self.assertIn("key", bound)
self.assertIn("val", bound)
def test_for_loop_variables_are_bound(self):
source = "@for $counter from 1 through 3 { a: $counter; }"
self.assertIn("counter", scss.bound_names(source))
class TestTheUrlIsSplitForTheFix(unittest.TestCase):
"""reset_asset veut le fichier ET le bundle ; l'URL porte les deux."""
def test_a_frontend_customization(self):
base, bundle = scss.split_custom_url(
"/website/static/src/scss/website.custom.web.assets_frontend.scss"
)
self.assertEqual(base, "/website/static/src/scss/website.scss")
self.assertEqual(bundle, "web.assets_frontend")
def test_a_common_customization(self):
base, bundle = scss.split_custom_url(
"/website/static/src/scss/options/colors/"
"user_theme_color_palette.custom.web.assets_common.scss"
)
self.assertTrue(base.endswith("user_theme_color_palette.scss"))
self.assertEqual(bundle, "web.assets_common")
class TestTheReport(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def finding(self, **override):
data = {
"id": 1110,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["o-theme-font-number"],
"custom": "a { b: $o-theme-font-number; }",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": "odoo13.0/a/b.scss",
"module_content": "a { b: 1; }",
"version_dir": "odoo13.0",
"database": "db",
}
data.update(override)
return data
def test_nothing_at_risk_says_so(self):
text = scss.render([], "db", "odoo13.0")
self.assertIn("✅", text)
def test_a_finding_names_the_attachment_and_the_variable(self):
text = scss.render([self.finding()], "db", "odoo13.0")
self.assertIn("1110", text)
self.assertIn("$o-theme-font-number", text)
def test_it_prints_a_command_that_can_be_pasted(self):
# Un diagnostic sans le geste qui répare oblige à rechercher les deux
# arguments de reset_asset au pire moment.
text = scss.render([self.finding()], "mydb", "odoo13.0")
self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text)
self.assertIn("-d mydb", text)
def test_it_warns_before_dropping(self):
text = scss.render([self.finding()], "db", "odoo13.0")
self.assertIn("real customization", text)
class TestTheDiff(unittest.TestCase):
"""Ce que la copie a changé : la seule chose que réinitialiser perd."""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def finding(self, **override):
data = {
"id": 1110,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["x"],
"custom": "a { b: 2; }",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": "odoo13.0/a/b.scss",
"module_content": "a { b: 1; }",
"version_dir": "odoo13.0",
"database": "db",
}
data.update(override)
return data
def test_it_counts_what_would_be_lost(self):
text = scss.render_diff(self.finding())
self.assertIn("+1/-1", text)
def test_an_identical_copy_says_it_loses_nothing(self):
text = scss.render_diff(self.finding(custom="a { b: 1; }"))
self.assertIn("identical", text)
def test_a_missing_module_file_is_said_not_guessed(self):
# Si la cible ne livre plus le fichier, il n'y a rien sur quoi
# retomber : le taire ferait accepter une réinitialisation vide.
text = scss.render_diff(self.finding(module_path=None))
self.assertIn("no longer ships", text)
class TestThePrompt(unittest.TestCase):
"""Regarder ne doit pas répondre à la question."""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def finding(self):
return {
"id": 1,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["x"],
"custom": "a { b: 2; }",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": "odoo13.0/a/b.scss",
"module_content": "a { b: 1; }",
"version_dir": "odoo13.0",
"database": "db",
}
def run_prompt(self, answers):
import contextlib
import io
seq = iter(answers)
out = io.StringIO()
with contextlib.redirect_stdout(out):
wrote = scss.prompt(
[self.finding()], "db", ask=lambda prompt: next(seq)
)
return wrote, out.getvalue()
def test_enter_writes_nothing(self):
wrote, text = self.run_prompt([""])
self.assertFalse(wrote)
self.assertEqual(text, "")
def test_v_shows_the_diff_and_asks_again(self):
# Sans le « asks again », montrer vaudrait réponse : on aurait vu le
# diff et perdu la main sur la décision.
wrote, text = self.run_prompt(["v", ""])
self.assertFalse(wrote)
self.assertIn("+1/-1", text)
def test_apply_saves_a_copy_before_writing(self):
# reset_asset SUPPRIME la pièce jointe : sans sauvegarde préalable,
# les lignes personnalisées ne seraient plus nulle part.
import os
import tempfile
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(tempfile.mkdtemp())
seen = {}
original = scss.apply_reset
scss.apply_reset = lambda lst, db, cfg="./config.conf": (
seen.update(called=True),
(0, "ok"),
)[1]
self.addCleanup(setattr, scss, "apply_reset", original)
wrote, text = self.run_prompt(["a"])
self.assertTrue(wrote)
self.assertTrue(seen.get("called"))
import glob
saved = glob.glob("private/odoo/migration/db/scss_backup/*")
self.assertEqual(len(saved), 1)
with open(saved[0]) as handle:
self.assertEqual(handle.read(), "a { b: 2; }")
def test_a_failed_reset_says_nothing_changed(self):
import os
import tempfile
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(tempfile.mkdtemp())
original = scss.apply_reset
scss.apply_reset = lambda lst, db, cfg="./config.conf": (1, "boom")
self.addCleanup(setattr, scss, "apply_reset", original)
wrote, text = self.run_prompt(["a"])
self.assertFalse(wrote)
self.assertIn("nothing was changed", text)
class TestTheMigrationRunsIt(unittest.TestCase):
def test_it_is_checked_at_the_same_place_as_the_cow_views(self):
# Les deux prédictions valent avant le palier, pas après : c'est le
# seul moment où l'on peut encore arbitrer sans avoir tout rejoué.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertIn("check_stale_scss.py", source)
self.assertLess(
source.index("check_stale_scss.py"),
source.index("4 - Upgrade version with OpenUpgrade"),
)
def test_it_gets_a_real_terminal(self):
# L'outil pose lui-même ses questions et peut ouvrir un plein écran :
# un tube les rendrait toutes injoignables, sans rien signaler.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
# On ne compare pas à une mise en forme — black la change — mais à
# l'appel le plus proche EN AMONT : c'est lui qui exécute.
avant = source[: source.index("check_stale_scss.py")]
self.assertGreater(
avant.rfind("run_on_terminal("),
avant.rfind("todo_upgrade_execute("),
"l'outil repasse par l'exécuteur à tube",
)
class TestTheFixCannotRunTooEarly(unittest.TestCase):
"""Corriger exige la version d'ARRIVÉE, pas celle de départ.
Mesuré sur une vraie migration : la question a été posée avant le palier,
alors que le checkout était encore sur odoo12.0. Répondre « a » a lancé
reset_asset dans un shell Odoo 12, qui ne connaît pas `web_editor.assets`
— KeyError, rien de modifié, et la migration a continué jusqu'à casser au
palier suivant.
Prédire tôt reste juste. C'est corriger tôt qui ne l'est pas.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_a_version_without_reset_asset_is_refused(self):
import os
import tempfile
root = tempfile.mkdtemp()
old = os.path.join(root, "odoo12.0", "addons", "web_editor", "models")
os.makedirs(old)
with open(os.path.join(old, "assets.py"), "w") as handle:
handle.write("class Assets:\n pass\n")
self.assertFalse(scss.reset_supported(os.path.join(root, "odoo12.0")))
def test_a_version_with_it_is_allowed(self):
import os
import tempfile
root = tempfile.mkdtemp()
new = os.path.join(root, "odoo13.0", "addons", "web_editor", "models")
os.makedirs(new)
with open(os.path.join(new, "assets.py"), "w") as handle:
handle.write("def reset_asset(self, url, bundle):\n pass\n")
self.assertTrue(scss.reset_supported(os.path.join(root, "odoo13.0")))
def test_an_unknown_checkout_does_not_block(self):
# Rien pour trancher : refuser sur une supposition empêcherait de
# corriger là où c'est possible.
self.assertTrue(scss.reset_supported("odoo_no_such_dir_zz"))
def test_the_prompt_hides_the_fix_when_it_cannot_work(self):
# Offrir un choix qui échouera, c'est le faire prendre.
import contextlib
import io
original = scss.reset_supported
scss.reset_supported = lambda odoo_dir=None: False
self.addCleanup(setattr, scss, "reset_supported", original)
finding = {
"id": 1,
"url": "/a/b.custom.web.assets_frontend.scss",
"missing": ["x"],
"custom": "a",
"base_url": "/a/b.scss",
"bundle": "web.assets_frontend",
"module_path": None,
"module_content": "",
"version_dir": "odoo13.0",
"database": "db",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
wrote = scss.prompt([finding], "db", ask=lambda p: "a")
self.assertFalse(wrote)
self.assertIn("KeyError", out.getvalue())
self.assertNotIn("a = reset", out.getvalue())
class TestTheMigrationAsksAtTheRightMoment(unittest.TestCase):
"""Prédire avant le palier, corriger après — jamais l'inverse."""
def source(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_the_early_call_cannot_offer_the_fix(self):
source = self.source()
premier = source.index("check_stale_scss.py")
fenetre = source[premier : premier + 400]
self.assertIn("--report-only", fenetre)
def test_a_second_call_comes_after_the_bump(self):
# Sans elle, la prédiction n'aurait jamais de suite : on saurait ce
# qui va casser sans jamais pouvoir le réparer.
source = self.source()
self.assertEqual(source.count("check_stale_scss.py"), 2)
second = source.rindex("check_stale_scss.py")
self.assertGreater(second, source.index("state_4_upgrade_odoo_lst"))
def test_the_second_call_targets_the_upgraded_database(self):
source = self.source()
second = source.rindex("check_stale_scss.py")
fenetre = source[second : second + 300]
self.assertIn("database_name_upgrade", fenetre)
self.assertNotIn("--report-only", fenetre)
class TestThePromptStaysOutOfAPipe(unittest.TestCase):
def test_it_only_asks_in_front_of_a_terminal(self):
# Une invite dans un tube bloquerait l'appelant sur une question que
# personne ne voit.
with open(scss.__file__) as handle:
source = handle.read()
self.assertIn("sys.stdin.isatty()", source)
if __name__ == "__main__":
unittest.main()

972
test/test_database_cleanup.py Executable file
View file

@ -0,0 +1,972 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le nettoyage OCA, en ordre, jusqu'à ce que plus rien ne bouge.
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui la
visaient. Une seule passe ne suffit jamais, et l'ordre compte.
Les erreurs sont ATTENDUES — une clé étrangère tient encore, un module
refuse. Purger la liste d'un bloc perdrait tout au premier refus : chaque
entrée passe donc dans son propre point de reprise. Ce que la passe n'a pas
réparé, la suivante le peut, une fois les voisines parties.
Ces tests exécutent le script réellement poussé dans le shell, sur un `env`
simulé. C'est la seule façon de vérifier l'ordre, l'isolement des refus et
l'arrêt de la boucle sans lancer Odoo sur une vraie base.
"""
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
import database_cleanup as cleanup # noqa: E402
class FakeLine:
def __init__(self, name, fails=0, journal=None):
self.name = name
self.id = abs(hash(name)) % 10000
self.fails = fails # nombre de refus avant de céder
self.journal = journal if journal is not None else []
self.purged = False
def purge(self):
# Comme le vrai : une ligne déjà purgée ne l'est pas deux fois.
# `purge()` d'OCA filtre sur `not x.purged`, et sans cela le repli
# ligne à ligne recompterait ce que le lot avait déjà fait.
if self.purged:
return True
self.journal.append(("purge", self.name))
if self.fails > 0:
self.fails -= 1
raise RuntimeError(f"refus sur {self.name}")
self.purged = True
return True
class FakeRecordset(list):
"""Un `purge_line_ids` qui se purge EN LOT, comme le vrai.
C'est TOUT l'enjeu du correctif : `purge()` d'un module appelle
button_immediate_uninstall(), qui recharge le registre entier. Un appel
par ligne en faisait un rechargement par module — mesuré, dix secondes
chacun. Un seul appel sur le lot, c'est un seul rechargement.
"""
def __init__(self, lines, journal=None):
super().__init__(lines)
self.journal = journal if journal is not None else []
def purge(self):
self.journal.append(("purge_batch", len(self)))
for line in self:
line.purge()
return True
class FakeWizard:
def __init__(self, lines, journal=None):
self.purge_line_ids = FakeRecordset(lines, journal)
class FakeModel:
def __init__(self, lines, raise_on_create=None):
self._lines = lines
self._raise = raise_on_create
def create(self, values):
if self._raise:
raise RuntimeError(self._raise)
# Les lignes déjà purgées ne reviennent pas : find() les recalcule.
reste = [ln for ln in self._lines if ln.fails >= 0 and not ln.purged]
journal = reste[0].journal if reste else None
return FakeWizard(reste, journal)
class FakeCursor:
"""Volontairement SANS `savepoint` : c'est l'invariant du correctif.
Le module OCA valide de lui-même — `purge_modules.find()` purge (ligne
91) et `purge_columns.purge()` appelle `cr.commit()` (ligne 57). Un
COMMIT détruit tous les points de reprise. En reprendre un ici ferait
revivre le défaut sans que rien ne le dise ; l'absence de la méthode
le fait échouer tout de suite.
"""
def __init__(self, journal):
self.journal = journal
def commit(self):
self.journal.append(("commit", None))
def rollback(self):
self.journal.append(("rollback", None))
class AbortingCursor(FakeCursor):
"""Ce que PostgreSQL fait VRAIMENT après une erreur.
La transaction reste avortée et refuse tout ordre jusqu'au rollback.
C'est ce qui changeait une panne sur « modules » en sept catégories
mortes, toutes sur « current transaction is aborted ».
"""
def __init__(self, journal):
super().__init__(journal)
self.aborted = False
def commit(self):
if self.aborted:
raise RuntimeError(
"current transaction is aborted, commands ignored"
" until end of transaction block"
)
super().commit()
def rollback(self):
super().rollback()
self.aborted = False
class CommittingLine(FakeLine):
"""Une purge qui valide toute seule, comme `purge_columns` le fait.
Le COMMIT emporte le point de reprise ; l'ordre suivant meurt sur
« savepoint ... does not exist » et laisse la transaction avortée.
"""
def __init__(self, name, cursor, journal=None):
super().__init__(name, journal=journal)
self.cursor = cursor
def purge(self):
self.journal.append(("purge", self.name))
self.cursor.commit()
self.cursor.aborted = True
raise RuntimeError('savepoint "10eb69719a9211f1" does not exist')
class FakeEnv(dict):
def __init__(self, mapping, journal, cursor=None):
super().__init__(mapping)
self.cr = cursor if cursor is not None else FakeCursor(journal)
class UserError(Exception):
"""Celle que le script poussé importera : on fournit odoo.exceptions.
Refaire une classe de son côté ne servirait à rien — « except » compare
des identités, pas des noms, et le test passerait à côté.
"""
def install_fake_odoo_exceptions(case):
"""Rendre `from odoo.exceptions import UserError` possible ici."""
import types
odoo = sys.modules.get("odoo") or types.ModuleType("odoo")
exceptions = types.ModuleType("odoo.exceptions")
exceptions.UserError = UserError
avant_odoo = sys.modules.get("odoo")
avant_exc = sys.modules.get("odoo.exceptions")
sys.modules["odoo"] = odoo
sys.modules["odoo.exceptions"] = exceptions
def remettre():
for nom, valeur in (
("odoo", avant_odoo),
("odoo.exceptions", avant_exc),
):
if valeur is None:
sys.modules.pop(nom, None)
else:
sys.modules[nom] = valeur
case.addCleanup(remettre)
def run_script(models, max_round=10, dry_run=False, cursor=None):
"""Exécuter le script réellement poussé, et rendre (rapport, journal)."""
import json
journal = cursor.journal if cursor is not None else []
env = FakeEnv(models, journal, cursor=cursor)
namespace = {"env": env}
exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102
# Le script imprime le rapport entre deux sentinelles ; ici on le relit
# dans son espace de noms, ce qui teste la MÊME structure.
return json.loads(json.dumps(namespace["report"])), journal
class TestTheOrder(unittest.TestCase):
def test_the_requested_order_is_kept(self):
# Purger un modèle libère des colonnes : l'inverse ne marcherait pas.
self.assertEqual(
[kind for kind, _model in cleanup.ORDER],
[
"models",
"modules",
"columns",
"tables",
"data",
"menus",
"indexes",
"properties",
],
)
def test_indexes_come_after_the_purges(self):
kinds = [kind for kind, _ in cleanup.ORDER]
self.assertGreater(kinds.index("indexes"), kinds.index("tables"))
def test_a_pass_visits_the_kinds_in_that_order(self):
models = {
model: FakeModel([FakeLine(f"{kind}-1")])
for kind, model in cleanup.ORDER
}
report, _journal = run_script(models, max_round=1)
self.assertEqual(
[entry["kind"] for entry in report["rounds"][0]],
[kind for kind, _ in cleanup.ORDER],
)
class TestOneEntryCannotSinkThePass(unittest.TestCase):
def test_a_healthy_category_is_purged_in_ONE_call(self):
# LE point du correctif. `purge()` d'un module appelle
# button_immediate_uninstall(), qui recharge le registre ENTIER —
# 5984 modules à relire. En purgeant ligne par ligne j'en faisais
# un rechargement PAR MODULE : mesuré, dix secondes chacun,
# dix-sept minutes pour neuf modules, sans rien afficher.
journal = []
lines = [
FakeLine("a", journal=journal),
FakeLine("b", journal=journal),
FakeLine("c", journal=journal),
]
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
report, _got = run_script(models, max_round=1)
# `journal` est celui des LIGNES : c'est là qu'atterrit la purge.
self.assertEqual(journal.count(("purge_batch", 3)), 1)
self.assertEqual(report["rounds"][0][0]["purged"], 3)
def test_the_healthy_case_commits_once_for_the_batch(self):
journal = []
lines = [
FakeLine("a", journal=journal),
FakeLine("b", journal=journal),
]
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
_report, got = run_script(models, max_round=1)
# Une validation après la création — `find()` peut avoir purgé de
# lui-même — et une pour le lot. Pas une par ligne.
self.assertEqual(got.count(("commit", None)), 2)
def test_a_refusal_gives_up_only_its_own(self):
journal = []
lines = [
FakeLine("ok1", journal=journal),
FakeLine("bad", fails=99, journal=journal),
FakeLine("ok2", journal=journal),
]
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
report, got = run_script(models, max_round=1)
entry = report["rounds"][0][0]
self.assertEqual(entry["purged"], 2)
self.assertEqual([name for name, _msg in entry["errors"]], ["bad"])
# Le lot a été tenté, a échoué, et SEULEMENT alors on isole. Le
# coût du ligne-à-ligne n'est payé que là où il sert.
self.assertEqual(journal.count(("purge_batch", 3)), 1)
self.assertGreaterEqual(got.count(("rollback", None)), 1)
def test_the_isolation_only_happens_after_a_refusal(self):
# Une catégorie saine ne doit JAMAIS passer par le repli : c'est
# lui qui coûtait dix secondes par module.
journal = []
models = {
cleanup.ORDER[0][1]: FakeModel(
[
FakeLine("a", journal=journal),
FakeLine("b", journal=journal),
]
)
}
_report, got = run_script(models, max_round=1)
self.assertEqual(got.count(("rollback", None)), 0)
def test_the_script_never_takes_a_savepoint(self):
# L'invariant du correctif, dit une fois pour toutes : le module OCA
# valide de lui-même, et un COMMIT détruit le point de reprise
# qu'on aurait pris. Le reprendre serait revenir au défaut.
self.assertNotIn("env.cr.savepoint", cleanup.build_script(1, False))
def test_a_wizard_that_cannot_even_be_created_is_recorded(self):
models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")}
report, _got = run_script(models, max_round=1)
self.assertEqual(report["failed"][0][0], "models")
self.assertIn("boom", report["failed"][0][2])
class TestTheReportSurvivesAnything(unittest.TestCase):
"""Sans rapport, on ne sait même pas si la base a été touchée.
Vécu : `create({})` échouait, l'erreur était notée mais la transaction
restait AVORTÉE. La lecture de nom suivante mourait dessus, hors de tout
garde, et le script entier s'arrêtait — aucun rapport, juste une trace.
"""
def test_reading_the_names_is_inside_the_guard(self):
# C'est la lecture des noms qui déclenche la requête, pas la
# création : la laisser hors du `try` était le défaut — elle mourait
# sur une transaction déjà avortée, sans rien pour la rattraper.
source = cleanup.build_script(1, False)
creation = source.index("wizard = env[model].create({})")
garde = source.rindex("try:", 0, creation)
noms = source.index("line.name or str(line.id)")
rattrapage = source.index("except UserError:", noms)
self.assertLess(garde, creation)
self.assertLess(creation, noms)
self.assertLess(noms, rattrapage)
def test_a_failure_on_create_does_not_kill_the_run(self):
models = {
cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom"),
cleanup.ORDER[2][1]: FakeModel([FakeLine("colonne")]),
}
report, _got = run_script(models, max_round=1)
# La catégorie suivante a bien travaillé malgré l'échec de la
# première.
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
self.assertEqual(purged.get("columns"), 1)
self.assertEqual(report["failed"][0][0], "models")
def test_an_unexpected_failure_still_yields_a_report(self):
class Explosive(dict):
def __init__(self, journal):
super().__init__()
self.cr = FakeCursor(journal)
def __contains__(self, key):
raise RuntimeError("registre en miettes")
import json as _json
journal = []
namespace = {"env": Explosive(journal)}
exec(cleanup.build_script(1, False), namespace) # noqa: S102
report = _json.loads(_json.dumps(namespace["report"]))
self.assertEqual(report["failed"][0][:2], ["*", "fatal"])
self.assertIn("miettes", report["failed"][0][2])
class TestTheSilenceThatLookedLikeAHang(unittest.TestCase):
"""Dix-sept minutes sans une ligne, et l'on croit à une boucle infinie.
Vécu, sur test_neutralize_upgrade_16 : l'outil affichait « ⧖ Nettoyage
de … » puis PLUS RIEN. Le processus travaillait — zéro verrou en
attente, des requêtes qui changeaient à chaque instantané — mais un
travail qui avance et un blocage se ressemblent trait pour trait quand
aucun des deux ne parle. On interrompt alors une réparation à moitié
faite, ce qui est le pire des deux mondes.
"""
def test_the_pushed_script_announces_what_it_does(self):
source = cleanup.build_script(1, False)
self.assertIn(cleanup.STEP, source)
self.assertIn("flush=True", source)
def test_it_announces_each_pass(self):
source = cleanup.build_script(1, False)
self.assertIn('step("pass"', source)
def test_the_parent_relays_each_line_AS_IT_ARRIVES(self):
"""Le test comportemental, et non plus un mot cherché dans le code.
`capture_output=True` ne rend la main qu'à la fin : c'était la cause
du silence, pas la lenteur elle-même. On vérifie donc qu'une ligne
de progression ressort AVANT que le processus n'ait fini de parler.
"""
import io
lignes = [
f"{cleanup.STEP} modules 1/3 vieux_module\n",
"un journal Odoo sans rapport\n",
f"{cleanup.STEP} columns 2/3 res_partner.x\n",
f"{cleanup.START}\n",
'{"rounds": [], "missing": [], "failed": []}\n',
f"{cleanup.END}\n",
]
vu = []
class FauxProcessus:
def __init__(self, lst):
self.stdin = io.StringIO()
self.pid = -1
self._lst = lst
@property
def stdout(self):
# Un générateur : chaque ligne n'existe qu'au moment où on
# la lit, comme un vrai tube. Rendre la liste entière
# laisserait passer une lecture en bloc.
for rang, ligne in enumerate(self._lst):
vu.append(("lu", rang))
yield ligne
def wait(self, timeout=None):
return 0
def poll(self):
return 0
original = cleanup.subprocess.Popen
cleanup.subprocess.Popen = lambda *a, **kw: FauxProcessus(lignes)
self.addCleanup(setattr, cleanup.subprocess, "Popen", original)
report = cleanup.run_shell(
"db",
"./config.conf",
"script",
echo=lambda ligne: vu.append(("relayé", ligne)),
)
self.assertEqual(report["rounds"], [])
relayes = [x for x in vu if x[0] == "relayé"]
self.assertEqual(
[x[1] for x in relayes],
["modules 1/3 vieux_module", "columns 2/3 res_partner.x"],
)
# ET au fil de l'eau : le relais suit IMMÉDIATEMENT la lecture de
# sa ligne. « quelque part avant la fin » ne suffirait pas — une
# lecture en bloc suivie d'une boucle de relais passerait aussi.
self.assertEqual(vu[vu.index(("lu", 0)) + 1], relayes[0])
self.assertEqual(vu[vu.index(("lu", 2)) + 1], relayes[1])
def test_the_relay_shows_the_elapsed_time(self):
# Ce qui distingue « ça avance lentement » de « ça ne bouge plus ».
import io
from contextlib import redirect_stdout
import time as _time
echo = cleanup.make_echo(_time.monotonic() - 42)
out = io.StringIO()
with redirect_stdout(out):
echo("modules 2/9 stock_deposit")
self.assertIn("42", out.getvalue())
self.assertIn("stock_deposit", out.getvalue())
def test_the_slowness_is_announced_up_front(self):
import inspect
source = inspect.getsource(cleanup.main)
self.assertIn("reloads the whole registry", source)
def test_the_timeout_cannot_be_starved_by_silence(self):
# Un délai vérifié DANS la boucle de lecture ne se déclencherait
# jamais : cette boucle bloque tant qu'aucune ligne n'arrive, et
# c'est exactement le cas qu'il faut couvrir.
import inspect
source = inspect.getsource(cleanup.run_shell)
self.assertIn("threading.Timer", source)
def test_stopping_kills_the_whole_group(self):
# « ./odoo_bin.sh » est un script bash : un terminate() sur lui tue
# le script et laisse odoo-bin vivant, sur la base, avec ses
# verrous. La leçon a déjà été payée en serveurs orphelins.
import inspect
source = inspect.getsource(cleanup.kill_group)
self.assertIn("killpg", source)
self.assertIn(
"start_new_session", inspect.getsource(cleanup.run_shell)
)
def test_a_timeout_is_reported_as_such(self):
# Sans cela, un arrêt à l'expiration se lisait « le nettoyage n'a
# produit aucun rapport » — un diagnostic faux.
import inspect
source = inspect.getsource(cleanup.run_shell)
self.assertIn("was still running after", source)
self.assertLess(
source.index("was still running after"),
source.index("produced no report"),
)
class TestTheCascadeThatKilledEverything(unittest.TestCase):
"""Vécu, sur test_neutralize_upgrade_13 : sept catégories mortes d'une.
passe 1 : 0 purgés
⚠️ 0 purgés ; 7 n'ont pas pu l'être :
- [modules] - : savepoint "10eb6971..." does not exist
- [columns] - : current transaction is aborted, commands ignored
... et ainsi de suite jusqu'à la dernière.
Une seule panne, six victimes. La cause n'était pas dans OCA mais chez
nous : on n'a jamais remis la transaction d'aplomb après l'échec.
"""
def build(self):
journal = []
cursor = AbortingCursor(journal)
coupable = CommittingLine("colonne_morte", cursor, journal=journal)
models = {
cleanup.ORDER[1][1]: FakeModel([coupable]),
cleanup.ORDER[2][1]: FakeModel(
[FakeLine("suivante", journal=journal)]
),
cleanup.ORDER[3][1]: FakeModel(
[FakeLine("encore", journal=journal)]
),
}
return run_script(models, max_round=1, cursor=cursor)
def test_the_categories_after_it_still_work(self):
report, _got = self.build()
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
self.assertEqual(purged.get("columns"), 1)
self.assertEqual(purged.get("tables"), 1)
def test_nobody_else_reports_an_aborted_transaction(self):
# C'est la SIGNATURE de la cascade : six lignes qui ne disent rien
# de leur propre catégorie, seulement qu'une autre a échoué avant.
report, _got = self.build()
contamines = [
entry
for round_ in report["rounds"]
for entry in round_
for _name, message in entry["errors"]
if "transaction is aborted" in message
]
self.assertEqual(contamines, [])
self.assertEqual(report["failed"], [])
def test_the_real_failure_is_still_reported(self):
# Rattraper ne veut pas dire taire : la colonne n'a PAS été purgée.
report, _got = self.build()
entry = [e for e in report["rounds"][0] if e["kind"] == "modules"][0]
self.assertEqual(entry["purged"], 0)
self.assertIn("savepoint", entry["errors"][0][1])
def test_the_transaction_is_put_back_on_its_feet(self):
_report, got = self.build()
self.assertIn(("rollback", None), got)
class TestADeadCreateDoesNotContaminate(unittest.TestCase):
def test_the_next_category_is_not_dragged_down(self):
# `create({})` qui échoue laisse la transaction avortée : sans
# rollback, la catégorie suivante mourait sur l'erreur d'une autre.
journal = []
cursor = AbortingCursor(journal)
class MortAuDepart(FakeModel):
def create(self, values):
cursor.aborted = True
raise RuntimeError("registre indisponible")
models = {
cleanup.ORDER[0][1]: MortAuDepart([]),
cleanup.ORDER[2][1]: FakeModel(
[FakeLine("colonne", journal=journal)]
),
}
report, _got = run_script(models, max_round=1, cursor=cursor)
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
self.assertEqual(purged.get("columns"), 1)
self.assertEqual(report["failed"][0][0], "models")
self.assertIn("registre", report["failed"][0][2])
class TestGoingRoundAgain(unittest.TestCase):
def test_what_one_pass_refused_the_next_may_take(self):
# LE point, et la raison même de boucler : une entrée refuse TANT QUE
# sa voisine est là. Une ligne qui guérirait toute seule n'existe pas
# — ce qui existe, c'est une dépendance qui tombe.
etat = {"table_partie": False}
class Dependante(FakeLine):
def purge(self):
if not etat["table_partie"]:
raise RuntimeError("la table la retient encore")
self.fails = -1
class Liberatrice(FakeLine):
def purge(self):
etat["table_partie"] = True
self.fails = -1
modele_col, modele_tab = cleanup.ORDER[2][1], cleanup.ORDER[3][1]
models = {
modele_col: FakeModel([Dependante("colonne_liee")]),
modele_tab: FakeModel([Liberatrice("vieille_table")]),
}
report, _got = run_script(models, max_round=5)
# Passe 1 : la colonne refuse, la table part → du progrès, on continue.
self.assertEqual(report["rounds"][0][0]["purged"], 0)
self.assertEqual(report["rounds"][0][1]["purged"], 1)
# Passe 2 : la colonne cède, sa dépendance étant partie.
self.assertEqual(report["rounds"][1][0]["purged"], 1)
def test_a_pass_that_repairs_nothing_ends_it(self):
# Rien n'a changé dans la base : une passe de plus rendrait le même
# refus. Boucler serait des minutes brûlées pour rien.
models = {
cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
}
report, _got = run_script(models, max_round=8)
self.assertEqual(len(report["rounds"]), 1)
self.assertEqual(len(report["rounds"][0][0]["errors"]), 1)
def test_the_loop_stops_when_a_pass_repairs_nothing(self):
# Ce qui résistait au tour d'avant résistera encore : boucler
# jusqu'à max_round brûlerait des minutes pour rien.
models = {
cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
}
report, _got = run_script(models, max_round=8)
self.assertEqual(len(report["rounds"]), 1)
def test_nothing_to_do_is_one_pass(self):
models = {cleanup.ORDER[0][1]: FakeModel([])}
report, _got = run_script(models, max_round=8)
self.assertEqual(len(report["rounds"]), 1)
class TestWhatIsAbsentIsSkipped(unittest.TestCase):
def test_a_missing_wizard_is_noted_not_fatal(self):
# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property
# par une colonne jsonb. Échouer dessus arrêterait le nettoyage sur
# une version où il n'a plus lieu d'être.
models = {cleanup.ORDER[0][1]: FakeModel([])}
report, _got = run_script(models, max_round=1)
self.assertIn("properties", report["missing"])
self.assertIn("indexes", report["missing"])
def test_a_kind_is_noted_once(self):
models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("x", fails=1)])}
report, _got = run_script(models, max_round=5)
self.assertEqual(report["missing"].count("properties"), 1)
class TestTheDryRun(unittest.TestCase):
def test_it_purges_nothing(self):
journal = []
lines = [FakeLine("a", journal=journal)]
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
report, got = run_script(models, max_round=5, dry_run=True)
self.assertEqual(journal, [])
self.assertNotIn(("commit", None), got)
self.assertEqual(report["rounds"][0][0]["would"], ["a"])
def test_it_undoes_what_merely_looking_caused(self):
# `purge_modules.find()` PURGE de lui-même : une simulation écrivait
# donc pour de bon, ce qui lui retire tout son sens. On défait ce
# que la lecture a provoqué — possible ici, justement parce qu'on
# n'a validé aucune entrée.
journal = []
models = {
cleanup.ORDER[0][1]: FakeModel([FakeLine("a", journal=journal)])
}
_report, got = run_script(models, max_round=5, dry_run=True)
self.assertIn(("rollback", None), got)
self.assertNotIn(("commit", None), got)
def test_it_does_a_single_pass(self):
# Rien ne change, donc rien ne se libère : boucler serait du vent.
models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])}
report, _got = run_script(models, max_round=9, dry_run=True)
self.assertEqual(len(report["rounds"]), 1)
class TestTheReport(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_leftovers_are_a_warning_not_a_failure(self):
report = {
"rounds": [
[
{
"kind": "models",
"purged": 3,
"errors": [["x", "held"]],
"would": [],
}
]
],
"missing": [],
"failed": [],
}
text = cleanup.render(report, "db")
self.assertIn("⚠️", text)
self.assertIn("not a failure", text)
def test_all_clean_says_so(self):
report = {
"rounds": [
[{"kind": "models", "purged": 3, "errors": [], "would": []}]
],
"missing": [],
"failed": [],
}
self.assertIn("✅", cleanup.render(report, "db"))
def test_a_dry_run_is_not_read_as_refusals(self):
# 586 candidats ne sont pas 586 refus : la première version les
# affichait comme des échecs.
report = {
"rounds": [
[
{
"kind": "columns",
"purged": 0,
"errors": [],
"would": ["a", "b"],
}
]
],
"missing": [],
"failed": [],
}
text = cleanup.render(report, "db")
self.assertIn("would be purged", text)
self.assertNotIn("⚠️", text)
class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
"""Interroger les pages sur une base encombrée fait chercher à côté."""
def source(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_it_runs_before_both_smoke_tests(self):
source = self.source()
self.assertEqual(source.count("prompt_database_cleanup"), 2)
for _ in range(2):
nettoyage = source.index("prompt_database_cleanup")
mesure = source.index("prompt_smoke_public_url")
self.assertLess(nettoyage, mesure)
source = source[mesure + 1 :]
def test_it_gets_a_real_terminal(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_database_cleanup)
# `run_tool` retient le verdict PUIS délègue à `run_on_terminal` :
# les deux propriétés comptent, et vérifier la seconde à la source
# évite qu'un raccourci futur reprenne l'exécuteur qui capture.
self.assertIn("run_tool(", source)
self.assertNotIn("todo_upgrade_execute", source)
self.assertIn(
"self.run_on_terminal(",
inspect.getsource(TodoUpgrade.run_tool),
)
def test_saying_no_cleans_nothing(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
import contextlib
import io
from script.todo import todo_i18n
from script.todo.todo_upgrade import TodoUpgrade
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt, default="": "n" or default
with contextlib.redirect_stdout(io.StringIO()):
upgrade.prompt_database_cleanup("db")
self.assertEqual(lst_cmd, [])
def test_the_default_cleans(self):
# Entrée nettoie : le nettoyage précède les tests de fumée, et une
# base encombrée de tables mortes fait échouer des pages pour une
# raison qui n'a rien à voir avec la migration.
import contextlib
import io
from script.todo.todo_upgrade import TodoUpgrade
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt, default="": "" or default
with contextlib.redirect_stdout(io.StringIO()):
upgrade.prompt_database_cleanup("db")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("database_cleanup.py", lst_cmd[0])
def test_yes_runs_it_on_that_database(self):
from script.todo.todo_upgrade import TodoUpgrade
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt, default="": "y" or default
upgrade.prompt_database_cleanup("db_upgrade_18")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("database_cleanup.py", lst_cmd[0])
self.assertIn("-d db_upgrade_18", lst_cmd[0])
class TestNothingToPurgeIsNotAFailure(unittest.TestCase):
"""Le module signale le VIDE par une exception.
`raise UserError("No orphaned models found")` : le compter comme un
échec faisait passer une base saine pour une base cassée, avec quatre
avertissements sur cinq catégories.
"""
def test_the_pushed_script_separates_it(self):
source = cleanup.build_script(1, False)
self.assertIn("except UserError:", source)
vide = source.index("except UserError:")
echec = source.index('note(label, "-", exc)')
self.assertLess(vide, echec, "l'ordre des except décide")
def test_it_is_reported_as_zero_not_as_an_error(self):
install_fake_odoo_exceptions(self)
class Empty(FakeModel):
def create(self, values):
raise UserError("No orphaned models found")
models = {cleanup.ORDER[0][1]: Empty([])}
report, _got = run_script(models, max_round=1)
entry = report["rounds"][0][0]
self.assertEqual(entry["purged"], 0)
self.assertEqual(entry["errors"], [])
self.assertEqual(report["failed"], [])
class TestTheModuleIsInstalledFirst(unittest.TestCase):
"""Sans le module, aucun assistant n'existe et l'outil dit « rien ».
Ce silence se lit comme un succès. La migration l'installait à l'étape
3, donc APRÈS le nettoyage de l'étape 2 : l'ordre rendait l'outil
inutile au premier passage.
"""
def test_it_checks_the_state_before_cleaning(self):
import inspect
source = inspect.getsource(cleanup.main)
self.assertIn("module_state(", source)
self.assertLess(
source.index("module_state("), source.index("run_shell(")
)
def test_it_installs_when_absent(self):
import inspect
source = inspect.getsource(cleanup.main)
self.assertIn('state != "installed"', source)
self.assertIn("install_module(", source)
def test_a_failed_install_stops_there(self):
# Nettoyer sans le module rendrait « rien à faire » sur une base qui
# en avait besoin.
import inspect
source = inspect.getsource(cleanup.main)
install = source.index("install_module(")
self.assertIn("return 2", source[install : install + 400])
class TestTheMigrationNoLongerAsksTwice(unittest.TestCase):
def test_the_manual_cleanup_prompt_is_gone(self):
# Le faire à la main après l'avoir fait automatiquement.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertNotIn("Did you finish to clean database", source)
self.assertNotIn("Go to Settings / Technical / Cleanup", source)
def test_the_late_install_is_gone_too(self):
# Elle arrivait à l'étape 3, après l'usage de l'étape 2 : l'outil
# pose désormais le module lui-même, au bon moment.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertNotIn(
"install_addons.sh {database_name} database_cleanup", source
)
class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
"""Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer."""
def test_a_mismatch_is_refused(self):
original_checkout = cleanup.checkout_version
original_db = cleanup.database_version
cleanup.checkout_version = lambda: "14.0"
cleanup.database_version = lambda database: "17.0"
self.addCleanup(
setattr, cleanup, "checkout_version", original_checkout
)
self.addCleanup(setattr, cleanup, "database_version", original_db)
message = cleanup.require_matching_version("db")
self.assertIsNotNone(message)
self.assertIn("14.0", message)
self.assertIn("17.0", message)
def test_a_match_passes(self):
original_checkout = cleanup.checkout_version
original_db = cleanup.database_version
cleanup.checkout_version = lambda: "17.0"
cleanup.database_version = lambda database: "17.0"
self.addCleanup(
setattr, cleanup, "checkout_version", original_checkout
)
self.addCleanup(setattr, cleanup, "database_version", original_db)
self.assertIsNone(cleanup.require_matching_version("db"))
def test_not_knowing_does_not_block(self):
# Refuser sur une supposition empêcherait de nettoyer là où c'est
# possible.
original_checkout = cleanup.checkout_version
cleanup.checkout_version = lambda: None
self.addCleanup(
setattr, cleanup, "checkout_version", original_checkout
)
self.assertIsNone(cleanup.require_matching_version("db"))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,182 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qui doit tenir avant de toucher à une base de production.
Deux propriétés portent tout le reste. La première : sans `--apply`,
RIEN ne s'écrit — un outil de diagnostic qui répare tout seul est un
piège. La seconde : le compte de visibilité doit être fait avec un vrai
utilisateur, jamais en sudo, car la règle globale d'OCA DMS ne s'applique
pas au super-utilisateur et un compte en sudo dirait « tout va bien »
alors que personne ne voit rien.
"""
import ast
import io
import os
import sys
import unittest
from contextlib import redirect_stdout
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import dms_access_repair as repair # noqa: E402
from script.todo import todo_i18n # noqa: E402
class TestTheGeneratedScript(unittest.TestCase):
def test_the_dry_run_never_creates_anything(self):
# Le garde vit dans le script POUSSÉ : c'est lui qui décide, pas
# l'appelant. Un `DRY = True` qui n'entoure pas le `create` ne
# protège de rien.
code = repair.build_script(dry_run=True)
self.assertIn("DRY = True", code)
arbre = ast.parse(code)
creations = [
n
for n in ast.walk(arbre)
if isinstance(n, ast.Call)
and isinstance(n.func, ast.Attribute)
and n.func.attr == "create"
]
self.assertTrue(creations, "aucune création dans le script ?")
for noeud in creations:
self.assertTrue(
self._under_dry_guard(arbre, noeud),
"une création hors du garde `not DRY`",
)
@staticmethod
def _under_dry_guard(arbre, cible):
"""La création est-elle sous un `if not DRY ...` ?"""
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.If):
continue
texte = ast.dump(noeud.test)
if "DRY" not in texte:
continue
for enfant in ast.walk(noeud):
if enfant is cible:
return True
return False
def test_apply_lifts_the_guard(self):
self.assertIn("DRY = False", repair.build_script(dry_run=False))
def test_the_script_is_valid_python(self):
for dry in (True, False):
ast.parse(repair.build_script(dry_run=dry))
def test_visibility_is_measured_with_a_real_user(self):
# `with_user` est le point tout entier : en sudo, la règle globale
# ne s'applique pas et le rapport serait rassurant à tort.
code = repair.build_script(dry_run=True)
self.assertIn("with_user(temoin)", code)
self.assertIn("u.id != 1", code)
def test_it_reuses_the_shared_sentinels(self):
from script.odoo.migration import database_cleanup
self.assertEqual(repair.DEBUT, database_cleanup.START)
self.assertEqual(repair.FIN, database_cleanup.END)
class TestTheReport(unittest.TestCase):
PLEIN = {
"files": 69,
"directories": 16,
"access_groups_before": 0,
"witness": "marie@example.org",
"before": {"directories": 0, "files": 0},
"roots": ["CNESST", "Contrat"],
"already_repaired": False,
}
def test_it_shows_what_exists_and_what_is_seen(self):
texte = "\n".join(repair.render(self.PLEIN, dry_run=True))
self.assertIn("69", texte)
self.assertIn("marie@example.org", texte)
def test_a_dry_run_says_nothing_was_written(self):
texte = "\n".join(repair.render(self.PLEIN, dry_run=True))
self.assertIn(
todo_i18n.t("Nothing written. Re-run with --apply."), texte
)
def test_an_applied_run_reports_the_new_visibility(self):
rapport = dict(self.PLEIN, after={"directories": 16, "files": 69})
texte = "\n".join(repair.render(rapport, dry_run=False))
self.assertIn(todo_i18n.t("Now visible to"), texte)
def test_an_already_repaired_database_says_so_and_stops(self):
rapport = dict(self.PLEIN, already_repaired=True)
texte = "\n".join(repair.render(rapport, dry_run=True))
self.assertIn(
todo_i18n.t("Already repaired: the group exists."), texte
)
self.assertNotIn(
todo_i18n.t("Would create one access group over"), texte
)
def test_no_witness_is_flagged_not_silently_fine(self):
# Sans utilisateur témoin on ne SAIT pas : le taire ferait passer
# une base muette pour une base saine.
rapport = dict(self.PLEIN, before=None, witness=None)
texte = "\n".join(repair.render(rapport, dry_run=True))
self.assertIn(
todo_i18n.t("No DMS user to test visibility with."), texte
)
def test_every_translation_key_exists(self):
with io.open(
repair.__file__.replace(".pyc", ".py"), encoding="utf-8"
) as handle:
src = handle.read()
for node in ast.walk(ast.parse(src)):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "t"
and node.args
and isinstance(node.args[0], ast.Constant)
):
cle = node.args[0].value
self.assertTrue(
cle in todo_i18n.TRANSLATIONS,
f"clé sans traduction : {cle!r}",
)
class TestTheVersionGuard(unittest.TestCase):
def setUp(self):
from script.odoo.migration import database_cleanup
self.cleanup = database_cleanup
self.vraie = database_cleanup.require_matching_version
self.vrai_shell = database_cleanup.run_shell
self.appels = []
database_cleanup.run_shell = (
lambda *a, **k: self.appels.append(a) or {}
)
def tearDown(self):
self.cleanup.require_matching_version = self.vraie
self.cleanup.run_shell = self.vrai_shell
def test_a_mismatch_stops_before_opening_the_database(self):
# Un Odoo d'une autre version ÉCRIT avant d'échouer : le refus
# doit précéder toute ouverture, pas la suivre.
self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
tampon = io.StringIO()
with redirect_stdout(tampon):
code = repair.main(["-d", "vieille_base"])
self.assertEqual(code, 2)
self.assertEqual(self.appels, [])
self.assertIn("18.0 vs 12.0", tampon.getvalue())
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Réparer puis rejouer — et savoir s'arrêter.
Le motif d'échec le plus fréquent d'une migration est une copie COW en
retard sur sa vue module : « Element <xpath …> cannot be located in parent
view ». La réparation est connue, et c'est presque toujours ce qu'on allait
faire. D'où « 3 » par défaut, et le rejeu automatique derrière.
Mais un défaut qui agit doit savoir s'arrêter, et à deux titres :
- réinitialiser quand il n'y a RIEN à réinitialiser puis reproposer la même
chose est une boucle sans fin. Vécu : « Aucune copie COW n'a dérivé »,
encore et encore ;
- une réparation qui ne suffit pas relancerait la commande indéfiniment.
Trois tentatives, puis on rend la main : une migration lancée en
auto-exécution tournerait sinon toute la nuit sur le même échec.
Ces tests comptent les tours. C'est la seule façon de prouver qu'une
boucle se termine.
"""
import io
import os
import unittest
from contextlib import redirect_stdout
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from script.todo import todo_i18n # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class Harness(unittest.TestCase):
"""Un pilote dont la commande échoue toujours, et qu'on observe."""
def setUp(self):
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def upgrade(self, lst_answer=None, resets=None, echec=True):
"""`resets` : ce que la réinitialisation rend, tour après tour."""
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.lst_command_executed = []
obj.write_config = lambda: None
obj.database_from_command = lambda cmd: "db"
self.lst_run = []
self.lst_reset = []
class FauxExecute:
def exec_command_live(_self, cmd, **kw):
# Une interrogation de service — la détection de thème —
# n'est pas un rejeu de la commande : l'inscrire dans
# `lst_run` doublerait tous les comptes de cette suite.
if "ir_module_module" in cmd:
if kw.get("return_status_and_output_and_command"):
return 0, cmd, []
return 0, cmd
self.lst_run.append(cmd)
# Toujours en échec : c'est le cas qu'on veut borner.
statut = 1 if echec else 0
# La FORME du retour suit les drapeaux, comme le vrai :
# un faux qui rend toujours deux valeurs casse dès qu'un
# appelant demande la sortie.
if kw.get("return_status_and_output_and_command"):
return statut, cmd, []
if kw.get("return_status_and_output"):
return statut, []
return statut, cmd
obj.execute = FauxExecute()
suite = iter(resets if resets is not None else [])
def faux_reset(database):
self.lst_reset.append(database)
try:
return next(suite)
except StopIteration:
return False
obj.prompt_reset_stale_cow_views = faux_reset
obj.check_stale_cow_views = lambda db: None
obj.run_on_terminal = lambda cmd: 0
reponses = iter(lst_answer or [])
def faux_ask(prompt, default=""):
try:
return next(reponses)
except StopIteration:
# Personne ne répond : c'est le mode auto, et c'est
# précisément là qu'une boucle sans fin se déclenche.
return default
obj.ask = faux_ask
return obj
def executer(self, obj):
out = io.StringIO()
with redirect_stdout(out):
obj.todo_upgrade_execute("./script/addons/update_addons_all.sh db")
return out.getvalue()
class TestRepairingThenReplaying(Harness):
def test_the_default_repairs(self):
# Sans réponse, on répare : c'est le geste qu'on allait faire.
obj = self.upgrade(resets=[True, False])
self.executer(obj)
self.assertTrue(self.lst_reset)
def test_a_successful_repair_replays_the_command(self):
obj = self.upgrade(resets=[True, False])
self.executer(obj)
self.assertGreaterEqual(len(self.lst_run), 2)
def test_a_repair_that_changed_nothing_does_NOT_replay(self):
# Rejouer sans avoir rien changé donnerait le même échec.
obj = self.upgrade(resets=[False])
self.executer(obj)
self.assertEqual(len(self.lst_run), 1)
class TestItAlwaysStops(Harness):
"""La propriété qui compte le plus : la boucle se termine."""
def test_nothing_to_reset_does_not_loop_forever(self):
# Vécu : « Aucune copie COW n'a dérivé », reproposé sans fin parce
# que le défaut restait « 3 ».
obj = self.upgrade(resets=[False, False, False, False, False])
self.executer(obj)
self.assertLessEqual(len(self.lst_reset), 2)
def test_a_repair_that_never_helps_stops_after_three(self):
obj = self.upgrade(resets=[True] * 20)
self.executer(obj)
self.assertEqual(len(self.lst_run), TodoUpgrade.MAX_ERROR_RETRY)
def test_giving_up_says_so_out_loud(self):
# S'arrêter en silence ferait croire que c'est réparé.
obj = self.upgrade(resets=[True] * 20)
text = self.executer(obj)
self.assertIn("needs a developer", text)
def test_the_loop_is_bounded_STRUCTURALLY(self):
"""Même si la bascule du défaut disparaissait un jour.
Deux protections pour la même panne, et c'est délibéré : celle-ci
ne dépend d'aucune logique métier. Une boucle infinie dans une
migration lancée sans surveillance coûte une nuit, et la seule
preuve qu'une boucle se termine est de compter ses tours.
"""
obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
self.executer(obj)
self.assertLessEqual(len(self.lst_run), 2)
def test_the_turn_bound_says_so(self):
obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
text = self.executer(obj)
self.assertIn("Too many turns", text)
def test_the_bound_is_three(self):
self.assertEqual(TodoUpgrade.MAX_ERROR_RETRY, 3)
def test_a_command_that_succeeds_never_asks(self):
obj = self.upgrade(echec=False)
self.executer(obj)
self.assertEqual(self.lst_reset, [])
self.assertEqual(len(self.lst_run), 1)
class TestTheHumanKeepsTheWheel(Harness):
def test_typing_one_replays_without_consuming_the_budget(self):
# Le plafond borne le rejeu AUTOMATIQUE. Quelqu'un qui tape « 1 »
# sait ce qu'il fait, et se voir refuser un quatrième essai serait
# une surprise désagréable.
obj = self.upgrade(lst_answer=["1", "1", "1", "1", ""])
self.executer(obj)
self.assertEqual(len(self.lst_run), 5)
def test_typing_n_continues_without_repairing(self):
obj = self.upgrade(lst_answer=[""] * 0 + ["x"])
self.executer(obj)
self.assertEqual(self.lst_reset, [])
self.assertEqual(len(self.lst_run), 1)
def test_the_prompt_says_what_enter_does(self):
import inspect
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
# éprouve, pas une méthode en particulier.
source = inspect.getsource(
TodoUpgrade.todo_upgrade_execute
) + inspect.getsource(TodoUpgrade._prompt_on_error)
self.assertIn('defaut = "3" if database_name else ""', source)
self.assertIn("default=defaut", source)
def test_without_a_database_the_default_is_to_continue(self):
# Les options 2 à 4 ne s'affichent pas : proposer « 3 » viserait
# une commande qui n'existe pas.
obj = self.upgrade()
obj.database_from_command = lambda cmd: None
self.executer(obj)
self.assertEqual(self.lst_reset, [])
self.assertEqual(len(self.lst_run), 1)
class TestTheResetReportsWhatItDid(unittest.TestCase):
"""Sans verdict, on ne peut pas décider de rejouer."""
def test_nothing_drifted_is_False(self):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.stale_cow_keys = lambda db: []
with redirect_stdout(io.StringIO()):
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
def test_saying_no_is_False(self):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.stale_cow_keys = lambda db: ["web.layout"]
obj.ask_gate = lambda prompt, default="": "n"
with redirect_stdout(io.StringIO()):
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
def test_a_reset_that_ran_is_True(self):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.write_config = lambda: None
obj.stale_cow_keys = lambda db: ["web.layout"]
obj.ask_gate = lambda prompt, default="": default
obj.run_on_terminal = lambda cmd: 0
with redirect_stdout(io.StringIO()):
self.assertTrue(obj.prompt_reset_stale_cow_views("db"))
def test_a_reset_that_FAILED_is_False(self):
# Rejouer derrière une réinitialisation qui a échoué, c'est brûler
# une tentative sur un état inchangé.
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.write_config = lambda: None
obj.stale_cow_keys = lambda db: ["web.layout"]
obj.ask_gate = lambda prompt, default="": default
obj.run_on_terminal = lambda cmd: 2
with redirect_stdout(io.StringIO()):
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
if __name__ == "__main__":
unittest.main()

604
test/test_fix_view_type.py Normal file
View file

@ -0,0 +1,604 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une vue dont le `type` stocké contredit son héritage.
Deux propriétés portent l'outil. La première : il travaille en SQL, sans
l'ORM — le registre ne charge plus, et c'est justement ce qu'on répare.
La seconde : il ne corrige QUE ce qu'Odoo lui-même déclare faux — une
vue héritée prend le type de son ancêtre, règle citée dans `create`.
Vérifié sur une installation neuve et quatre bases migrées : zéro écart.
"""
import ast
import io
import os
import sys
import unittest
from contextlib import redirect_stdout
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import fix_view_type as fvt # noqa: E402
from script.todo import todo_i18n # noqa: E402
def vue(vid=637, actuel="tree", attendu="search", xmlid="event.x"):
return {
"id": vid,
"type": actuel,
"expected": attendu,
"mode": "primary",
"xmlid": xmlid,
"model": "event.registration",
}
class TestTheDetectionQuery(unittest.TestCase):
def test_it_only_looks_at_inherited_views(self):
# Une vue racine tient son type de sa balise : la comparer à
# elle-même n'apprendrait rien.
self.assertIn("v.inherit_id IS NOT NULL", fvt.DETECTION)
def test_it_compares_against_the_ROOT_ancestor(self):
# Pas le parent direct : une chaîne de trois vues doit remonter
# jusqu'à celle qui porte la balise.
self.assertIn("WITH RECURSIVE", fvt.DETECTION)
self.assertIn("v.type <> r.type", fvt.DETECTION)
def test_the_recursion_is_bounded(self):
# Un `inherit_id` circulaire ferait tourner PostgreSQL sans fin.
self.assertIn(f"r.prof < {fvt.PROFONDEUR_MAX}", fvt.DETECTION)
self.assertGreater(fvt.PROFONDEUR_MAX, 5)
def test_it_never_writes(self):
for mot in ("UPDATE", "DELETE", "INSERT", "DROP"):
self.assertNotIn(mot, fvt.DETECTION.upper().replace("UPDATED", ""))
class TestTheRepairSql(unittest.TestCase):
def test_it_sets_each_view_to_its_ancestor_type(self):
sql = fvt.repair_sql([vue(637, "tree", "search")])
self.assertIn("WHEN 637 THEN 'search'", sql)
self.assertIn("WHERE id IN (637)", sql)
def test_several_views_travel_in_ONE_statement(self):
# Une seule instruction, donc une seule transaction : couper au
# milieu ne laisse pas une base à moitié réparée.
sql = fvt.repair_sql(
[vue(1, "tree", "search"), vue(2, "form", "kanban")]
)
self.assertEqual(sql.count("UPDATE"), 1)
self.assertIn("WHEN 1 THEN 'search'", sql)
self.assertIn("WHEN 2 THEN 'kanban'", sql)
def test_nothing_to_fix_gives_no_sql(self):
self.assertEqual(fvt.repair_sql([]), "")
def test_it_targets_the_id_never_a_rebuilt_domain(self):
sql = fvt.repair_sql([vue()])
self.assertNotIn("inherit_id", sql)
self.assertNotIn("arch_db", sql)
self.assertNotIn("arch =", sql)
self.assertEqual(sql.count("SET "), 1)
self.assertIn("SET type =", sql)
class TestTheReport(unittest.TestCase):
def test_a_clean_check_renders_nothing(self):
self.assertEqual(fvt.render([]), [])
def test_a_finding_names_the_view_and_both_types(self):
texte = "\n".join(fvt.render([vue(637, "tree", "search")]))
self.assertIn("637", texte)
self.assertIn("tree → search", texte)
self.assertIn("event.x", texte)
def test_it_says_no_module_update_will_help(self):
# C'est le renseignement qui évite de perdre une heure à
# relancer `-u module` en boucle.
texte = "\n".join(fvt.render([vue()]))
self.assertIn(
todo_i18n.t(
"No module update will fix this: the type is set once,"
),
texte,
)
def test_an_applied_run_does_not_repeat_the_advice(self):
texte = "\n".join(fvt.render([vue()], applique=True))
self.assertIn(todo_i18n.t("Corrected."), texte)
self.assertNotIn(
todo_i18n.t("at creation. Re-run with --apply."), texte
)
class TestTheExitCodes(unittest.TestCase):
def setUp(self):
self.vrai = fvt.run_psql
self.ecritures = []
def tearDown(self):
fvt.run_psql = self.vrai
def branche(self, avant, apres=None):
"""`avant` puis `apres` : ce que la détection rend, avant/après."""
etat = {"tour": 0}
def faux(database, sql, read_only=True):
if not read_only:
self.ecritures.append(sql)
return []
etat["tour"] += 1
lot = avant if etat["tour"] == 1 or apres is None else apres
if lot is None:
return None
return [
[
str(v["id"]),
v["type"],
v["expected"],
v["mode"],
v["xmlid"],
v["model"],
]
for v in lot
]
fvt.run_psql = faux
def lance(self, argv):
tampon = io.StringIO()
with redirect_stdout(tampon):
code = fvt.main(argv)
return code, tampon.getvalue()
def test_nothing_wrong_exits_zero(self):
self.branche([])
code, _ = self.lance(["-d", "db"])
self.assertEqual(code, 0)
self.assertEqual(self.ecritures, [])
def test_a_finding_exits_one_and_writes_nothing(self):
# Sans --apply, l'outil DIAGNOSTIQUE. Réparer tout seul serait un
# piège dans un outil qu'on lance pour comprendre.
self.branche([vue()])
code, _ = self.lance(["-d", "db"])
self.assertEqual(code, 1)
self.assertEqual(self.ecritures, [])
def test_apply_fixes_and_exits_zero(self):
self.branche([vue()], apres=[])
code, sortie = self.lance(["-d", "db", "--apply"])
self.assertEqual(code, 0)
self.assertEqual(len(self.ecritures), 1)
self.assertIn(todo_i18n.t("Corrected."), sortie)
def test_a_correction_that_did_not_take_exits_two(self):
# Relire APRÈS : annoncer « corrigé » sans vérifier ferait
# relancer la migration sur le même mur.
self.branche([vue()], apres=[vue()])
code, _ = self.lance(["-d", "db", "--apply"])
self.assertEqual(code, 2)
def test_an_unreadable_database_exits_two(self):
self.branche(None)
code, _ = self.lance(["-d", "db"])
self.assertEqual(code, 2)
def arbre(vid=3931, origine="custom", type_="list", xmlid="-"):
return {
"id": vid,
"type": type_,
"xmlid": xmlid,
"model": "res.partner",
"origin": origine,
}
class TestTheTreeTag(unittest.TestCase):
"""Odoo 18 a renommé `tree` en `list`, balise comprise.
OpenUpgrade convertit la COLONNE `type` et jamais l'ARCH. Une vue de
module s'en remet à la première mise à jour ; une vue sans xmlid
n'est réécrite par rien.
"""
def test_the_replacement_covers_open_AND_close(self):
sql = fvt.repair_tree_sql([arbre()], jsonb=True)
self.assertIn("'<tree', '<list'", sql)
self.assertIn("'</tree>', '</list>'", sql)
def test_it_replaces_EVERY_occurrence_not_just_the_root(self):
# Un `<tree>` imbriqué dans un formulaire — une liste one2many —
# est refusé autant que celui de la racine. `replace` en SQL
# remplace tout ; un ancrage `^` n'aurait pris que le premier.
sql = fvt.repair_tree_sql([arbre()], jsonb=True)
self.assertNotIn("^", sql)
self.assertNotIn("regexp", sql.lower())
def test_jsonb_is_rebuilt_language_by_language(self):
# Une traduction oubliée laisserait la vue cassée dans cette
# langue seulement — une panne qui ne se montre qu'à certains.
sql = fvt.repair_tree_sql([arbre()], jsonb=True)
self.assertIn("jsonb_each_text", sql)
self.assertIn("jsonb_object_agg", sql)
def test_a_text_column_is_handled_too(self):
sql = fvt.repair_tree_sql([arbre()], jsonb=False)
self.assertNotIn("jsonb", sql)
self.assertIn("replace(replace(arch_db", sql)
def test_nothing_to_fix_gives_no_sql(self):
self.assertEqual(fvt.repair_tree_sql([], jsonb=True), "")
def test_it_targets_the_listed_ids_only(self):
sql = fvt.repair_tree_sql([arbre(11), arbre(22)], jsonb=True)
self.assertIn("WHERE id IN (11, 22)", sql)
def test_the_report_tells_custom_from_module(self):
# C'est LE renseignement utile : l'une se répare d'une mise à
# jour, l'autre ne se répare jamais toute seule.
texte = "\n".join(
fvt.render_tree([arbre(1, "custom"), arbre(2, "module")])
)
self.assertIn(
todo_i18n.t("custom — nothing will ever rewrite it"), texte
)
self.assertIn(
todo_i18n.t("from a module — a module update also fixes it"), texte
)
def test_no_tree_renders_nothing(self):
self.assertEqual(fvt.render_tree([]), [])
class TestTheSqlAgainstARealPostgres(unittest.TestCase):
"""Le SQL, exécuté pour de vrai.
Une assertion sur le TEXTE d'une requête ne voit pas ce qu'elle
fait : ajouter un `LIMIT 1` la laisserait passer alors qu'une seule
langue serait convertie — une vue cassée dans les autres, une panne
qui ne se montre qu'à certains.
"""
BASE = "tmp_fix_view_type_test"
@classmethod
def setUpClass(cls):
import shutil as _shutil
import subprocess as _sub
if not _shutil.which("psql") or not _shutil.which("createdb"):
raise unittest.SkipTest("PostgreSQL absent")
_sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True)
fait = _sub.run(["createdb", cls.BASE], capture_output=True)
if fait.returncode:
raise unittest.SkipTest("createdb impossible")
@classmethod
def tearDownClass(cls):
import shutil as _shutil
import subprocess as _sub
if _shutil.which("dropdb"):
_sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True)
def prepare(self, colonne, valeur):
fvt.run_psql(
self.BASE, "DROP TABLE IF EXISTS ir_ui_view", read_only=False
)
fvt.run_psql(
self.BASE,
f"CREATE TABLE ir_ui_view (id serial PRIMARY KEY,"
f" arch_db {colonne})",
read_only=False,
)
fvt.run_psql(
self.BASE,
f"INSERT INTO ir_ui_view (arch_db) VALUES ({valeur})",
read_only=False,
)
def arch(self):
lignes = fvt.run_psql(
self.BASE, "SELECT arch_db::text FROM ir_ui_view"
)
return lignes[0][0] if lignes else ""
def test_every_language_is_converted(self):
self.prepare(
"jsonb",
'\'{"en_US": "<tree><field/></tree>",'
' "fr_CA": "<tree><field/></tree>"}\'::jsonb',
)
fvt.run_psql(
self.BASE,
fvt.repair_tree_sql([arbre(1)], jsonb=True),
read_only=False,
)
obtenu = self.arch()
self.assertNotIn("<tree", obtenu)
self.assertEqual(obtenu.count("<list>"), 2)
self.assertEqual(obtenu.count("</list>"), 2)
def test_a_nested_tree_is_converted_too(self):
# Une liste one2many dans un formulaire : refusée autant que la
# racine, et invisible pour un contrôle qui ne verrait que celle-ci.
self.prepare(
"jsonb",
'\'{"en_US": "<form><field><tree/></field></form>"}\'::jsonb',
)
fvt.run_psql(
self.BASE,
fvt.repair_tree_sql([arbre(1)], jsonb=True),
read_only=False,
)
self.assertNotIn("<tree", self.arch())
self.assertIn("<list/>", self.arch())
def test_a_text_column_works_too(self):
self.prepare("text", "'<tree><field/></tree>'")
fvt.run_psql(
self.BASE,
fvt.repair_tree_sql([arbre(1)], jsonb=False),
read_only=False,
)
self.assertEqual(self.arch(), "<list><field/></list>")
def test_the_detection_query_runs(self):
# Une requête qui ne compile pas rendrait None, et l'outil
# dirait « base illisible » au lieu de « rien à corriger ».
self.prepare("jsonb", '\'{"en_US": "<form/>"}\'::jsonb')
fvt.run_psql(
self.BASE,
"CREATE TABLE IF NOT EXISTS ir_model_data"
" (id serial, model text, res_id integer, module text, name text)",
read_only=False,
)
fvt.run_psql(
self.BASE,
"ALTER TABLE ir_ui_view ADD COLUMN IF NOT EXISTS type text,"
" ADD COLUMN IF NOT EXISTS model text",
read_only=False,
)
self.assertEqual(fvt.find_tree(self.BASE, jsonb=True), [])
class TestTheVersionGate(unittest.TestCase):
"""Avant la 18, `<tree>` est parfaitement légitime."""
def setUp(self):
self.vrai = fvt.run_psql
self.ecritures = []
def tearDown(self):
fvt.run_psql = self.vrai
def branche(self, majeure, arbres=True):
def faux(database, sql, read_only=True):
if not read_only:
self.ecritures.append(sql)
return []
if "latest_version" in sql:
return [[f"{majeure}.0.1.3"]]
if "information_schema" in sql:
return [["jsonb"]]
if "WITH RECURSIVE" in sql:
return []
if "<tree" in sql:
return (
[["3931", "list", "-", "res.partner", "custom"]]
if arbres
else []
)
return []
fvt.run_psql = faux
def lance(self, argv):
tampon = io.StringIO()
with redirect_stdout(tampon):
code = fvt.main(argv)
return code, tampon.getvalue()
def test_a_17_database_is_left_alone(self):
# « Corriger » un <tree> légitime casserait une vue saine.
self.branche(17)
code, sortie = self.lance(["-d", "db"])
self.assertEqual(code, 0)
self.assertEqual(self.ecritures, [])
self.assertIn(
todo_i18n.t("Every view agrees with what Odoo expects."), sortie
)
def test_an_18_database_reports_it(self):
self.branche(18)
code, sortie = self.lance(["-d", "db"])
self.assertEqual(code, 1)
self.assertIn("3931", sortie)
self.assertEqual(self.ecritures, [])
def test_an_unknown_version_stays_on_the_safe_side(self):
# Sans version on ne SAIT pas : ne rien toucher.
self.branche(None)
code, _ = self.lance(["-d", "db"])
self.assertEqual(code, 0)
def test_apply_writes_once_per_repair(self):
appels = {"n": 0}
def faux(database, sql, read_only=True):
if not read_only:
self.ecritures.append(sql)
return []
if "latest_version" in sql:
return [["18.0.1.3"]]
if "information_schema" in sql:
return [["jsonb"]]
if "WITH RECURSIVE" in sql:
return []
if "<tree" in sql:
appels["n"] += 1
# Trouvé au premier passage, plus rien au second.
return (
[["3931", "list", "-", "res.partner", "custom"]]
if appels["n"] == 1
else []
)
return []
fvt.run_psql = faux
code, sortie = self.lance(["-d", "db", "--apply"])
self.assertEqual(code, 0)
self.assertEqual(len(self.ecritures), 1)
self.assertIn(todo_i18n.t("Corrected."), sortie)
def test_a_correction_that_did_not_take_exits_two(self):
self.branche(18)
code, _ = self.lance(["-d", "db", "--apply"])
self.assertEqual(code, 2)
class TestTheWiring(unittest.TestCase):
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def source(self):
with io.open(
os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
return handle.read()
def test_the_error_menu_offers_the_entry(self):
src = self.source()
self.assertIn("Fix views whose type contradicts their", src)
self.assertIn('if wait_status == "5" and database_name:', src)
self.assertIn("def prompt_fix_view_type", src)
def test_it_runs_the_tool_twice_report_then_apply(self):
src = self.source()
debut = src.index("def prompt_fix_view_type")
fin = src.index("def prompt_purge_dead_attachments")
bloc = src[debut:fin]
self.assertEqual(bloc.count("fix_view_type.py"), 1)
self.assertIn("--apply", bloc)
def test_only_a_finding_leads_to_the_question(self):
# 0 « rien à corriger » et 2 « l'outil a échoué » ne justifient
# ni la question ni le rejeu de la commande.
src = self.source()
debut = src.index("def prompt_fix_view_type")
fin = src.index("def prompt_purge_dead_attachments")
self.assertIn("if status != 1:", src[debut:fin])
def test_every_menu_entry_has_its_branch(self):
src = self.source()
debut = src.index("def _prompt_on_error")
fin = src.index("def prompt_fix_view_type")
bloc = src[debut:fin]
for rang in ("1", "2", "3", "4", "5"):
self.assertTrue(
f'f"[{rang}] ' in bloc, f"entrée [{rang}] absente du menu"
)
for rang in ("2", "3", "4", "5"):
self.assertTrue(
f'wait_status == "{rang}"' in bloc,
f"aiguillage [{rang}] absent",
)
class TestTheToolIsCalledWithTheRightContract(unittest.TestCase):
"""Pour cet outil, 1 veut dire « trouvailles », pas « échec ».
`todo_upgrade_execute` ouvre SON menu d'erreur sur tout statut non
nul. Sans `wait_at_error=False`, ce menu se superpose au nôtre : la
question « Les corriger ? » n'est jamais posée, et l'on tourne en
rond. C'est arrivé en vrai, sur une migration bloquée.
"""
def setUp(self):
from script.todo import auto_ask
from script.todo.todo_upgrade import TodoUpgrade
self.auto_ask = auto_ask
self.vrai_ask = auto_ask.ask
self.obj = TodoUpgrade.__new__(TodoUpgrade)
self.appels = []
def faux(cmd, **kw):
self.appels.append((cmd, kw))
statut = 0 if "--apply" in cmd else 1
if statut and kw.get("wait_at_error", True):
# Ce que fait le VRAI : il n'en revient pas avec le
# statut, il repart dans son propre menu.
raise AssertionError(
"menu d'erreur imbriqué : wait_at_error manquant"
)
return statut, cmd
self.obj.todo_upgrade_execute = faux
self.obj.ask = lambda prompt, default="": "y"
def tearDown(self):
self.auto_ask.ask = self.vrai_ask
def test_the_report_call_does_not_reopen_the_error_menu(self):
with redirect_stdout(io.StringIO()):
self.obj.prompt_fix_view_type("db")
self.assertTrue(self.appels)
self.assertIs(self.appels[0][1].get("wait_at_error"), False)
def test_the_apply_call_does_not_either(self):
with redirect_stdout(io.StringIO()):
self.obj.prompt_fix_view_type("db")
self.assertEqual(len(self.appels), 2)
self.assertIn("--apply", self.appels[1][0])
self.assertIs(self.appels[1][1].get("wait_at_error"), False)
def test_it_returns_true_only_when_the_apply_succeeded(self):
with redirect_stdout(io.StringIO()):
self.assertTrue(self.obj.prompt_fix_view_type("db"))
def test_refusing_runs_no_apply(self):
self.obj.ask = lambda prompt, default="": "n"
with redirect_stdout(io.StringIO()):
self.assertFalse(self.obj.prompt_fix_view_type("db"))
self.assertEqual(len(self.appels), 1)
def test_nothing_to_fix_asks_nothing(self):
demandes = []
self.obj.ask = (
lambda prompt, default="": demandes.append(prompt) or "y"
)
self.obj.todo_upgrade_execute = lambda cmd, **kw: (0, cmd)
with redirect_stdout(io.StringIO()):
self.assertFalse(self.obj.prompt_fix_view_type("db"))
self.assertEqual(demandes, [])
class TestTranslations(unittest.TestCase):
def test_every_key_exists(self):
with io.open(fvt.__file__, encoding="utf-8") as handle:
arbre = ast.parse(handle.read())
for node in ast.walk(arbre):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "t"
and node.args
and isinstance(node.args[0], ast.Constant)
):
cle = node.args[0].value
self.assertTrue(
cle in todo_i18n.TRANSLATIONS,
f"clé sans traduction : {cle!r}",
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'écran de reprise doit OFFRIR le retour à une étape, pas le cacher.
Le mécanisme existait — Entrée sur une ligne du tableau — mais vivait dans une
ligne d'astuce sous quatre boutons nommés. Une capacité qu'il faut deviner
n'est pas offerte : elle a donc une touche, un bouton et un message, comme les
quatre autres actions.
"""
import json
import unittest
from script.todo import todo_i18n
from script.todo.migration_form import run_resume_tui
from script.todo.todo_upgrade import TodoUpgrade
def context():
"""Le contexte d'une migration arrêtée avant les montées de version."""
progression = {
"migration_file": "./image_db/technolibre.zip",
"config_database_name": "technolibre_migration_01_neutralize",
"target_odoo_version": "18.0",
"date_create": "2026-08-12 05:04:22",
"state_0_install_odoo": True,
"state_1_restore_database": True,
"state_2_update_all": True,
"state_3_clean_database": True,
}
return TodoUpgrade.resume_context(TodoUpgrade, progression)
class TestBackIsOffered(unittest.TestCase):
def setUp(self):
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
self.ctx = context()
def bindings(self, app):
out = {}
for item in app.BINDINGS:
if isinstance(item, tuple):
out[item[0]] = item[1]
else:
out[item.key] = item.action
return out
def test_b_is_a_binding_like_the_others(self):
app = run_resume_tui(self.ctx, run_app=False)
self.assertEqual(self.bindings(app).get("b"), "back_step")
def test_the_action_exists(self):
self.assertTrue(
hasattr(
run_resume_tui(self.ctx, run_app=False), "action_back_step"
)
)
class TestBackWorks(unittest.IsolatedAsyncioTestCase):
"""Piloté dans un terminal simulé : la touche mène bien au choix."""
def setUp(self):
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
self.ctx = context()
async def test_b_focuses_the_steps_and_enter_replays_from_there(self):
from textual.widgets import DataTable
app = run_resume_tui(self.ctx, run_app=False)
async with app.run_test() as pilot:
await pilot.press("b")
await pilot.pause()
table = app.query_one("#steps", DataTable)
self.assertTrue(table.has_focus)
# Le curseur part sur la première étape inachevée ; on remonte de
# deux pour viser « mettre à jour tous les modules ».
await pilot.press("up")
await pilot.press("up")
await pilot.pause()
expected = str(self.ctx["steps"][table.cursor_row]["step"])
await pilot.press("enter")
await pilot.pause()
self.assertEqual(app._result["answer"], expected)
async def test_there_is_a_button_too(self):
# C'est l'absence VISIBLE qui a été signalée : une touche seule ne se
# voit pas parmi quatre boutons. Les widgets ne se lisent qu'une fois
# l'écran monté, d'où le pilote.
from textual.widgets import Button
app = run_resume_tui(self.ctx, run_app=False)
async with app.run_test():
labels = [b.label.plain for b in app.query(Button)]
self.assertIn("Go back to a step", labels)
async def test_the_button_does_the_same(self):
from textual.widgets import Button, DataTable
app = run_resume_tui(self.ctx, run_app=False)
async with app.run_test() as pilot:
button = next(b for b in app.query(Button) if b.id == "a_back")
await pilot.click(button)
await pilot.pause()
self.assertTrue(app.query_one("#steps", DataTable).has_focus)
# Et surtout : le bouton ne répond PAS à la place de l'utilisateur.
self.assertIsNone(app._result["answer"])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les outils de migration parlaient anglais sur un système en français.
Ils tournent en sous-processus depuis le pilote, et n'avaient aucun i18n : la
migration alternait donc les deux langues d'une ligne à l'autre.
Deux choses à tenir, et la seconde est un piège : le pilote CHERCHAIT des
phrases anglaises dans leur sortie pour décider s'il devait poser une question
(« will break when moving to », « No website COW view to neutralize »).
Traduire ces phrases l'aurait rendu muet — sans erreur, sans trace. Le lien
passe désormais par le code de sortie, que la langue ne touche pas.
"""
import ast
import os
import subprocess
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Les outils COW, ceux que la migration lance et dont la sortie est lue par un
# humain au milieu d'une migration de plusieurs heures.
TOOLS = (
"check_cow_views.py",
"cow_drift.py",
"neutralize_cow_views.py",
"reset_stale_cow_views.py",
"snapshot_cow_views.py",
)
# Ce qui n'est pas de la prose : du SQL, des chemins, des drapeaux.
NOT_PROSE = ("SELECT ", "UPDATE ", "./script/", "--", "id=", "<!--")
def displayed_strings(path):
"""Chaînes affichées sans passer par t(), et qui sont de la prose."""
with open(path) as handle:
tree = ast.parse(handle.read())
translated, keys = set(), set()
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "t":
for arg in node.args:
translated.add(id(arg))
if isinstance(node, ast.Subscript) and isinstance(
node.slice, ast.Constant
):
keys.add(id(node.slice))
found = []
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", "") in ("print", "input")
):
continue
for sub in ast.walk(node):
if not (
isinstance(sub, ast.Constant) and isinstance(sub.value, str)
):
continue
if id(sub) in translated or id(sub) in keys:
continue
text = sub.value.strip()
# De la prose, pas de la ponctuation d'invite ni un nom de
# commande : au moins deux mots réellement alphabétiques.
# « ' (y/Y) : » n'en a aucun, « make repo_show_status » un seul.
words = [w for w in text.split() if w.isalpha() and len(w) > 2]
if len(words) < 2:
continue
if any(mark in text for mark in NOT_PROSE):
continue
found.append((sub.lineno, text))
return found
class TestNoEnglishLeftInTheTools(unittest.TestCase):
def test_every_displayed_sentence_goes_through_t(self):
for name in TOOLS:
path = os.path.join(REPO, "script", "odoo", "migration", name)
with self.subTest(tool=name):
self.assertEqual(displayed_strings(path), [], name)
def test_the_detector_is_not_blind(self):
# Un test de couverture qui ne voit rien passe toujours. On lui donne
# une phrase non traduite, il doit la trouver.
import tempfile
with tempfile.NamedTemporaryFile(
"w", suffix=".py", delete=False
) as handle:
handle.write('print("this sentence is not translated")\n')
handle.write("print(f\"{t('this one is')}\")\n")
self.addCleanup(os.unlink, handle.name)
found = displayed_strings(handle.name)
self.assertEqual(
[text for _, text in found], ["this sentence is not translated"]
)
class TestTheDatabaseMigrationSpeaksFrench(unittest.TestCase):
"""Le parcours que l'on suit pendant des heures, en une seule langue.
`execute_odoo_upgrade` est la migration de base : c'est ce qui défile à
l'écran de l'étape 0 à l'étape 6. Elle alternait les deux langues.
"""
def driver(self):
path = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
with open(path) as handle:
return ast.parse(handle.read()), path
def test_no_english_sentence_is_displayed_raw(self):
tree, path = self.driver()
target = [
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef)
and node.name == "execute_odoo_upgrade"
]
self.assertEqual(len(target), 1, "fonction introuvable")
left = [
text
for _, text in displayed_strings(path)
if target[0].lineno <= _ <= target[0].end_lineno
# Ponctuation d'invite : « (y/Y) : » n'est pas une phrase.
and len(text.split()) > 2
]
self.assertEqual(left, [])
def test_every_t_key_has_a_translation(self):
# `t()` rend sa CLÉ quand la traduction manque : une clé oubliée
# s'affiche en anglais sans que rien ne signale l'oubli.
from script.todo.todo_i18n import TRANSLATIONS
tree, _ = self.driver()
missing = sorted(
{
arg.value
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and getattr(node.func, "id", "") == "t"
for arg in node.args
if isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
and arg.value not in TRANSLATIONS
}
)
self.assertEqual(missing, [])
def test_the_step_headers_translate_only_their_label(self):
# L'en-tête part AUSSI dans le journal, que l'écran de reprise
# relit : traduire ce qui est écrit rendrait un journal illisible
# pour l'autre langue.
import io
from contextlib import redirect_stdout
from script.todo import todo_i18n
from script.todo.todo_upgrade import TodoUpgrade
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "fr"
upgrade = TodoUpgrade.__new__(TodoUpgrade)
out = io.StringIO()
with redirect_stdout(out):
upgrade.print_step("2 - Succeed update all addons")
printed = out.getvalue()
self.assertIn("🔷 2 - ", printed)
self.assertNotIn("Succeed", printed)
class TestTheDriverNoLongerReadsEnglish(unittest.TestCase):
"""Le lien pilote/outil doit survivre à une traduction."""
def source(self):
path = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
with open(path) as handle:
return handle.read()
def test_no_output_text_matching_is_left(self):
# LE piège : `if "will break when moving to" in "\n".join(output)`.
# Muet, sans erreur, dès que l'outil parle une autre langue.
self.assertNotIn('in "\\n".join(output', self.source())
def test_the_cow_decisions_use_the_exit_code(self):
source = self.source()
self.assertIn("if status == 1:", source)
self.assertIn("if status == 0:", source)
class TestTheExitCodesTheDriverRelieson(unittest.TestCase):
"""0 = rien, 1 = des copies concernées, 2 = l'outil a échoué."""
def run_tool(self, name, args):
return subprocess.run(
[sys.executable, f"./script/odoo/migration/{name}", *args],
cwd=REPO,
capture_output=True,
text=True,
)
def test_a_missing_target_version_is_a_tool_failure(self):
# Pas 1 : 1 veut dire « des copies casseront », et confondre les deux
# ferait poser une question sur un résultat qui n'existe pas.
for name in ("check_cow_views.py", "neutralize_cow_views.py"):
with self.subTest(tool=name):
done = self.run_tool(name, ["-d", "x", "-t", "odoo99.0"])
self.assertEqual(done.returncode, 2, done.stdout)
def test_the_failure_message_is_translated(self):
done = self.run_tool("check_cow_views.py", ["-d", "x", "-t", "zz9.0"])
self.assertNotIn("not found", done.stdout)
def test_check_cow_views_reports_findings_with_1(self):
# Le chemin qui compte demande une base ; on vérifie la décision
# elle-même, à la source.
path = os.path.join(
REPO, "script", "odoo", "migration", "check_cow_views.py"
)
with open(path) as handle:
self.assertIn("return 1 if lst_at_risk else 0", handle.read())
class TestAllToolsKeepWorkingStandalone(unittest.TestCase):
def test_they_import_without_the_repo_on_the_path(self):
# L'import d'i18n remonte trois répertoires ; une faute s'y voit
# seulement à l'exécution, et alors la migration est déjà lancée.
for name in TOOLS:
with self.subTest(tool=name):
done = subprocess.run(
[sys.executable, f"./script/odoo/migration/{name}", "-h"],
cwd=REPO,
capture_output=True,
text=True,
)
self.assertEqual(done.returncode, 0, done.stderr)
if __name__ == "__main__":
unittest.main()

View 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()

View file

@ -0,0 +1,595 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que fait Entrée, et ce que fait le silence.
Une migration pose une quarantaine de questions dont la réponse était
presque toujours la même. Le mode auto promettait de les prendre « par
défaut » — sauf que le défaut était VIDE partout : il ne faisait rien, et
la moitié des invites vivent de toute façon dans des outils lancés à part,
qui ne savaient rien du mode auto et attendaient une frappe pour toujours.
Deux exigences, donc, et elles sont indissociables :
- ce que fait Entrée doit être ÉCRIT dans la question. Une invite qui
annonce « y/N » et désinstalle est pire que pas d'invite du tout ;
- aucune invite du chemin de migration ne doit rester un `input()` nu. Ce
n'est pas une faute qu'on commet exprès, c'est une faute qu'on commet
par habitude — d'où le test qui refuse la construction elle-même.
"""
import ast
import contextlib
import io
import os
import subprocess
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
sys.path.insert(0, os.path.join(REPO, "script", "addons"))
from script.todo import auto_ask # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class EnvCase(unittest.TestCase):
def setUp(self):
avant = {
key: os.environ.get(key)
for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
}
def remettre():
for key, value in avant.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(remettre)
auto_ask.export(False)
class TestTheChannelThatCrossesAFork(EnvCase):
"""L'environnement, parce que c'est le SEUL canal partagé.
Le désinstalleur de thème, le détecteur de SCSS figé et le test de
fumée sont des processus séparés. Ils ne voient ni l'objet du pilote ni
sa mémoire ; sans une variable d'environnement, ils attendraient une
frappe qui ne vient jamais, et l'automatisation s'arrêterait là — sans
message, puisque la question, elle, a bien été posée.
"""
def test_export_puts_it_where_a_child_will_read_it(self):
auto_ask.export(True, 3)
self.assertEqual(os.environ[auto_ask.ENV_ENABLED], "1")
self.assertEqual(os.environ[auto_ask.ENV_DELAY], "3")
self.assertTrue(auto_ask.enabled())
def test_turning_it_off_REMOVES_it(self):
# Y écrire « 0 » laisserait un reste de session précédente décider à
# notre place : ce qui n'existe pas ne peut pas se tromper.
auto_ask.export(True, 3)
auto_ask.export(False)
self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
self.assertFalse(auto_ask.enabled())
def test_a_real_child_process_takes_the_default(self):
# LE test qui compte : un vrai sous-processus, un vrai stdin fermé.
code = (
"import sys; sys.path.insert(0, %r);"
"from script.todo import auto_ask;"
"print(auto_ask.ask('q : ', default='d'))" % REPO
)
env = dict(os.environ)
env[auto_ask.ENV_ENABLED] = "1"
env[auto_ask.ENV_DELAY] = "1"
done = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=30,
cwd=REPO,
env=env,
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn("d", done.stdout.strip().splitlines()[-1])
def test_a_child_without_the_flag_still_asks(self):
# Hors mode auto, on ne décide à la place de personne : le tuyau
# fermé rend une ligne vide, et le défaut n'arrive que là.
code = (
"import sys; sys.path.insert(0, %r);"
"from script.todo import auto_ask;"
"print('RÉPONSE=' + auto_ask.ask('q : ', default='d'))" % REPO
)
done = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
input="k\n",
timeout=30,
cwd=REPO,
)
self.assertIn("RÉPONSE=k", done.stdout)
def test_a_broken_delay_does_not_freeze_anything(self):
os.environ[auto_ask.ENV_DELAY] = "plus tard"
self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY)
def test_a_zero_delay_is_refused(self):
# Sans fenêtre, on ne peut pas reprendre la main : « auto » ne veut
# pas dire « sans recours ».
os.environ[auto_ask.ENV_DELAY] = "0"
self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY)
class TestEnterMeansTheDefault(EnvCase):
"""Hors mode auto AUSSI : sinon la question mentirait à qui la lit."""
def test_an_empty_answer_takes_the_default(self):
import builtins
original = builtins.input
builtins.input = lambda prompt="": ""
self.addCleanup(setattr, builtins, "input", original)
self.assertEqual(auto_ask.ask("q : ", default="y"), "y")
def test_a_typed_answer_wins(self):
import builtins
original = builtins.input
builtins.input = lambda prompt="": "n"
self.addCleanup(setattr, builtins, "input", original)
self.assertEqual(auto_ask.ask("q : ", default="y"), "n")
def test_the_driver_exports_it_on_every_ask(self):
# Un outil lancé plus bas doit voir le mode auto même si la question
# d'activation date d'une reprise précédente.
import builtins
original = builtins.input
builtins.input = lambda prompt="": ""
self.addCleanup(setattr, builtins, "input", original)
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.auto_execute = True
obj.AUTO_DELAY = 0.2
with contextlib.redirect_stdout(io.StringIO()):
obj.ask("q : ", default="y")
self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
class TestTheCountdownSpeaksThePromptsLanguage(EnvCase):
"""« ⏱ → d » nommait une réponse qui n'était PAS au menu.
L'invite annonce « Entrée = effacer, k = garder » : elle ne mentionne
« d » nulle part. Afficher la valeur brute obligeait donc à deviner ce
que l'outil venait de décider — alors que la décision était exactement
celle qu'Entrée promettait.
"""
def setUp(self):
super().setUp()
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def tick(self, default):
"""Forcer le chemin du DÉLAI, celui qui affiche la ligne."""
import select
original = select.select
select.select = lambda r, w, x, t: ([], [], [])
self.addCleanup(setattr, select, "select", original)
auto_ask.export(True, 0.01)
out = io.StringIO()
with contextlib.redirect_stdout(out):
valeur = auto_ask.ask("q : ", default=default)
return valeur, out.getvalue()
def test_it_names_what_the_prompt_offered(self):
_valeur, texte = self.tick("d")
self.assertIn("Enter", texte)
def test_the_raw_value_stays_for_precision(self):
# La nommer reste utile : deux invites voisines peuvent avoir des
# défauts différents, et l'on relit ces lignes après coup.
_valeur, texte = self.tick("d")
self.assertIn("(d)", texte)
def test_an_empty_default_shows_no_parentheses(self):
# « Entrée () » n'aurait aucun sens : il n'y a rien à préciser.
_valeur, texte = self.tick("")
self.assertIn("Enter", texte)
self.assertNotIn("(", texte)
def test_the_answer_itself_is_unchanged(self):
# L'affichage seul change : ce que la migration reçoit reste la
# valeur, sinon toutes les invites changeraient de comportement.
valeur, _texte = self.tick("d")
self.assertEqual(valeur, "d")
def test_it_follows_the_language_of_the_migration(self):
from script.todo import todo_i18n
todo_i18n._current_lang = "fr"
_valeur, texte = self.tick("d")
self.assertIn("Entrée", texte)
class TestTheQuestionThatCannotAnswerItself(EnvCase):
"""Celle-là engage toutes les suivantes : elle ne se prend pas seule.
Une variable `ERPLIBRE_AUTO_EXECUTE` héritée du shell ferait répondre
« oui » à la place de quelqu'un, et cette réponse-là décide de toutes
les autres pendant des heures.
"""
def answer(self, typed):
import builtins
obj = TodoUpgrade.__new__(TodoUpgrade)
original = builtins.input
builtins.input = lambda prompt="": typed
self.addCleanup(setattr, builtins, "input", original)
with contextlib.redirect_stdout(io.StringIO()):
obj.prompt_auto_execute()
return obj.auto_execute
def test_enter_says_no(self):
self.assertFalse(self.answer(""))
def test_the_default_is_written_down_not_deduced(self):
# `answer == "y"` donnait le bon résultat par accident ; un défaut
# explicite dit ce qu'on veut, et se relit.
import inspect
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
self.assertIn('default="n"', source)
def test_an_inherited_flag_cannot_switch_it_on(self):
os.environ[auto_ask.ENV_ENABLED] = "1"
os.environ[auto_ask.ENV_DELAY] = "5"
self.assertFalse(self.answer(""))
def test_the_inherited_flag_is_cleared_before_asking(self):
import inspect
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
self.assertLess(
source.index("auto_ask.export(False)"),
source.index("auto_ask.ask("),
)
def test_yes_still_says_yes(self):
self.assertTrue(self.answer("y"))
class TestEveryPromptAnnouncesTheCountdown(EnvCase):
"""Une invite qui a l'air d'attendre indéfiniment n'invite pas à
s'absenter — et c'est pourtant tout l'intérêt du mode auto."""
def ask(self, default="d", seconds=5):
import select
original = select.select
select.select = lambda r, w, x, t: ([], [], [])
self.addCleanup(setattr, select, "select", original)
auto_ask.export(True, seconds)
out = io.StringIO()
with contextlib.redirect_stdout(out):
auto_ask.ask("💬 q : ", default=default)
return out.getvalue()
def test_the_delay_is_announced_before_the_question(self):
texte = self.ask()
self.assertIn("⏱5s", texte)
self.assertLess(texte.index("⏱5s"), texte.index("💬"))
def test_it_shows_the_real_delay(self):
self.assertIn("⏱2s", self.ask(seconds=2))
def test_nothing_is_announced_outside_auto_mode(self):
import builtins
auto_ask.export(False)
original = builtins.input
vu = []
builtins.input = lambda prompt="": vu.append(prompt) or "x"
self.addCleanup(setattr, builtins, "input", original)
auto_ask.ask("💬 q : ", default="d")
self.assertNotIn("⏱", vu[0])
def test_the_clock_announces_AND_reports(self):
# Le même symbole des deux côtés : il annonce l'attente, puis rend
# compte de la réponse. Deux symboles feraient deux mécanismes.
texte = self.ask()
self.assertEqual(texte.count("⏱"), 2)
class TestNoPromptOfTheMigrationCanHang(unittest.TestCase):
"""Un `input()` nu ne sait rien du mode auto : il attend, pour toujours.
Vécu à l'échelle du fichier : deux invites avaient échappé au premier
passage — la prédiction COW et le choix de désinstallation — parce que
le garde-fou ne regardait qu'`execute_odoo_upgrade`. On regarde
désormais TOUTES les méthodes du chemin de migration.
"""
METHODES = (
"execute_odoo_upgrade",
# Appelée DEPUIS execute_odoo_upgrade : c'est par elle que le
# « validez que le dépôt est prêt » arrivait, et il bloquait.
"internal_module_upgrade",
"prompt_cow_prediction",
"prompt_uninstall_theme",
"prompt_reset_stale_cow_views",
"prompt_database_cleanup",
"prompt_smoke_public_url",
"split_present_missing",
"prompt_uninstall_missing",
"todo_upgrade_execute",
)
def bare_inputs(self, nom):
chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
with open(chemin) as handle:
tree = ast.parse(handle.read())
cible = [
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == nom
]
self.assertEqual(len(cible), 1, f"méthode introuvable : {nom}")
return [
node.lineno
for node in ast.walk(cible[0])
if isinstance(node, ast.Call)
and getattr(node.func, "id", "") == "input"
]
def test_none_of_them_asks_outside_the_timer(self):
coupables = {
nom: self.bare_inputs(nom)
for nom in self.METHODES
if self.bare_inputs(nom)
}
self.assertEqual(coupables, {}, "invites hors du mode auto")
class TestWhereBlockingIsStillRight(unittest.TestCase):
"""Tout ne doit pas prendre un défaut : certaines questions n'en ont pas.
`execute_module_upgrade` demande un nom de module, un chemin, une
version de départ. Rendre « » au bout de cinq secondes n'y serait pas
une commodité mais une réponse fausse. Ces invites restent bloquantes,
et c'est correct — à une condition, vérifiée ici : qu'aucune migration
automatique ne passe par elles.
"""
def test_the_module_menu_is_not_on_the_automatic_path(self):
import inspect
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertNotIn("execute_module_upgrade", source)
def test_but_the_step_it_shares_IS(self):
# `internal_module_upgrade` est appelée des DEUX côtés : par le
# menu et par la migration. C'est ce qui la rend obligatoire.
import inspect
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertIn("internal_module_upgrade", source)
def test_its_prompts_ask_for_values_with_no_default(self):
# Si l'un d'eux devenait un oui/non, il faudrait le faire passer
# par le lecteur temporisé comme les autres.
import inspect
source = inspect.getsource(TodoUpgrade.execute_module_upgrade)
for question in ("Module name", "Path", "From odoo version"):
self.assertIn(question, source)
class TestWhatEnterDoesIsWritten(unittest.TestCase):
"""La question doit dire la vérité à qui appuie sur Entrée."""
def source(self, nom):
import inspect
return inspect.getsource(getattr(TodoUpgrade, nom))
def test_the_theme_prompt_announces_Y(self):
source = self.source("prompt_uninstall_theme")
self.assertIn("(Y/n,", source)
self.assertIn('default="y"', source)
def test_the_cleanup_prompt_announces_Y(self):
source = self.source("prompt_database_cleanup")
self.assertIn("(Y/n,", source)
self.assertIn('default="y"', source)
def test_the_smoke_prompt_announces_Y(self):
source = self.source("prompt_smoke_public_url")
self.assertIn("(Y/n,", source)
self.assertIn('default="y"', source)
def test_the_reset_prompt_says_enter_is_all(self):
source = self.source("prompt_reset_stale_cow_views")
self.assertIn("Enter = all", source)
self.assertIn('default="a"', source)
def test_the_cow_prediction_says_enter_neutralizes(self):
source = self.source("prompt_cow_prediction")
self.assertIn("Enter = neutralize now", source)
self.assertIn('default="a"', source)
def test_the_uninstall_strategy_defaults_to_one(self):
source = self.source("prompt_uninstall_missing")
self.assertIn("Enter = 1", source)
self.assertIn('default="1"', source)
def test_every_default_has_a_way_to_say_no(self):
# Un défaut qui agit sans issue n'est plus un défaut, c'est un ordre.
for nom, refus in (
("prompt_reset_stale_cow_views", '== "n"'),
("prompt_uninstall_missing", '== "3"'),
("prompt_cow_prediction", "n = "),
):
self.assertIn(refus, self.source(nom), nom)
class TestTheBackupAtTheEnd(unittest.TestCase):
"""Au bout de plusieurs heures, l'archive est ce qu'on voulait.
Trois réponses, trois sens, et le troisième est celui qui piège : « n »
refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE RESTE est un
nom de fichier. « non.zip » est donc un fichier, pas un refus.
"""
TPL = "./odoo_bin.sh db --backup --database x --restore_image"
DEFAUT = "./odoo_bin.sh db --backup --database x --restore_image x_f_2026"
def cmd(self, answer):
return TodoUpgrade.backup_command(answer, self.TPL, self.DEFAUT)
def test_yes_takes_the_timestamped_name(self):
self.assertEqual(self.cmd("y"), self.DEFAUT)
def test_case_does_not_matter(self):
self.assertEqual(self.cmd("Y"), self.DEFAUT)
def test_n_refuses(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
self.assertEqual(self.cmd("n"), "")
self.assertEqual(self.cmd("N"), "")
def test_anything_else_is_a_filename(self):
self.assertTrue(self.cmd("archive.zip").endswith(" archive.zip"))
def test_a_filename_that_STARTS_with_n_is_still_a_filename(self):
# Le piège : comparer un début de mot au lieu de la réponse
# entière ferait passer « non.zip » pour un refus, et l'on
# perdrait l'archive d'une migration de plusieurs heures.
self.assertTrue(self.cmd("non.zip").endswith(" non.zip"))
def test_blanks_are_not_a_filename(self):
self.assertEqual(self.cmd(" "), "")
def test_none_does_not_crash(self):
self.assertEqual(self.cmd(None), "")
def test_enter_backs_up_because_the_default_says_so(self):
import inspect
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
debut = source.index("Export a backup?")
fenetre = source[debut : debut + 200]
self.assertIn('default="y"', fenetre)
self.assertIn("(Y/n,", fenetre)
class TestTheToolsLaunchedApart(unittest.TestCase):
def test_the_leftovers_default_to_deleting(self):
import theme_leftover
self.assertEqual(theme_leftover.DEFAULT_ANSWER, "d")
def test_the_leftovers_are_saved_BEFORE_being_deleted(self):
# C'est ce qui rend ce défaut tenable. Sans la sauvegarde, il aurait
# dû rester « garder » : on ne fait pas d'une décision irréversible
# la réponse qu'on obtient en ne répondant pas.
import inspect
import theme_leftover
source = inspect.getsource(theme_leftover.prompt)
self.assertLess(
source.index("backup_attachments"),
source.index("delete_attachments"),
)
def test_the_leftovers_prompt_says_enter_deletes(self):
import inspect
import theme_leftover
source = inspect.getsource(theme_leftover.prompt)
self.assertIn("Enter = delete", source)
self.assertIn("k = ", source)
def test_the_smoke_reset_defaults_to_all(self):
import smoke_public_url
self.assertEqual(smoke_public_url.DEFAULT_ANSWER, "a")
def test_the_scss_default_follows_what_the_checkout_can_do(self):
# « a » là où `reset_asset` n'existe pas (avant la 13.0) ferait
# boucler l'invite sur elle-même : le défaut suit la capacité.
import inspect
import check_stale_scss
source = inspect.getsource(check_stale_scss.prompt)
self.assertIn('defaut = "a" if can_reset else "n"', source)
def test_each_of_them_can_run_without_a_driver(self):
# Le repli compte : ces outils se lancent aussi à la main, et une
# ImportError les rendrait inutilisables hors migration.
import check_stale_scss
import smoke_public_url
import theme_leftover
for module in (theme_leftover, smoke_public_url, check_stale_scss):
import inspect
self.assertIn(
"auto_ask = None",
inspect.getsource(module),
module.__name__,
)
class TestTestingEveryBump(unittest.TestCase):
"""« a » dit une fois ce qu'on répétait six fois."""
def source(self):
import inspect
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_the_option_is_offered(self):
self.assertIn("Open it at EVERY version bump", self.source())
def test_it_is_remembered_in_the_progression(self):
# Sinon une reprise après interruption reposerait la question, et
# l'automatisation s'arrêterait au premier palier suivant.
source = self.source()
self.assertIn("state_4_selenium_every_bump", source)
self.assertIn("self.write_config()", source)
def test_the_memory_stops_the_question(self):
source = self.source()
debut = source.index("state_4_selenium_every_bump")
fenetre = source[debut : debut + 400]
self.assertIn('status = "y"', fenetre)
def test_it_belongs_to_step_four(self):
# Le nom porte sa propriété : un retour avant l'étape 4 doit
# l'effacer, comme tout ce que l'étape 4 a décidé.
from script.todo.todo_upgrade import STEP_PREFIX_RE
match = STEP_PREFIX_RE.match("state_4_selenium_every_bump")
self.assertIsNotNone(match)
self.assertEqual(match.group(1), "4")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Choisir une copie à réinitialiser dans une LISTE, pas de mémoire.
L'aide affichée après une erreur disait « --reset <key> --apply » et laissait
retrouver la clé dans un diff de mille lignes. On la recopie, on se trompe
d'un caractère, et la commande ne fait RIEN sans le dire — une clé qui ne
correspond à aucune copie n'est pas une erreur pour l'outil.
D'où l'option [3] : elle demande les clés à l'outil, les numérote, et offre
« toutes ». Ces tests portent sur ce que chaque réponse lance.
"""
import os
import subprocess
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from script.todo import todo_i18n # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class PromptCase(unittest.TestCase):
def setUp(self):
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def run_prompt(self, answer, lst_key=None):
"""(commandes lancées, texte affiché) après cette réponse."""
import contextlib
import io
if lst_key is None:
lst_key = ["website_sale.product", "web.layout", "website_blog.x"]
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
upgrade.stale_cow_keys = lambda db: lst_key
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
# les tests de défaut ne testeraient que le doublon.
upgrade.ask_gate = lambda prompt, default="": answer or default
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_reset_stale_cow_views("db")
return lst_cmd, out.getvalue()
class TestTheMenu(PromptCase):
def test_every_key_is_numbered(self):
# C'est tout l'objet : on ne connaît pas les clés de tête.
_cmd, text = self.run_prompt("")
self.assertIn("[1] website_sale.product", text)
self.assertIn("[2] web.layout", text)
self.assertIn("[3] website_blog.x", text)
def test_all_is_offered(self):
_cmd, text = self.run_prompt("")
self.assertIn("[a]", text)
def test_nothing_drifted_asks_nothing(self):
lst_cmd, text = self.run_prompt("a", lst_key=[])
self.assertEqual(lst_cmd, [])
self.assertIn("No COW copy", text)
class TestWhatEachAnswerRuns(PromptCase):
def test_a_number_resets_that_key(self):
lst_cmd, _ = self.run_prompt("2")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("--reset web.layout", lst_cmd[0])
self.assertIn("--apply", lst_cmd[0])
self.assertNotIn("website_sale", lst_cmd[0])
def test_several_numbers_reset_several_keys(self):
lst_cmd, _ = self.run_prompt("1,3")
self.assertIn("--reset website_sale.product", lst_cmd[0])
self.assertIn("--reset website_blog.x", lst_cmd[0])
self.assertNotIn("web.layout", lst_cmd[0])
def test_a_resets_them_all(self):
lst_cmd, _ = self.run_prompt("a")
self.assertIn("--reset all", lst_cmd[0])
def test_enter_resets_them_all(self):
# Entrée les prend TOUTES : elles ne sont dans cette liste que
# parce qu'un enfant n'y trouve plus son ancrage, et l'outil sauve
# ce que chaque copie portait avant de la réinitialiser.
lst_cmd, _text = self.run_prompt("")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("--reset all", lst_cmd[0])
def test_n_resets_nothing(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
lst_cmd, text = self.run_prompt("n")
self.assertEqual(lst_cmd, [])
self.assertIn("Kept", text)
def test_an_out_of_range_number_resets_nothing(self):
# Le piège serait de tomber en silence sur « all » ou sur la
# première clé : on ne devine pas ce qui n'a pas été demandé.
lst_cmd, text = self.run_prompt("9")
self.assertEqual(lst_cmd, [])
self.assertIn("Unknown choice", text)
def test_garbage_resets_nothing(self):
lst_cmd, text = self.run_prompt("zzz")
self.assertEqual(lst_cmd, [])
self.assertIn("Unknown choice", text)
class TestTheKeysComeFromTheTool(unittest.TestCase):
"""La liste est demandée à l'outil, pas devinée."""
def test_list_keys_prints_only_keys(self):
script = os.path.join(
REPO, "script", "odoo", "migration", "reset_stale_cow_views.py"
)
with open(script) as handle:
source = handle.read()
self.assertIn("--list-keys", source)
self.assertIn('print(cow_view["key"])', source)
def test_a_dead_database_yields_no_key(self):
# Sans ce garde-fou, un menu vide se lirait comme « rien n'a dérivé ».
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
upgrade.todo_upgrade_execute = lambda cmd, **kw: (2, cmd, [])
self.assertEqual(upgrade.stale_cow_keys("nope"), [])
def test_the_error_prompt_offers_it(self):
import inspect
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
# éprouve, pas une méthode en particulier.
source = inspect.getsource(
TodoUpgrade.todo_upgrade_execute
) + inspect.getsource(TodoUpgrade._prompt_on_error)
self.assertIn("prompt_reset_stale_cow_views", source)
self.assertIn("Reset one of them onto its module view", source)
class TestTheToolAcceptsWhatWeSend(unittest.TestCase):
def test_reset_can_be_repeated_and_accepts_all(self):
done = subprocess.run(
[
sys.executable,
"./script/odoo/migration/reset_stale_cow_views.py",
"--help",
],
capture_output=True,
text=True,
cwd=REPO,
)
self.assertEqual(done.returncode, 0)
self.assertIn("--list-keys", done.stdout)
self.assertIn("--reset", done.stdout)
if __name__ == "__main__":
unittest.main()

175
test/test_reset_stale_cow_tui.py Executable file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Lire une copie COW avant d'accepter de la perdre, en plein écran.
Le rapport texte met à mille lignes d'écart les deux choses qui décident :
ce que la copie porte en propre — la seule chose qu'une réinitialisation
abandonne — et quel enfant ne trouve plus son ancrage, qui est la raison
pour laquelle quoi que ce soit casse. L'espace bascule entre les deux.
La commande de réparation prend une CLÉ. La recopier à la main depuis un
diff défilé est l'endroit où un caractère se perd, et une clé sans
correspondance n'est pas une erreur pour l'outil : il tourne et ne fait
rien. D'où « c ».
"""
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
import reset_stale_cow_tui as tui # noqa: E402
import reset_stale_cow_views as reset # noqa: E402
def finding(**override):
cow = {
"id": 2841,
"key": "website_blog.blog_post_complete",
"website_id": 1,
"arch": "<t>\n <div/>\n <span>vieux</span>\n</t>",
}
module = {
"id": 1826,
"key": "website_blog.blog_post_complete",
"website_id": None,
"arch": "<t>\n <div/>\n <section id='o_wblog_post_footer'/>\n</t>",
}
broken = [(3288, "//section[@id='o_wblog_post_footer']")]
if "cow" in override:
cow.update(override["cow"])
return cow, override.get("module", module), override.get("broken", broken)
class TestBothScreens(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_the_diff_shows_what_the_copy_holds(self):
cow, module, _broken = finding()
text = reset.render_diff(module, cow, indent=" ")
self.assertIn("vieux", text)
self.assertIn("o_wblog_post_footer", text)
def test_the_why_screen_names_the_failing_child(self):
# Sans lui, on lit un diff sans savoir pourquoi il compte.
text = tui.render_broken(*finding())
self.assertIn("3288", text)
self.assertIn("//section[@id='o_wblog_post_footer']", text)
def test_the_why_screen_says_what_a_reset_costs(self):
text = tui.render_broken(*finding())
self.assertIn("INHERITING", text)
def test_a_copy_without_broken_child_is_said_so(self):
# Elle a dérivé sans rien casser ENCORE : le taire ferait croire à
# une erreur de détection.
cow, module, _ = finding()
text = tui.render_broken(cow, module, [])
self.assertIn("without", text)
def test_the_two_screens_use_the_same_diff_as_the_cli(self):
# Deux rendus séparés dériveraient sans que rien ne le signale.
import inspect
source = inspect.getsource(tui)
self.assertIn("from reset_stale_cow_views import", source)
self.assertIn("render_diff", source)
class TestTheTriageColumn(unittest.TestCase):
def test_the_weight_counts_both_directions(self):
cow, module, _ = finding()
self.assertEqual(tui.weight(cow, module), "+1/-1")
def test_no_module_twin_has_no_weight(self):
cow, _module, _ = finding()
self.assertEqual(tui.weight(cow, None), "—")
class TestTheRefusalIsNeverSilent(unittest.TestCase):
"""Un refus muet ferait réafficher le rapport texte sans le dire."""
def test_a_pipe_is_explained(self):
import io
from contextlib import redirect_stdout
out = io.StringIO()
with redirect_stdout(out):
result = tui.run_tui([finding()], "db")
self.assertFalse(result)
self.assertTrue(out.getvalue().strip(), "refus muet")
def test_nothing_to_show_stays_silent(self):
import io
from contextlib import redirect_stdout
out = io.StringIO()
with redirect_stdout(out):
self.assertFalse(tui.run_tui([], "db"))
self.assertEqual(out.getvalue(), "")
class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
def test_the_tool_has_a_tui_flag(self):
import subprocess
done = subprocess.run(
[
sys.executable,
"./script/odoo/migration/reset_stale_cow_views.py",
"--help",
],
capture_output=True,
text=True,
cwd=REPO,
)
self.assertEqual(done.returncode, 0)
self.assertIn("--tui", done.stdout)
def test_the_error_prompt_offers_it_as_option_four(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
# éprouve, pas une méthode en particulier.
source = inspect.getsource(
TodoUpgrade.todo_upgrade_execute
) + inspect.getsource(TodoUpgrade._prompt_on_error)
self.assertIn("Browse the differences full screen", source)
self.assertIn('wait_status == "4"', source)
def test_it_gets_a_real_terminal(self):
# Un plein écran lancé par l'exécuteur qui capture retomberait sur
# le rapport texte, sans que rien ne distingue les deux.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
# éprouve, pas une méthode en particulier.
source = inspect.getsource(
TodoUpgrade.todo_upgrade_execute
) + inspect.getsource(TodoUpgrade._prompt_on_error)
start = source.index('wait_status == "4"')
window = source[start : start + 620]
self.assertIn("run_on_terminal", window)
self.assertIn("--tui", window)
if __name__ == "__main__":
unittest.main()

162
test/test_script_permissions.py Executable file
View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un script avec shebang doit être exécutable — dans ce que git STOCKE.
Le bit d'exécution n'accorde rien : qui peut lire le fichier peut déjà faire
« python3 fichier ». Il décide seulement si « ./script/... » fonctionne.
La première version de ce test exigeait aussi l'absence d'écriture par le
groupe, mesurée sur le disque. C'était une faute : git ne stocke QUE le bit
d'exécution — 100644 ou 100755 — et le reste vient de l'umask de celui qui
fait le checkout. Sur un poste en umask 0002, chaque checkout produit 775 et
le test échouait treize fois, pour une raison qui n'est pas dans le dépôt.
On vérifie donc l'index, seul mode que le dépôt porte et propage. Le mode du
disque reste l'affaire de la machine, pas d'un test.
"""
import glob
import os
import subprocess
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PATTERNS = (
"script/analyse/*.py",
"script/analyse/*/*.py",
"script/odoo/migration/*.py",
)
def scripts():
"""(chemin, a un shebang) pour chaque script des dossiers visés."""
found = []
for pattern in PATTERNS:
for path in sorted(glob.glob(os.path.join(REPO, pattern))):
with open(path, "rb") as handle:
found.append((path, handle.read(2) == b"#!"))
return found
def index_mode(path):
"""Le mode que GIT porte pour ce fichier : 100644 ou 100755."""
done = subprocess.run(
["git", "ls-files", "-s", "--", os.path.relpath(path, REPO)],
capture_output=True,
text=True,
cwd=REPO,
)
if done.returncode or not done.stdout.strip():
return None
return done.stdout.split()[0]
class TestExecutableBit(unittest.TestCase):
def test_the_inventory_is_not_empty(self):
# Un test qui ne trouve rien passe toujours.
self.assertGreater(len(scripts()), 10)
def test_every_shebang_script_is_executable_in_git(self):
# Dans l'index, pas sur le disque : c'est ce que reçoivent les
# autres. Un fichier rendu exécutable localement sans être commité
# marcherait ici et nulle part ailleurs.
for path, has_shebang in scripts():
if not has_shebang:
continue
relative = os.path.relpath(path, REPO)
mode = index_mode(path)
if mode is None:
continue # non suivi : rien à garantir pour personne
with self.subTest(script=relative):
self.assertEqual(mode, "100755", relative)
def test_a_file_without_shebang_is_not_marked_executable(self):
# Le symétrique : un bit d'exécution sur un fichier qu'aucun
# interpréteur ne réclame dit une intention qui n'existe pas.
for path, has_shebang in scripts():
if has_shebang:
continue
mode = index_mode(path)
if mode is None:
continue
with self.subTest(script=os.path.relpath(path, REPO)):
self.assertEqual(mode, "100644")
def test_the_check_reads_git_not_the_filesystem(self):
# Le défaut corrigé : l'umask du poste décide de 755 contre 775, et
# un test qui le lit échoue sur la machine des autres. On le vérifie
# par les IMPORTS — chercher un nom de constante dans la source d'un
# fichier qui contient ce test échouerait sur lui-même.
import ast
with open(__file__) as handle:
tree = ast.parse(handle.read())
imported = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module)
self.assertIn("subprocess", imported)
self.assertNotIn("stat", imported)
class TestTheTuiSaysWhyItRefuses(unittest.TestCase):
"""Rendre un script lançable ouvre un chemin sans le venv.
Le shebang est « #!/usr/bin/env python3 » : lancé directement depuis un
shell où le venv n'est pas actif, c'est le python du système qui répond,
et Textual n'y est pas. La TUI retombait alors sur le rapport texte sans
rien dire — le même défaut muet que le tube de sortie.
"""
def run_tui(self):
import sys
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
self.addCleanup(sys.path.remove, sys.path[0])
from cow_drift_tui import run_tui
return run_tui
def finding(self):
return {
"id": 1,
"key": "k",
"website_id": 1,
"reason": "r",
"module_id": 2,
"module_arch": "a",
"copy_arch": "b",
"decl_current": None,
"decl_target": None,
"current_version": "odoo12.0",
"target_version": "odoo13.0",
}
def test_a_pipe_is_explained_not_swallowed(self):
import io
from contextlib import redirect_stdout
out = io.StringIO()
with redirect_stdout(out):
result = self.run_tui()([self.finding()])
self.assertFalse(result)
self.assertTrue(out.getvalue().strip(), "refus muet")
def test_nothing_to_show_stays_silent(self):
# Une liste vide n'est pas un refus : il n'y a rien à annoncer.
import io
from contextlib import redirect_stdout
out = io.StringIO()
with redirect_stdout(out):
self.assertFalse(self.run_tui()([]))
self.assertEqual(out.getvalue(), "")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,918 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ouvrir le back-office comme quelqu'un qui s'y connecte.
Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit RIEN du
back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
champ retiré du modèle mais toujours nommé dans un formulaire, un modèle
dont le code n'accompagne plus la version cible. Rien de tout cela n'arrête
le chargement des modules ; cela arrête le jour où quelqu'un ouvre l'appli.
Ce que ces tests verrouillent est ce que l'exécution réelle a corrigé — et
chaque point ci-dessous a d'abord été un vrai défaut, mesuré sur une base
18.0 de 25 applications :
- `web_search_read` a changé de signature en 17 (`fields` est devenu
`specification`). Seize applications sur vingt-deux échouaient sur MON
appel. `search_read` n'a pas bougé depuis la 12 ;
- un modèle absent du registre rend un « 404 Not Found » nu, illisible,
alors que c'est la trouvaille la plus nette d'une migration ;
- lire les champs d'un arch à l'expression régulière ramasse ceux des
SOUS-VUES : neuf applications rapportées « nommant un champ absent »,
toutes fausses, parce que les lignes d'une facture ne sont pas des
champs de la facture ;
- `run_psql` rend une liste vide aussi bien pour « aucune ligne » que pour
« requête refusée » : un SQL fautif faisait dire « base non neutralisée ».
"""
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
import smoke_internal_ui as ui # noqa: E402
class TestFindingTheFirstPageOfEachApp(unittest.TestCase):
"""Ce que le client web ouvre au clic : le premier menu avec une action."""
MENUS = [
{
"id": 1,
"name": "Ventes",
"parent_id": False,
"sequence": 10,
"action": False,
},
{
"id": 2,
"name": "Commandes",
"parent_id": [1, "Ventes"],
"sequence": 5,
"action": False,
},
{
"id": 3,
"name": "Devis",
"parent_id": [2, "Commandes"],
"sequence": 1,
"action": "ir.actions.act_window,42",
},
{
"id": 4,
"name": "Clients",
"parent_id": [1, "Ventes"],
"sequence": 9,
"action": "ir.actions.act_window,43",
},
{
"id": 5,
"name": "Réglages",
"parent_id": False,
"sequence": 99,
"action": "ir.actions.act_window,7",
},
{
"id": 6,
"name": "Vide",
"parent_id": False,
"sequence": 50,
"action": False,
},
]
def test_it_descends_to_the_first_actionable_menu(self):
trouve = dict(
(app["name"], first["name"] if first else None)
for app, first in ui.apps(self.MENUS)
)
self.assertEqual(trouve["Ventes"], "Devis")
def test_sequence_decides_not_the_id(self):
# « Commandes » (5) passe avant « Clients » (9) : l'ordre affiché
# est celui que l'utilisateur voit, pas l'ordre d'insertion.
trouve = dict(
(app["name"], first["name"] if first else None)
for app, first in ui.apps(self.MENUS)
)
self.assertNotEqual(trouve["Ventes"], "Clients")
def test_an_app_carrying_its_own_action_is_its_own_first_page(self):
trouve = dict(
(app["name"], first["name"] if first else None)
for app, first in ui.apps(self.MENUS)
)
self.assertEqual(trouve["Réglages"], "Réglages")
def test_an_app_without_any_action_is_not_an_error(self):
# Elle n'a rien à ouvrir. La compter comme un échec ferait chercher
# un dégât là où il n'y a qu'un menu de regroupement.
trouve = dict(
(app["name"], first["name"] if first else None)
for app, first in ui.apps(self.MENUS)
)
self.assertIsNone(trouve["Vide"])
def test_a_menu_loop_does_not_hang(self):
# Une base migrée porte parfois des données incohérentes ; un
# parcours naïf tournerait indéfiniment sans rien dire.
boucle = [
{
"id": 1,
"name": "A",
"parent_id": False,
"sequence": 1,
"action": False,
},
{
"id": 2,
"name": "B",
"parent_id": [1, "A"],
"sequence": 1,
"action": False,
},
{
"id": 3,
"name": "C",
"parent_id": [2, "B"],
"sequence": 1,
"action": False,
},
]
boucle[0]["parent_id"] = [3, "C"]
self.assertIsInstance(ui.apps(boucle), list)
class TestReadingTheFieldsOfAPage(unittest.TestCase):
"""Les champs de la page, et RIEN de ce qui appartient aux sous-vues."""
FORM = {
"views": {
"form": {
"arch": (
"<form>"
'<field name="partner_id"/>'
'<field name="line_ids">'
'<list><field name="product_id"/>'
'<field name="price_unit"/></list>'
"</field>"
'<field name="amount_total"/>'
"</form>"
)
}
}
}
def test_the_top_level_fields_are_taken(self):
self.assertEqual(
ui.arch_fields(self.FORM),
["partner_id", "line_ids", "amount_total"],
)
def test_the_fields_of_an_embedded_view_are_NOT(self):
# Le défaut mesuré : `product_id` et `price_unit` sont des champs de
# la LIGNE de facture. Les attribuer à la facture faisait rapporter
# neuf applications cassées qui allaient parfaitement bien.
lst = ui.arch_fields(self.FORM)
self.assertNotIn("product_id", lst)
self.assertNotIn("price_unit", lst)
def test_a_dotted_name_is_left_alone(self):
views = {"a": {"arch": '<list><field name="partner_id.name"/></list>'}}
self.assertEqual(ui.arch_fields(views), [])
def test_an_unreadable_arch_yields_nothing_rather_than_nonsense(self):
views = {"a": {"arch": "<form><field name='x'"}}
self.assertEqual(ui.arch_fields(views), [])
def test_it_finds_arch_wherever_the_version_put_it(self):
# `views` en 16+, `fields_views` avant : chercher à plat évite de
# tenir deux chemins en dur qui dérivent l'un de l'autre.
ancien = {
"fields_views": {
"list": {"arch": '<list><field name="x"/></list>'}
}
}
self.assertEqual(ui.arch_fields(ancien), ["x"])
class TestTheThreeStatesOfTheTestUser(unittest.TestCase):
"""« je ne sais pas » n'est pas « il n'y en a pas ».
Vécu : un « id » ambigu dans MON SQL faisait rendre une liste vide, et
l'outil annonçait tranquillement que la base n'avait pas été
neutralisée. Le back-office n'était pas testé, et rien ne le disait.
"""
def test_a_row_saying_one_is_present(self):
self.assertEqual(
ui.user_state("db", run_psql=lambda d, s: [["1"]]), "present"
)
def test_a_row_saying_zero_is_absent(self):
self.assertEqual(
ui.user_state("db", run_psql=lambda d, s: [["0"]]), "absent"
)
def test_no_row_at_all_is_unknown(self):
self.assertEqual(
ui.user_state("db", run_psql=lambda d, s: []), "unknown"
)
def test_a_psql_that_raises_is_unknown(self):
def explose(database, sql):
raise OSError("psql absent")
self.assertEqual(ui.user_state("db", run_psql=explose), "unknown")
def test_it_counts_rather_than_selects(self):
# Un COUNT rend TOUJOURS une ligne quand la requête aboutit : c'est
# ce qui sépare « aucun utilisateur » de « requête refusée ».
vu = {}
ui.user_state(
"db", run_psql=lambda d, s: vu.setdefault("sql", s) and []
)
self.assertIn("count(", vu["sql"].lower())
class TestTellingRealBreakageFromNoise(unittest.TestCase):
def test_a_bare_404_means_the_model_is_not_registered(self):
# Le message d'Odoo — « 404 Not Found: The requested URL... » — ne
# dit rien. Ce qu'il faut savoir : le module est installé dans la
# base et son code manque à cette version.
self.assertTrue(
ui.model_is_unregistered({"name": "werkzeug.exceptions.NotFound"})
)
def test_an_access_error_is_not_that(self):
self.assertFalse(
ui.model_is_unregistered({"name": "odoo.exceptions.AccessError"})
)
def test_a_missing_method_is_recognised(self):
self.assertTrue(
ui._is_missing_method(
{
"name": "builtins.AttributeError",
"message": "object has no attribute 'load_views'",
}
)
)
def test_a_real_failure_is_not_mistaken_for_one(self):
# Retomber sur l'ancien nom de méthode après une vraie panne
# masquerait la panne derrière un « méthode inconnue » sans rapport.
self.assertFalse(
ui._is_missing_method(
{"name": "odoo.exceptions.AccessError", "message": "refusé"}
)
)
class TestNotEvaluatingWhatComesFromTheDatabase(unittest.TestCase):
"""Un domaine d'action est parfois du Python. On ne l'exécute pas."""
def test_a_literal_domain_is_used(self):
self.assertEqual(
ui.literal("[('a','=',1)]", []), ([("a", "=", 1)], False)
)
def test_an_expression_falls_back_and_SAYS_so(self):
# `uid`, `context_today` : seul le navigateur sait les évaluer. Le
# second membre du couple est ce qui permet de le dire au rapport
# au lieu de faire passer une approximation pour une mesure.
valeur, approx = ui.literal("[('user_id','=',uid)]", [])
self.assertEqual(valeur, [])
self.assertTrue(approx)
def test_an_empty_domain_is_not_an_approximation(self):
self.assertEqual(ui.literal("[]", []), ([], False))
class TestWhatIsWorthOpening(unittest.TestCase):
def test_a_window_action_is_split(self):
self.assertEqual(
ui.split_action("ir.actions.act_window,42"),
("ir.actions.act_window", 42),
)
def test_a_server_action_is_recognised_and_left_alone(self):
session = object()
app = {"id": 1, "name": "X"}
menu = {"id": 2, "name": "Y", "action": "ir.actions.server,9"}
# Une action serveur ÉCRIT : l'exécuter pour « voir si ça marche »
# ferait justement ce qu'un test ne doit pas faire.
result = ui.check_entry(session, app, menu)
self.assertEqual(result["kind"], "ir.actions.server")
self.assertIsNone(result["error"])
self.assertIsNone(result["model"])
def test_qweb_is_not_a_page_to_open(self):
self.assertEqual(
ui.view_pairs("list,qweb,form"), [[False, "list"], [False, "form"]]
)
def test_no_view_mode_still_gives_something(self):
self.assertTrue(ui.view_pairs(None))
class TestLoadingTheFirstPage(unittest.TestCase):
"""L'appel qui charge les enregistrements, et sa forme exacte.
C'EST le défaut qui a coûté le plus cher : `web_search_read` a changé
de signature en 17 — `fields`, une liste, est devenu `specification`,
un dictionnaire. Mesuré sur une base 18.0 : seize applications sur
vingt-deux échouaient sur « unexpected keyword argument 'fields' »,
c'est-à-dire sur MON appel et non sur la base. `search_read` n'a pas
bougé depuis la 12.
"""
def call(self, lst_field=None):
session = ui.Session("http://x")
vu = {}
def faux(model, method, args, kwargs=None):
vu.update(
model=model, method=method, args=args, kwargs=kwargs or {}
)
return [], None
session.call_kw = faux
session.first_page("sale.order", [], {}, 20, lst_field=lst_field)
return vu
def test_it_uses_the_call_that_never_changed(self):
self.assertEqual(self.call(["name"])["method"], "search_read")
def test_the_fields_go_where_search_read_expects_them(self):
vu = self.call(["name", "partner_id"])
self.assertEqual(vu["args"], [[], ["name", "partner_id"]])
self.assertEqual(vu["kwargs"]["limit"], 20)
def test_without_fields_it_still_loads_something(self):
self.assertEqual(self.call()["args"][1], ["display_name"])
def test_the_page_reads_the_columns_it_shows(self):
# Lire `display_name` seul ne prouverait presque rien : ce sont les
# colonnes de la liste qui font travailler l'ORM, et c'est là qu'un
# champ calculé cassé par la migration se manifeste.
import inspect
source = inspect.getsource(ui.check_entry)
self.assertIn("arch_fields(views)", source)
self.assertIn("lst_field=lst_read", source)
def test_a_field_the_model_lacks_is_never_requested(self):
# Le demander ferait échouer la lecture par NOTRE faute, et l'on
# perdrait le vrai signal derrière une erreur qu'on a causée.
source = __import__("inspect").getsource(ui.check_entry)
self.assertIn("if name in lst_known", source)
class TestTheViewsAreRenderedServerSide(unittest.TestCase):
def test_it_tries_the_modern_name_then_the_old_one(self):
# get_views depuis la 16, load_views avant, et la 18 n'a plus que le
# premier : une seule des deux ne couvrirait pas 12→18.
import inspect
source = inspect.getsource(ui.Session.views_of)
self.assertIn("get_views", source)
self.assertIn("load_views", source)
self.assertLess(source.index("get_views"), source.index("load_views"))
def test_the_resolved_name_is_remembered(self):
# Chercher à chaque application doublerait le nombre de requêtes.
session = ui.Session("http://x")
appels = []
def faux(model, method, args, kwargs=None):
appels.append(method)
if method == "get_views":
return None, {
"name": "builtins.AttributeError",
"message": "has no attribute",
}
return {"views": {}}, None
session.call_kw = faux
session.views_of("m", [[False, "list"]])
session.views_of("m", [[False, "list"]])
self.assertEqual(appels, ["get_views", "load_views", "load_views"])
class TestASkipThatHidesAFailure(unittest.TestCase):
"""Tous les sauts ne se valent pas, et c'est mesuré.
L'utilisateur `test` survit aux paliers 12 à 15 puis DISPARAÎT : relevé
sur les six bases d'une vraie migration, présent jusqu'à 15, absent en
17 et 18. La passe back-office s'arrêtait donc sans bruit exactement là
où une migration fait le plus de dégâts, et le rapport disait
tranquillement « la base n'a pas été neutralisée » — ce qui était faux.
`required` porte ce que la migration SAIT : elle a neutralisé cette
base, le compte devrait y être. Son absence devient alors une
trouvaille, pas une formalité.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
import smoke_public_url
self.public = smoke_public_url
original = smoke_public_url.run_psql
self.addCleanup(setattr, smoke_public_url, "run_psql", original)
def phase(self, rows, **kw):
self.public.run_psql = lambda db, sql: rows
return self.public.internal_phase("http://x", "db", enabled=True, **kw)
def test_no_user_on_a_plain_database_is_a_quiet_skip(self):
rapport = self.phase([["0"]])
self.assertIn("not neutralized", rapport["skipped"])
self.assertFalse(rapport.get("loud"))
def test_no_user_on_a_NEUTRALIZED_database_is_a_finding(self):
rapport = self.phase([["0"]], required=True)
self.assertIn("NOT checked", rapport["skipped"])
self.assertTrue(rapport["loud"])
def test_a_finding_counts_as_a_failure(self):
# Sinon le code de sortie annonce que tout va bien alors que le
# back-office n'a jamais été ouvert.
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out):
echec = self.public.render_internal(
{"skipped": "disparu", "loud": True}
)
self.assertTrue(echec)
self.assertIn("NOT browsed", out.getvalue())
def test_a_quiet_skip_does_not(self):
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(
self.public.render_internal({"skipped": "pas neutralisée"})
)
def test_an_unreadable_database_is_never_silent(self):
rapport = self.phase([])
self.assertTrue(rapport["loud"])
def test_a_missing_tool_is_never_silent(self):
# `except ImportError: return None` faisait disparaître la passe
# ENTIÈRE sans un mot, et l'on croyait le back-office testé.
import inspect
source = inspect.getsource(self.public.internal_phase)
debut = source.index("except ImportError")
self.assertIn("loud", source[debut : debut + 400])
def test_the_migration_asks_for_it_when_it_neutralized(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url)
self.assertIn("--internal-required", source)
self.assertIn('"_neutralize" in database_name', source)
def test_the_migration_says_UP_FRONT_what_will_be_browsed(self):
# Un saut annoncé en une ligne à la fin d'un long rapport ne se
# voit pas : on croit alors le back-office testé.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url)
self.assertLess(
source.index("Public pages only:"),
source.index("run_tool("),
)
class TestSayingWHYTheLoginFailed(unittest.TestCase):
"""« Session expired » décrit la conséquence et cache la cause.
Vécu : la passe back-office rapportait « Connexion impossible en tant
que test : SessionExpiredException », et l'on a cherché du côté du mot
de passe pendant que la vraie cause était sous les yeux — /web/login
rendait 500. Une copie COW de `website.submenu` casse la page de
connexion comme elle casse le site : les deux passent par le même
gabarit.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def session(self, status, body):
session = ui.Session("http://x")
session.open = lambda path, data=None, headers=None: (status, body)
return session
def test_a_broken_login_page_is_named_as_such(self):
ok, raison = self.session(500, "<html>oups</html>").log_in(
"db", "test", "test"
)
self.assertFalse(ok)
self.assertIn("login page itself failed", raison)
self.assertIn("500", raison)
def test_it_does_not_blame_the_password(self):
_ok, raison = self.session(500, "<html>oups</html>").log_in(
"db", "test", "test"
)
self.assertNotIn("password", raison.lower())
def test_a_page_without_a_token_is_named_too(self):
# Sans jeton, Odoo refuse le POST et rend la page de connexion avec
# un statut 200 : on croirait à un mot de passe refusé.
ok, raison = self.session(200, "<html>rien</html>").log_in(
"db", "test", "test"
)
self.assertFalse(ok)
self.assertIn("CSRF", raison)
def test_a_dead_server_is_still_distinguished(self):
ok, raison = self.session(0, "").log_in("db", "test", "test")
self.assertFalse(ok)
self.assertIn("did not serve", raison)
class TestTheBackOfficeIsJudgedAfterTheRepair(unittest.TestCase):
"""La passe interne tournait AVANT la réinitialisation.
Elle rapportait donc « connexion impossible » sur une base que la
réparation remettait d'aplomb quelques secondes plus tard — et rien ne
corrigeait le rapport. Les URL, elles, étaient déjà revérifiées.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_a_failed_pass_deserves_a_second_look(self):
import smoke_public_url as public
self.assertTrue(public.internal_needs_retry({"failures": [{"x": 1}]}))
def test_a_SKIPPED_pass_too(self):
# « connexion impossible » est justement le symptôme d'un site
# cassé, c'est-à-dire de ce qu'on vient de réparer.
import smoke_public_url as public
self.assertTrue(public.internal_needs_retry({"skipped": "raison"}))
def test_a_healthy_pass_is_not_redone(self):
import smoke_public_url as public
self.assertFalse(
public.internal_needs_retry({"results": [1], "failures": []})
)
def test_no_pass_at_all_is_not_redone(self):
import smoke_public_url as public
self.assertFalse(public.internal_needs_retry(None))
def test_the_retry_happens_while_the_second_server_is_up(self):
import inspect
import smoke_public_url as public
source = inspect.getsource(public.recheck_after_reset)
self.assertLess(
source.index("internal_needs_retry"),
source.index("stop_server(server)"),
)
def test_the_report_says_it_was_a_second_look(self):
import contextlib
import io
import smoke_public_url as public
out = io.StringIO()
with contextlib.redirect_stdout(out):
public.render_internal(
{"skipped": "raison", "loud": True, "retried": True}
)
self.assertIn("after the reset", out.getvalue())
class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
"""Mesuré en diagnostiquant ce même incident, et je m'y suis pris.
Un checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
trente-sept URL ET sur /web/login. On conclut à un site entièrement
cassé alors que rien ne l'est — et Odoo écrit en chemin.
"""
def test_the_smoke_tool_has_the_guard(self):
import inspect
import smoke_public_url as public
source = inspect.getsource(public.run)
self.assertIn("require_matching_version", source)
self.assertLess(
source.index("require_matching_version"),
source.index("start_server("),
)
def test_the_internal_tool_has_it_too(self):
import inspect
source = inspect.getsource(ui.main)
self.assertIn("require_matching_version", source)
def test_the_guard_is_not_reimplemented(self):
# Deux implémentations divergeraient, et une garde qui diverge ne
# garde rien.
import inspect
import smoke_public_url as public
source = inspect.getsource(public.require_matching_version)
self.assertIn("from database_cleanup import", source)
class TestThePortalPage(unittest.TestCase):
"""/my n'est ni le site public ni le back-office : c'est un troisième
rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur
modèle. Le sitemap ne le liste pas — la page demande une session — et
aucun appel RPC ne passe par là. Une migration peut donc le casser sans
que rien ailleurs ne le dise.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def session(self, status, body, url=None):
session = ui.Session("http://x")
session.last_url = url or ("http://x/my")
def faux(path, data=None, headers=None):
return status, body
session.open = faux
return session
def test_a_page_that_answers_is_a_success(self):
item = ui.check_portal(
self.session(200, "<html>Mon compte</html>"), "/my"
)
self.assertIsNone(item["error"])
self.assertEqual(item["status"], 200)
def test_a_five_hundred_is_a_failure(self):
item = ui.check_portal(self.session(500, "<html>oups</html>"), "/my")
self.assertEqual(item["stage"], ui.PORTAL_KIND)
self.assertIn("500", item["error"]["name"])
def test_a_dead_connection_is_a_failure(self):
item = ui.check_portal(self.session(0, "connexion refusée"), "/my")
self.assertIsNotNone(item["error"])
def test_a_login_form_served_with_200_is_NOT_a_success(self):
# MESURÉ : /my demandé sans session redirige vers
# /web/login?redirect=/my et rend 200. Compter cela comme une
# réussite serait le mensonge le plus coûteux du lot — on
# annoncerait un portail sain sans l'avoir jamais vu.
page = '<form><input name="login"/><input name="password"/></form>'
item = ui.check_portal(self.session(200, page), "/my")
self.assertIsNotNone(item["error"])
def test_a_redirect_to_the_login_is_caught_even_without_the_form(self):
item = ui.check_portal(
self.session(
200, "<html>rien</html>", url="http://x/web/login?redirect=/my"
),
"/my",
)
self.assertIsNotNone(item["error"])
def test_the_word_login_alone_does_not_condemn_a_page(self):
# Le mot est partout dans le portail. N'exiger que lui rejetterait
# des pages parfaitement saines.
page = "<html>Votre login est test. Bienvenue.</html>"
item = ui.check_portal(self.session(200, page), "/my")
self.assertIsNone(item["error"])
def test_the_error_page_is_read_without_its_stylesheet(self):
# MESURÉ : « Internal Server Error html { font-size: 14px; } Home
# Back 500 » — du CSS présenté comme un diagnostic.
page = (
"<html><style>html { font-size: 14px; }</style>"
"<body>500: Internal Server Error</body></html>"
)
self.assertNotIn("font-size", ui.error_from_page(page))
def test_it_is_checked_before_the_apps(self):
# L'ordre n'est pas indifférent : si la session est perdue, tout le
# reste échouera aussi, et l'on veut la cause en tête du rapport.
import inspect
source = inspect.getsource(ui.crawl)
self.assertLess(
source.index("check_portal"), source.index("check_entry")
)
def test_asking_for_no_portal_path_checks_none(self):
session = self.session(200, "ok")
session.call_kw = lambda *a, **kw: ([], None)
lst_result, _ = ui.crawl(session, lst_portal=[])
self.assertEqual(
[x for x in lst_result if x["kind"] == ui.PORTAL_KIND], []
)
def test_the_default_path_is_my(self):
self.assertEqual(ui.DEFAULT_PORTAL_PATHS, ("/my",))
class TestBlamingTheRightRequest(unittest.TestCase):
"""Une cause fausse coûte plus cher qu'une cause absente.
Vécu : le portail est interrogé en PREMIER, donc sa trace est la
première du journal. En prenant simplement la dernière, on accusait /my
d'une panne appartenant à une application testée bien après — et l'on
aurait cherché des heures du mauvais côté, avec confiance.
"""
JOURNAL = [
"AttributeError: 'res.users' object has no attribute 'portal_360'",
'... werkzeug: "GET /my HTTP/1.1" 500 - 8 0.004 0.029',
"ValueError: Invalid field project.project.is_project_br",
'... werkzeug: "POST /web/dataset/call_kw HTTP/1.1" 200 - 3 0.01',
]
def test_the_trace_of_that_very_request_is_taken(self):
self.assertIn("portal_360", ui.reason_for_path(self.JOURNAL, "/my"))
def test_the_trace_of_another_request_is_NOT(self):
self.assertNotIn(
"is_project_br", ui.reason_for_path(self.JOURNAL, "/my") or ""
)
def test_a_healthy_answer_wipes_the_slate(self):
journal = [
"ValueError: sans rapport",
'... werkzeug: "GET /my HTTP/1.1" 200 - 8 0.004',
]
self.assertIsNone(ui.reason_for_path(journal, "/my"))
def test_a_redirected_path_still_matches(self):
# /my renvoie vers /my/home : la trace est loggée sous le second.
journal = [
"KeyError: compteur",
'... werkzeug: "GET /my/home HTTP/1.1" 500 - 8 0.004',
]
self.assertIn("KeyError", ui.reason_for_path(journal, "/my"))
def test_a_similar_path_is_not_confused_with_it(self):
journal = [
"KeyError: ailleurs",
'... werkzeug: "GET /myaccount HTTP/1.1" 500 - 8 0.004',
]
self.assertIsNone(ui.reason_for_path(journal, "/my"))
def test_an_rpc_message_is_never_overwritten(self):
# Un appel RPC nomme précisément SON erreur ; le journal est un flux
# partagé. Écraser le premier par le second serait perdre la seule
# information sûre.
item = ui.blank_entry("Ventes", "Devis", kind="ir.actions.act_window")
item["error"] = {"name": "KeyError", "message": "précis"}
ui.attach_log_reason(
[item], ["ValueError: du journal", '"GET /my" 500']
)
self.assertNotIn("log", item["error"])
def test_the_server_is_asked_to_log_its_requests(self):
# Sans les lignes d'accès, aucune trace ne peut être rattachée à un
# chemin : elles coûtent deux lignes par requête.
import inspect
import smoke_public_url as public
self.assertIn("werkzeug:INFO", inspect.getsource(public.start_server))
class TestTheReport(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def entry(self, **kw):
item = {
"app": "Sales",
"menu": "Quotations",
"model": "sale.order",
"kind": "ir.actions.act_window",
"error": None,
"stage": None,
"domain_ignored": False,
"unknown_fields": [],
"fields_read": 3,
}
item.update(kw)
return item
def test_all_clean_says_so(self):
text = ui.render([self.entry()], [])
self.assertIn("1/1", text)
self.assertIn("opened its first page", text)
def test_a_registry_failure_explains_itself(self):
# Le 404 nu est illisible : le rapport doit dire ce qu'il signifie.
item = self.entry(
stage="registry",
error={"name": "werkzeug.exceptions.NotFound", "message": "404"},
)
text = ui.render([item], [item])
self.assertIn("addons path", text)
self.assertNotIn("The requested URL", text)
def test_ghost_fields_are_reported_without_being_a_failure(self):
# Une vue qui nomme un champ disparu n'empêche pas toujours la page
# de s'ouvrir. Le taire laisserait passer la cause d'une panne à
# venir ; le compter comme un échec ferait crier au loup.
item = self.entry(unknown_fields=["grant_offer_id"])
text = ui.render([item], [])
self.assertIn("grant_offer_id", text)
self.assertIn("1/1", text)
def test_an_approximate_domain_is_admitted(self):
text = ui.render([self.entry(domain_ignored=True)], [])
self.assertIn("empty domain", text)
def test_the_report_says_the_public_test_cannot_see_this(self):
item = self.entry(
stage="records", error={"name": "KeyError", "message": "x"}
)
text = ui.render([item], [item])
self.assertIn(
"back-office", text.lower().replace("back office", "back-office")
)
class TestItRidesTheServerAlreadyRunning(unittest.TestCase):
"""Le démarrage d'Odoo coûte des minutes ; les requêtes, non."""
def test_the_public_tool_runs_it_in_the_same_session(self):
import inspect
import smoke_public_url as public
source = inspect.getsource(public.run)
debut = source.index("check_urls(")
arret = source.index("stop_server(server)")
self.assertLess(debut, source.index("internal_phase("))
self.assertLess(source.index("internal_phase("), arret)
def test_an_unknown_state_is_not_reported_as_healthy(self):
import smoke_public_url as public
rapport = public.internal_phase("http://x", "db", enabled=True)
self.assertIsNotNone(rapport)
def test_disabling_it_returns_nothing(self):
import smoke_public_url as public
self.assertIsNone(
public.internal_phase("http://x", "db", enabled=False)
)
if __name__ == "__main__":
unittest.main()

511
test/test_smoke_public_url.py Executable file
View file

@ -0,0 +1,511 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une migration peut réussir et servir quand même des 500.
Mesuré sur une vraie migration 12 → 13 : tous les modules chargés, aucune
erreur au journal, et pourtant deux pages publiques sur trente-trois
répondaient 500 — un billet de blogue et /contactus. Rien ne les distinguait
avant de les demander.
La liste vient du sitemap, celle qu'Odoo publie pour les moteurs de
recherche. Elle ne se lit PAS depuis « odoo-bin shell » : enumerate_pages()
réclame http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. D'où le serveur démarré, interrogé, arrêté.
"""
import os
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
import smoke_public_url as smoke # noqa: E402
class TestTheUrlAreBroughtHome(unittest.TestCase):
"""Le sitemap porte le domaine du site ; on teste une base locale."""
def test_the_host_is_replaced(self):
# Garder le domaine ferait interroger la PRODUCTION depuis une
# migration. C'est le genre d'erreur qui ne se voit qu'après.
url = smoke.local_url(
"http://127.0.0.1:8169", "https://technolibre.ca/blog/x-3/post/y-5"
)
self.assertEqual(url, "http://127.0.0.1:8169/blog/x-3/post/y-5")
def test_a_query_string_survives(self):
url = smoke.local_url("http://127.0.0.1:8169", "https://a.ca/p?x=1")
self.assertEqual(url, "http://127.0.0.1:8169/p?x=1")
def test_a_bare_domain_becomes_the_root(self):
url = smoke.local_url("http://127.0.0.1:8169", "https://a.ca")
self.assertEqual(url, "http://127.0.0.1:8169/")
def test_a_trailing_slash_on_the_base_does_not_double(self):
url = smoke.local_url("http://127.0.0.1:8169/", "https://a.ca/p")
self.assertEqual(url, "http://127.0.0.1:8169/p")
class TestReadingTheSitemap(unittest.TestCase):
def setUp(self):
self.answers = {}
self.original = smoke.fetch
smoke.fetch = lambda url, timeout=30: self.answers.get(url, (404, ""))
self.addCleanup(setattr, smoke, "fetch", self.original)
def test_a_plain_sitemap(self):
self.answers["http://h/sitemap.xml"] = (
200,
"<urlset><loc>https://a.ca/x</loc><loc>https://a.ca/y</loc></urlset>",
)
lst_url, status = smoke.sitemap_urls("http://h")
self.assertEqual(status, 200)
self.assertEqual(lst_url, ["http://h/x", "http://h/y"])
def test_an_index_is_followed(self):
# Odoo découpe le sitemap au-delà d'un certain nombre de pages : ne
# pas descendre d'un cran ferait tester deux fichiers XML au lieu du
# site, et conclure « tout va bien ».
self.answers["http://h/sitemap.xml"] = (
200,
"<sitemapindex><loc>https://a.ca/sitemap-1-1.xml</loc>"
"</sitemapindex>",
)
self.answers["http://h/sitemap-1-1.xml"] = (
200,
"<urlset><loc>https://a.ca/deep</loc></urlset>",
)
lst_url, _status = smoke.sitemap_urls("http://h")
self.assertEqual(lst_url, ["http://h/deep"])
def test_duplicates_are_dropped(self):
self.answers["http://h/sitemap.xml"] = (
200,
"<urlset><loc>https://a.ca/x</loc><loc>https://a.ca/x</loc></urlset>",
)
lst_url, _status = smoke.sitemap_urls("http://h")
self.assertEqual(lst_url, ["http://h/x"])
def test_an_unreachable_sitemap_is_reported_not_taken_as_empty(self):
# Sans cela, un sitemap injoignable se lirait comme « aucune page à
# tester », c'est-à-dire comme un succès.
lst_url, status = smoke.sitemap_urls("http://h")
self.assertEqual(lst_url, [])
self.assertEqual(status, 404)
class TestWhatCountsAsAFailure(unittest.TestCase):
def setUp(self):
self.answers = {}
self.original = smoke.fetch
smoke.fetch = lambda url, timeout=30: self.answers.get(url, (200, ""))
self.addCleanup(setattr, smoke, "fetch", self.original)
def test_a_500_fails(self):
self.answers["http://h/bad"] = (500, "")
self.assertEqual(
smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500, [])]
)
def test_a_404_fails_too(self):
# Le sitemap est la liste PUBLIQUE : une page qui y figure et répond
# 404 est aussi fausse qu'un 500, juste plus discrète.
self.answers["http://h/gone"] = (404, "")
self.assertEqual(len(smoke.check_urls(["http://h/gone"])), 1)
def test_no_answer_at_all_fails(self):
self.answers["http://h/dead"] = (0, "")
self.assertEqual(
smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0, [])]
)
def test_a_200_passes(self):
self.assertEqual(smoke.check_urls(["http://h/ok"]), [])
class TestTheReport(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_all_green_says_how_many(self):
text = smoke.render(["a", "b"], [])
self.assertIn("2", text)
self.assertIn("✅", text)
def test_a_failure_shows_the_status_and_the_url(self):
text = smoke.render(["a"], [("http://h/blog/x", 500, [])])
self.assertIn("500", text)
self.assertIn("http://h/blog/x", text)
def test_an_empty_sitemap_is_not_a_success(self):
# « rien testé » ne doit pas se lire « rien de cassé ».
text = smoke.render([], [])
self.assertIn("⚠️", text)
class TestTheServerIsAlwaysStopped(unittest.TestCase):
"""Un serveur oublié tient le port et fait échouer le palier suivant."""
def test_it_is_terminated_even_when_the_sitemap_fails(self):
stopped = []
class FakeServer:
def terminate(self):
stopped.append("terminate")
def wait(self, timeout=None):
return 0
def kill(self):
stopped.append("kill")
original_start = smoke.start_server
original_wait = smoke.wait_ready
original_stop = smoke.stop_server
original_port = smoke.port_is_taken
smoke.start_server = (
lambda db, port, cfg="./config.conf", log_path=None: FakeServer()
)
smoke.wait_ready = lambda base, timeout=180, sleep=2: False
smoke.stop_server = lambda server: stopped.append("terminate")
smoke.port_is_taken = lambda port, host="127.0.0.1": False
self.addCleanup(setattr, smoke, "start_server", original_start)
self.addCleanup(setattr, smoke, "wait_ready", original_wait)
self.addCleanup(setattr, smoke, "stop_server", original_stop)
self.addCleanup(setattr, smoke, "port_is_taken", original_port)
with self.assertRaises(RuntimeError):
smoke.run("db", 8169, "./config.conf")
self.assertEqual(stopped, ["terminate"])
def test_a_distinct_port_by_default(self):
# 8069 est souvent pris par l'instance de travail : le lui voler
# ferait échouer le test pour une raison sans rapport.
self.assertNotEqual(smoke.DEFAULT_PORT, 8069)
class TestTheCulpritViewsAreNamed(unittest.TestCase):
"""Détecter sans nommer laisse relever des ids dans une trace à la main."""
def test_the_parent_is_read_from_the_error_context(self):
# C'est le PARENT le coupable : la copie figée dans laquelle
# l'enfant ne trouve plus son xpath.
line = "[view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281]"
self.assertEqual(smoke.RE_CONTEXT.findall(line), [("3282", "3281")])
def test_the_context_line_is_not_translated(self):
# La phrase qui précède l'est, celle-ci non : la lire marche donc
# sur un système français comme anglais.
self.assertEqual(
smoke.RE_CONTEXT.findall("[view_id: 1, parent_id: 2]"),
[("1", "2")],
)
def test_a_late_context_is_still_attached(self):
# Odoo vide son tampon à l'arrêt : le journal se lit APRÈS, et rien
# ne doit dépendre du moment où la ligne est apparue.
lst_failure = [("http://h/a", 500, [])]
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertIn("2841", rebuilt[0][2])
def test_both_the_parent_and_the_child_are_proposed(self):
# LE défaut mesuré sur /contactus : le parent (3281) était IDENTIQUE
# à sa vue module, et c'est l'enfant (3282) qui portait l'arch
# périmée. Ne nommer que le parent envoyait réinitialiser une copie
# qui allait déjà bien, et la page restait en 500.
lst_failure = [("http://h/contactus", 500, [])]
log = ["[view_id: 3282, model: n/a, parent_id: 3281]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertEqual(rebuilt[0][2], ["3281", "3282"])
def test_the_parent_comes_first(self):
# C'est le cas le plus fréquent — le blogue — donc en tête de liste.
lst_failure = [("http://h/a", 500, [])]
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertEqual(rebuilt[0][2][0], "2841")
def test_an_already_attributed_id_is_not_duplicated(self):
lst_failure = [("http://h/a", 500, ["2841"])]
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertEqual(rebuilt[0][2].count("2841"), 1)
class TestARenderFailureNamesItsTemplate(unittest.TestCase):
"""Une QWebException ne porte pas de bloc [view_id …] : elle nomme le
gabarit.
Mesuré au palier 17 : une copie figée appelait `submenu.clean_url()`,
méthode renommée `_clean_url()` par la version. 34 URL sur 37 rendaient
500, et l'outil répondait « aucune vue parente nommée » — il ne lisait
que le contexte d'héritage, absent ici.
"""
def test_the_template_line_is_read(self):
self.assertEqual(
smoke.RE_TEMPLATE.findall("Template: website.submenu"),
["website.submenu"],
)
def test_prose_is_not_mistaken_for_a_key(self):
# « Template: » suivi d'une phrase n'est pas un gabarit.
self.assertEqual(
smoke.RE_TEMPLATE.findall("Template: not a key, just words"), []
)
def test_only_keys_with_a_copy_are_proposed(self):
# Proposer une clé sans copie COW enverrait réinitialiser une vue
# module : la commande tournerait sans rien faire.
seen = {}
original = smoke.run_psql
smoke.run_psql = lambda db, sql: (
seen.update(sql=sql),
[["website.submenu"]],
)[1]
self.addCleanup(setattr, smoke, "run_psql", original)
got = smoke.template_keys(
["Template: website.submenu", "Template: website.layout"], "db"
)
self.assertEqual(got, ["website.submenu"])
self.assertIn("website_id IS NOT NULL", seen["sql"])
def test_no_template_line_asks_nothing_of_the_database(self):
called = []
original = smoke.run_psql
smoke.run_psql = lambda db, sql: called.append(sql) or []
self.addCleanup(setattr, smoke, "run_psql", original)
self.assertEqual(smoke.template_keys(["rien ici"], "db"), [])
self.assertEqual(called, [])
def test_a_quote_in_a_key_is_escaped(self):
seen = {}
original = smoke.run_psql
smoke.run_psql = lambda db, sql: (seen.update(sql=sql), [])[1]
self.addCleanup(setattr, smoke, "run_psql", original)
smoke.template_keys(["Template: a.b"], "db")
self.assertIn("'a.b'", seen["sql"])
def test_both_sources_feed_the_proposal(self):
# Le contexte d'héritage quand il existe, le gabarit quand l'échec
# vient du rendu : l'un ne remplace pas l'autre.
import inspect
source = inspect.getsource(smoke.run)
self.assertIn("culprit_keys(", source)
self.assertIn("template_keys(", source)
class TestTheServerIsKilledForReal(unittest.TestCase):
"""« ./run.sh » est un script bash : il ne transmet pas les signaux."""
def test_it_starts_its_own_process_group(self):
# Sans cela, terminate() tue l'enveloppe et laisse odoo-bin vivant.
# Mesuré : six serveurs orphelins, un par essai, et les essais
# suivants interrogeaient sans le savoir celui d'avant.
import inspect
source = inspect.getsource(smoke.start_server)
self.assertIn("start_new_session=True", source)
def test_it_kills_the_group_not_just_the_wrapper(self):
import inspect
source = inspect.getsource(smoke.stop_server)
self.assertIn("killpg", source)
def test_a_taken_port_is_refused_not_tested(self):
# Un serveur resté d'un essai précédent répond « prêt » : on
# testerait sa base à lui en croyant tester la sienne.
original = smoke.port_is_taken
smoke.port_is_taken = lambda port, host="127.0.0.1": True
self.addCleanup(setattr, smoke, "port_is_taken", original)
with self.assertRaises(RuntimeError) as caught:
smoke.run("db", 8169, "./config.conf")
self.assertIn("8169", str(caught.exception))
class TestOfferingTheFix(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
self.applied = []
original = smoke.apply_reset
smoke.apply_reset = lambda db, keys: (
self.applied.append(keys),
(0, "ok"),
)[1]
self.addCleanup(setattr, smoke, "apply_reset", original)
def run_prompt(self, answer, lst_key=None):
import contextlib
import io
if lst_key is None:
lst_key = ["website_blog.blog_post_complete", "website_form.x"]
out = io.StringIO()
with contextlib.redirect_stdout(out):
done = smoke.prompt(
"db",
[("http://h/a", 500, ["2841"])],
lst_key,
ask=lambda prompt: answer,
)
return done, out.getvalue()
def test_every_key_is_numbered(self):
_done, text = self.run_prompt("")
self.assertIn("[1] website_blog.blog_post_complete", text)
self.assertIn("[a]", text)
def test_a_number_applies_only_that_one(self):
done, _text = self.run_prompt("1")
self.assertEqual(done, ["website_blog.blog_post_complete"])
self.assertEqual(self.applied, [["website_blog.blog_post_complete"]])
def test_a_applies_them_all(self):
done, _text = self.run_prompt("a")
self.assertEqual(len(done), 2)
def test_empty_applies_nothing(self):
done, text = self.run_prompt("")
self.assertEqual(done, [])
self.assertEqual(self.applied, [])
self.assertIn("Kept", text)
def test_an_unknown_answer_applies_nothing(self):
done, text = self.run_prompt("9")
self.assertEqual(self.applied, [])
self.assertIn("Unknown choice", text)
def test_no_key_found_says_so(self):
done, text = self.run_prompt("a", lst_key=[])
self.assertEqual(done, [])
self.assertIn("nothing to offer", text)
class TestTheMigrationOffersIt(unittest.TestCase):
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def run_prompt(self, answer):
from script.todo.todo_upgrade import TodoUpgrade
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
# Le doublon HONORE le défaut, comme le vrai `ask_gate`.
upgrade.ask_gate = lambda prompt, default="": answer or default
upgrade.prompt_smoke_public_url("db_upgrade_13")
return lst_cmd
def test_the_default_runs_it(self):
# Une page cassée qu'on ne demande pas reste cassée. Le coût est
# quelques minutes de serveur ; le prix de l'ignorer est de
# découvrir le 500 six paliers plus loin.
lst_cmd = self.run_prompt("")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("smoke_public_url.py", lst_cmd[0])
def test_saying_no_still_skips_it(self):
self.assertEqual(self.run_prompt("n"), [])
def test_yes_runs_it_on_the_upgraded_database(self):
lst_cmd = self.run_prompt("y")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("smoke_public_url.py", lst_cmd[0])
self.assertIn("-d db_upgrade_13", lst_cmd[0])
def test_it_is_also_asked_before_the_first_bump(self):
# LA mesure de départ : sans elle, une page qui rendait déjà 500
# avant la migration se lit comme un dégât du palier, et l'on
# cherche des heures du mauvais côté.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertEqual(source.count("prompt_smoke_public_url"), 2)
premier = source.index("prompt_smoke_public_url")
self.assertLess(
premier, source.index("4 - Upgrade version with OpenUpgrade")
)
def test_the_baseline_runs_on_the_database_before_the_bump(self):
# Sur la base d'AVANT, pas sur une base de palier qui n'existe pas
# encore : la mesurer après ne dirait plus d'où vient la panne.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
premier = source.index("prompt_smoke_public_url")
fenetre = source[premier : premier + 90]
self.assertIn("database_name", fenetre)
self.assertNotIn("database_name_upgrade", fenetre)
self.assertIn("baseline=True", fenetre)
def test_the_baseline_says_what_it_measures(self):
# Un même écran à deux moments différents : sans un mot, on croit
# que la migration a déjà eu lieu.
import contextlib
import io
from script.todo import todo_i18n
from script.todo.todo_upgrade import TodoUpgrade
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
upgrade.run_on_terminal = lambda cmd: 0
upgrade.ask_gate = lambda prompt, default="": "n"
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_smoke_public_url("db", baseline=True)
self.assertIn("Before starting", out.getvalue())
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_smoke_public_url("db")
self.assertNotIn("Before starting", out.getvalue())
def test_it_is_asked_before_the_selenium_prompt(self):
# Après, la question arriverait une fois le navigateur ouvert : on
# aurait déjà cherché à la main ce que le test nomme.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
self.assertLess(
source.index("prompt_smoke_public_url"),
source.index("Open the server with Selenium"),
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,196 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""« Vers quelle version migrer ? » : Entrée doit viser la plus haute.
C'est le choix qu'on fait presque toujours — migrer, c'est aller au bout — et
il fallait le taper. `click.prompt` reçoit donc un défaut : il affiche
« [6] » et rend « 6 » sur Entrée, si bien que la réponse suit le chemin
normal, sans second comportement à tenir d'accord avec le premier.
Deux pièges, tous deux vérifiés ici :
- SANS `default`, click redemande en silence sur une ligne vide. Une valeur
par défaut ajoutée sans lui n'aurait jamais été atteinte, et rien ne
l'aurait signalé.
- « 6 » est le rang de 18.0 AUJOURD'HUI. Une version de plus au catalogue le
déplace ; écrit en dur, il ferait choisir 18.0 quand l'écran propose 19.0.
"""
import ast
import inspect
import os
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
def prompt_call(source):
"""Le voisinage de l'invite de version, où se joue le défaut.
Ce n'est plus click.prompt : lui ne sait pas rendre la main après un
délai, et le mode auto se serait arrêté à la première question.
"""
start = source.index("Which version do you want to upgrade to?")
return source[start : start + 1400]
class TestTheSource(unittest.TestCase):
"""Le choix se joue dans une boucle interactive de mille lignes.
On vérifie donc les décisions là où elles s'écrivent : que click reçoive
un défaut, qu'il soit calculé, et que la question dise la version.
"""
def source(self):
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_the_prompt_receives_a_default(self):
window = prompt_call(self.source())
self.assertIn("default=", window)
self.assertIn("default_index", window)
def test_it_goes_through_the_auto_aware_reader(self):
# `self.ask` porte le mode auto ; click.prompt bloquerait pour
# toujours sur une invite que personne ne vient servir.
window = prompt_call(self.source())
self.assertIn("self.ask(", window)
self.assertNotIn("click.prompt(", window)
def test_the_default_is_not_written_by_hand(self):
window = prompt_call(self.source())
self.assertNotIn('default="6"', window)
self.assertNotIn("default=6", window)
def test_the_rank_is_computed_by_comparison(self):
# Lire le dernier élément marcherait par chance : l'ordre
# d'affichage n'est pas une garantie.
source = self.source()
self.assertIn("max(", source)
self.assertIn("range(len(lst_odoo_version))", source)
def test_an_empty_catalogue_shows_no_default(self):
# Afficher « [] » se lirait comme un choix possible : la marque
# n'est posée que s'il y a vraiment un défaut.
self.assertIn(
'marque = f" [{default_index}]" if default_index else ""',
prompt_call(self.source()),
)
def test_the_question_announces_the_version_not_the_rank(self):
# click montre « [6] » ; seul le texte dit que c'est 18.0.
source = self.source()
self.assertIn("Enter =", source)
self.assertIn("default_version", source)
def test_an_empty_answer_still_takes_the_default(self):
# click rendait « 6 » lui-même ; `self.ask` ne le fait qu'en mode
# AUTO. Hors mode auto, Entrée rend une chaîne vide, et sans ce cas
# le défaut ne servirait que la moitié du temps.
window = prompt_call(self.source())
self.assertIn("if not str(status).strip()", window)
class TestTheRankItself(unittest.TestCase):
"""Le calcul du défaut, rejoué sur des catalogues réels."""
def rank(self, lst_version):
"""Le rang affiché de la plus haute version, comme le code le fait."""
lst_odoo_version = [{"prompt_description": v} for v in lst_version]
if not lst_odoo_version:
return None
return (
max(
range(len(lst_odoo_version)),
key=lambda i: float(
lst_odoo_version[i].get("prompt_description")
),
)
+ 1
)
def test_the_case_reported(self):
# Base en 12.0 : la liste proposée va de 13.0 à 18.0, donc [6].
self.assertEqual(
self.rank(["13.0", "14.0", "15.0", "16.0", "17.0", "18.0"]), 6
)
def test_it_follows_a_new_version(self):
# LE point : le jour où 19.0 entre au catalogue, le défaut devient
# [7] sans que personne ne touche au code.
self.assertEqual(
self.rank(
["13.0", "14.0", "15.0", "16.0", "17.0", "18.0", "19.0"]
),
7,
)
def test_an_unsorted_list_points_at_the_highest(self):
self.assertEqual(self.rank(["18.0", "13.0", "16.0"]), 1)
def test_versions_compare_as_numbers(self):
# « 9.0 » se trie après « 18.0 » en chaînes : la comparaison
# numérique est ce qui évite de proposer une version antérieure.
self.assertEqual(self.rank(["9.0", "18.0"]), 2)
def test_a_single_choice(self):
self.assertEqual(self.rank(["18.0"]), 1)
def test_no_choice_at_all_has_no_rank(self):
# Une base déjà à la version maximale : pas de défaut plutôt qu'une
# erreur là où il n'y a rien à faire.
self.assertIsNone(self.rank([]))
class TestClickReallyReturnsIt(unittest.TestCase):
"""Ce que click fait vraiment, mesuré et non supposé."""
def answer(self, typed, default):
import io
import sys
import click
original = sys.stdin
sys.stdin = io.StringIO(typed)
try:
return click.prompt(
"", default=default, show_default=True, prompt_suffix=""
)
finally:
sys.stdin = original
def test_enter_returns_the_default(self):
self.assertEqual(self.answer("\n", "6"), "6")
def test_a_typed_value_wins(self):
self.assertEqual(self.answer("2\n", "6"), "2")
def test_without_a_default_enter_is_swallowed(self):
# La raison d'être du `default` : ici click redemande, et c'est la
# SECONDE ligne qui revient. Entrée n'aurait rien déclenché.
import io
import sys
import click
original = sys.stdin
sys.stdin = io.StringIO("\n2\n")
try:
self.assertEqual(click.prompt("", prompt_suffix=""), "2")
finally:
sys.stdin = original
class TestTheModuleStillParses(unittest.TestCase):
def test_it_parses(self):
path = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
with open(path) as handle:
ast.parse(handle.read())
if __name__ == "__main__":
unittest.main()

184
test/test_theme_on_error.py Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Proposer de retirer un thème SEULEMENT quand il est en cause.
La propriété qui porte tout : deux conditions, jamais une seule. Un
thème doit être installé — sinon il n'y a rien à retirer — ET son nom
doit figurer dans ce que la commande vient d'écrire. Offrir la
désinstallation à chaque échec reviendrait à proposer de casser le
design du site pour une panne qui n'a rien à voir.
La question était déjà posée à l'étape 1, une fois, et un drapeau
l'empêchait de revenir. Or un thème devient incompatible à un PALIER
précis, des heures plus tard : la poser au départ ne pouvait pas suffire.
"""
import io
import os
import sys
import tempfile
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.todo import todo_i18n # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class TestTheDetection(unittest.TestCase):
def setUp(self):
self.obj = TodoUpgrade.__new__(TodoUpgrade)
self.dossier = tempfile.mkdtemp()
self.obj.log_dir = lambda: self.dossier
self.obj.current_step = "4.5.J - Migrate database"
self.obj.step_slug = staticmethod(lambda msg: "etape")
self.obj.installed_theme = lambda base: ["theme_buzzy"]
def journal(self, texte):
with io.open(
os.path.join(self.dossier, "etape.log"), "w", encoding="utf-8"
) as handle:
handle.write(texte)
def test_a_theme_named_by_the_error_is_blamed(self):
self.journal(
"ERROR ... /addons/odoo_design-themes/theme_buzzy/data/x.xml"
)
self.assertEqual(
self.obj.theme_blamed_by_the_error("db"), ["theme_buzzy"]
)
def test_an_error_that_never_mentions_it_blames_nothing(self):
# C'est LA protection : une erreur de compte ne doit pas faire
# proposer d'effacer le design du site.
self.journal("ERROR relation discuss_channel does not exist")
self.assertEqual(self.obj.theme_blamed_by_the_error("db"), [])
def test_no_theme_installed_blames_nothing(self):
self.obj.installed_theme = lambda base: []
self.journal("theme_buzzy partout dans le journal")
self.assertEqual(self.obj.theme_blamed_by_the_error("db"), [])
def test_no_theme_installed_never_even_reads_the_log(self):
# Le garde n'est pas décoratif : sans lui on ouvrirait et lirait
# 64 ko à CHAQUE erreur d'une migration, pour une liste vide.
lectures = []
self.obj.installed_theme = lambda base: []
self.obj.step_log_tail = lambda *a, **k: lectures.append(1) or ""
self.obj.theme_blamed_by_the_error("db")
self.assertEqual(lectures, [])
def test_no_log_blames_nothing(self):
# Sans journal on ne SAIT pas : accuser au hasard serait pire
# que se taire.
self.assertEqual(self.obj.theme_blamed_by_the_error("db"), [])
def test_no_database_blames_nothing(self):
self.journal("theme_buzzy")
self.assertEqual(self.obj.theme_blamed_by_the_error(""), [])
def test_only_the_themes_actually_named_are_returned(self):
self.obj.installed_theme = lambda base: ["theme_buzzy", "theme_zap"]
self.journal("erreur dans theme_zap/data/ir_asset.xml")
self.assertEqual(
self.obj.theme_blamed_by_the_error("db"), ["theme_zap"]
)
class TestReadingTheTail(unittest.TestCase):
def setUp(self):
self.obj = TodoUpgrade.__new__(TodoUpgrade)
self.dossier = tempfile.mkdtemp()
self.obj.log_dir = lambda: self.dossier
self.obj.current_step = "etape"
self.obj.step_slug = staticmethod(lambda msg: "etape")
def test_it_reads_from_the_END(self):
# Une mise à jour de modules écrit des dizaines de milliers de
# lignes : charger tout le fichier pour en lire vingt coûterait
# plus que l'erreur qu'on cherche.
with io.open(
os.path.join(self.dossier, "etape.log"), "w", encoding="utf-8"
) as handle:
handle.write("debut\n" + ("x" * 100000) + "\nLA_FIN\n")
fin = self.obj.step_log_tail(octets=1000)
self.assertIn("LA_FIN", fin)
self.assertNotIn("debut", fin)
self.assertLessEqual(len(fin), 1100)
def test_a_missing_log_is_empty_not_a_crash(self):
self.assertEqual(self.obj.step_log_tail(), "")
def test_no_step_is_empty(self):
self.obj.current_step = ""
self.assertEqual(self.obj.step_log_tail(), "")
def test_broken_bytes_do_not_stop_it(self):
# Un journal de migration mêle les encodages : refuser de le lire
# ferait perdre la détection au moment où elle sert.
with open(os.path.join(self.dossier, "etape.log"), "wb") as handle:
handle.write(b"avant \xff\xfe theme_buzzy apres")
self.assertIn("theme_buzzy", self.obj.step_log_tail())
class TestTheMenu(unittest.TestCase):
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def source(self):
with io.open(
os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
src = handle.read()
debut = src.index("def _prompt_on_error")
fin = src.index("def prompt_fix_view_type")
return src[debut:fin]
def test_the_entry_is_conditional(self):
# Affichée sans condition, elle proposerait de retirer le thème
# à chaque erreur, quelle qu'elle soit.
bloc = self.source()
self.assertIn("if themes:", bloc)
self.assertIn('f"[6] ', bloc)
def test_the_branch_requires_a_blamed_theme(self):
bloc = self.source()
self.assertIn('if wait_status == "6" and themes:', bloc)
def test_the_detection_runs_once_outside_the_loop(self):
# La boucle peut tourner huit fois ; la détection lit un journal
# et interroge la base.
bloc = self.source()
self.assertEqual(bloc.count("theme_blamed_by_the_error"), 1)
self.assertLess(
bloc.index("theme_blamed_by_the_error"), bloc.index("while True:")
)
def test_uninstalling_asks_for_a_retry(self):
bloc = self.source()
debut = bloc.index('if wait_status == "6"')
fin = bloc.index('if wait_status == "5"')
morceau = bloc[debut:fin]
self.assertIn('wait_status = "1"', morceau)
self.assertIn("repare = True", morceau)
def test_it_goes_through_the_real_terminal(self):
# `uninstall_addons_theme.sh` finit par poser une question ; un
# tube la rendrait invisible et l'on répondrait à l'aveugle.
bloc = self.source()
debut = bloc.index('if wait_status == "6"')
fin = bloc.index('if wait_status == "5"')
self.assertIn("run_on_terminal", bloc[debut:fin])
def test_the_label_is_translated(self):
self.assertIn(
"Uninstall the theme(s) the error names", todo_i18n.TRANSLATIONS
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,164 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le journal de migration doit survivre à la migration suivante.
Il ne vit qu'à un seul endroit, et repartir de zéro écrit par-dessus. Ce qu'on
perdait — quels paliers étaient passés, quels modules manquaient — est
précisément ce qu'on cherche APRÈS avoir dû recommencer.
Ces tests portent sur deux choses distinctes : que la copie contienne bien ce
qu'elle doit contenir, et qu'elle parte au bon moment — ni sur une reprise
partielle, qui ne perd rien, ni sur un journal qui n'a rien à dire.
"""
import glob
import json
import os
import tempfile
import unittest
from script.todo.todo_upgrade import TodoUpgrade
def progression(**override):
"""Un journal ayant réellement avancé."""
data = {
"date_create": "2026-08-11 12:05:08",
"date_update": "2026-08-12 01:13:23",
"migration_file": "./image_db/technolibre_2026-08-12_02h36m38s.zip",
"config_database_name": "technolibre_migration_01_neutralize",
"state_0_install_odoo": True,
"state_1_restore_database": True,
"state_4_switch_odoo_lst": [True, False, False],
}
data.update(override)
return data
class ArchiveCase(unittest.TestCase):
def setUp(self):
# Chaque test dans un arbre neuf : l'archive s'écrit sous un chemin
# relatif, donc le répertoire courant EST le contexte.
self.previous = os.getcwd()
self.addCleanup(os.chdir, self.previous)
os.chdir(tempfile.mkdtemp())
def archives(self):
return sorted(
glob.glob("private/odoo/migration/*/migration_log/*.json")
)
class TestWhatIsKept(ArchiveCase):
def test_the_whole_log_is_copied_untouched(self):
old = progression()
path = TodoUpgrade.archive_progression(old, "restart_from_zero")
saved = json.load(open(path))
for key, value in old.items():
self.assertEqual(saved[key], value, key)
def test_the_copy_says_when_and_from_which_database(self):
# La demande, mot pour mot : une date de la copie et le nom de la base
# d'origine.
path = TodoUpgrade.archive_progression(
progression(), "restart_from_zero"
)
saved = json.load(open(path))
self.assertEqual(
saved["archived_database"], "technolibre_migration_01_neutralize"
)
self.assertTrue(saved["archived_at"].startswith("20"))
self.assertEqual(saved["archived_reason"], "restart_from_zero")
def test_the_file_name_carries_both_too(self):
# Pour qu'un fichier déplacé hors de son dossier se décrive encore.
path = TodoUpgrade.archive_progression(progression(), "x")
name = os.path.basename(path)
self.assertTrue(
name.startswith("technolibre_migration_01_neutralize_")
)
self.assertRegex(name, r"_\d{4}-\d{2}-\d{2}_\d{2}h\d{2}m\d{2}s\.json$")
def test_it_lands_under_private_not_in_the_venv(self):
# Une réinstallation efface .venv.erplibre/, donc y garder l'historique
# reviendrait à le perdre au pire moment.
path = TodoUpgrade.archive_progression(progression(), "x")
self.assertTrue(path.startswith(os.path.join("private", "odoo")))
self.assertNotIn(".venv", path)
def test_the_database_name_falls_back_to_the_backup_name(self):
old = progression()
del old["config_database_name"]
path = TodoUpgrade.archive_progression(old, "x")
self.assertIn("technolibre_2026-08-12_02h36m38s", path)
def test_two_restarts_in_the_same_second_do_not_overwrite_each_other(self):
# L'horodatage est à la seconde : deux copies rapprochées portent le
# même nom. Écraser la première annulerait la perte qu'on évite — et
# un test qui saute ce cas ne vérifie rien.
first = TodoUpgrade.archive_progression(progression(), "x")
second = TodoUpgrade.archive_progression(
progression(date_update="2026-08-12 02:00:00"), "y"
)
self.assertNotEqual(first, second)
self.assertEqual(len(self.archives()), 2)
self.assertEqual(
json.load(open(first))["date_update"], "2026-08-12 01:13:23"
)
self.assertEqual(
json.load(open(second))["date_update"], "2026-08-12 02:00:00"
)
class TestWhenNothingIsKept(ArchiveCase):
def test_an_empty_log_is_not_archived(self):
self.assertIsNone(TodoUpgrade.archive_progression({}, "x"))
self.assertEqual(self.archives(), [])
def test_a_log_without_any_state_is_not_archived(self):
# Un journal qui n'a rien enregistré n'apprend rien à personne ;
# l'archiver ne ferait qu'accumuler des fichiers vides.
self.assertIsNone(
TodoUpgrade.archive_progression({"migration_file": "a.zip"}, "x")
)
self.assertEqual(self.archives(), [])
class TestWhenItTriggers(ArchiveCase):
"""Seules les réponses qui repartent de zéro archivent."""
def answer(self, letter):
upgrade = TodoUpgrade.__new__(TodoUpgrade)
result = TodoUpgrade.apply_resume_answer(
upgrade, progression(), letter, {"versions": []}
)
return result, self.archives()
def test_restart_from_zero_archives(self):
result, archives = self.answer("n")
self.assertEqual(result, ({}, True))
self.assertEqual(len(archives), 1)
def test_restart_with_the_same_backup_archives(self):
# « r » garde le zip mais jette tous les états : la perte est la même.
result, archives = self.answer("r")
self.assertTrue(result[1])
self.assertNotIn("state_0_install_odoo", result[0])
self.assertEqual(len(archives), 1)
def test_continuing_archives_nothing(self):
result, archives = self.answer("c")
self.assertEqual(archives, [])
self.assertIn("state_0_install_odoo", result[0])
def test_rewinding_to_a_step_archives_nothing(self):
# Une reprise partielle garde le journal : rien n'est perdu, donc rien
# n'est à sauver.
_, archives = self.answer("0")
self.assertEqual(archives, [])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'étape 2 annonce des copies COW cassées, et ne demandait rien.
Le message disait le problème, invitait à arbitrer, puis passait à la suite :
« la migration proposera de neutraliser au palier ». Des dizaines de minutes
plus tard, donc — alors que REGARDER n'écrit rien, et que neutraliser ici vaut
pour tous les paliers, chaque base de palier étant un clone de celle-ci.
Ces tests portent sur ce que chaque réponse déclenche réellement, pas sur le
texte de l'invite.
"""
import builtins
import contextlib
import io
import os
import unittest
from script.todo import todo_i18n
from script.todo.todo_upgrade import TodoUpgrade
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class PromptCase(unittest.TestCase):
def setUp(self):
# PAS set_lang() : il persiste la langue dans env_var.sh, suivi par
# git. On écrit la mémoïsation, et on la rend.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def run_prompt(self, answers, database="db", version=13):
"""(commandes lancées, texte affiché) après ces réponses."""
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
lst_cmd = []
upgrade.todo_upgrade_execute = lambda cmd, **kw: (
lst_cmd.append(cmd),
(False, cmd),
)[1]
# Les afficheurs ne passent PLUS par todo_upgrade_execute : il capture
# la sortie par un tube, et un plein écran refuse de s'ouvrir sur un
# stdout qui n'est pas un terminal. Sans ce bouchon, ces tests
# lanceraient les vraies commandes.
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
seq = iter(answers)
original = builtins.input
builtins.input = lambda *a: next(seq)
self.addCleanup(setattr, builtins, "input", original)
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_cow_prediction(database, version)
return lst_cmd, out.getvalue()
class TestWhatEachAnswerRuns(PromptCase):
def test_enter_neutralizes_now(self):
# Entrée neutralise : c'est réversible — l'arch est conservée — et
# ce qu'on neutralise ici vaut pour TOUS les paliers, au lieu
# d'attendre des dizaines de minutes pour redécider à chaque fois.
lst_cmd, _text = self.run_prompt([""])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("--apply", lst_cmd[0])
def test_n_runs_nothing_and_says_so(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
lst_cmd, text = self.run_prompt(["n"])
self.assertEqual(lst_cmd, [])
self.assertIn("version bump", text)
def test_v_shows_what_the_copies_hold(self):
lst_cmd, _ = self.run_prompt(["v", "n"])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("cow_drift.py", lst_cmd[0])
self.assertIn("-d db -t odoo13.0", lst_cmd[0])
self.assertNotIn("--shape", lst_cmd[0])
def test_s_shows_why_it_breaks(self):
lst_cmd, _ = self.run_prompt(["s", "n"])
self.assertTrue(lst_cmd[0].endswith("--shape"))
def test_w_opens_the_full_screen_view(self):
lst_cmd, _ = self.run_prompt(["w", "n"])
self.assertTrue(lst_cmd[0].endswith("--tui"))
def test_looking_does_not_answer_the_question(self):
# LE piège de l'invite précédente : montrer puis passer à la suite.
# Après avoir regardé, on doit pouvoir encore choisir.
lst_cmd, _ = self.run_prompt(["v", "s", "w", "a"])
self.assertEqual(len(lst_cmd), 4)
self.assertIn("--apply", lst_cmd[-1])
class TestNeutralizingNow(PromptCase):
def test_a_applies_and_stops_asking(self):
lst_cmd, _ = self.run_prompt(["a"])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("neutralize_cow_views.py", lst_cmd[0])
self.assertIn("-d db -t odoo13.0 --apply", lst_cmd[0])
def test_the_way_back_is_printed_and_is_valid(self):
# `--restore` refuse `-t` : une commande d'annulation qu'on ne peut
# pas coller est une commande d'annulation qui n'existe pas.
_, text = self.run_prompt(["a"])
self.assertIn("--restore", text)
ligne = [x for x in text.splitlines() if "--restore" in x][0]
self.assertIn("-d db", ligne)
self.assertNotIn("-t odoo", ligne)
def test_applying_says_nothing_about_deciding_later(self):
_, text = self.run_prompt(["a"])
self.assertNotIn("ask again", text)
class TestTheFullScreenViewGetsARealTerminal(PromptCase):
"""« w » réaffichait le rapport texte au lieu d'ouvrir le plein écran.
`todo_upgrade_execute` capture la sortie par un tube pour que le pilote
puisse la relire. La TUI y voit un stdout qui n'est PAS un terminal,
renonce — c'est son repli délibéré — et retombe sur le même rapport que
« v » venait d'afficher. Aucune erreur, aucune trace : deux touches
différentes donnant le même écran.
"""
def test_the_viewers_do_not_go_through_the_piped_executor(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.show_cow_drift)
self.assertNotIn("todo_upgrade_execute", source)
self.assertIn("self.run_on_terminal(", source)
def test_run_on_terminal_does_not_capture_anything(self):
# Capturer, c'est justement ce qui referme le plein écran.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.run_on_terminal)
self.assertNotIn("PIPE", source)
self.assertNotIn("capture_output", source)
self.assertIn("subprocess.call(", source)
def test_the_tui_refuses_a_pipe_and_accepts_a_terminal(self):
# La raison même du défaut, vérifiée sur le code qui décide.
import sys
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
self.addCleanup(sys.path.remove, sys.path[0])
from cow_drift_tui import run_tui
finding = {
"id": 1,
"key": "k",
"website_id": 1,
"reason": "r",
"module_id": 2,
"module_arch": "a",
"copy_arch": "b",
"decl_current": None,
"decl_target": None,
"current_version": "odoo12.0",
"target_version": "odoo13.0",
}
# Un vrai objet fichier : run_tui ÉCRIT maintenant la raison de son
# refus, et un faux sans write() ne mesurait plus le comportement mais
# sa propre incomplétude.
class FakeStdout(io.StringIO):
def __init__(self, tty):
super().__init__()
self.tty = tty
def isatty(self):
return self.tty
real = sys.stdout
self.addCleanup(setattr, sys, "stdout", real)
sys.stdout = piped = FakeStdout(False)
refused = run_tui([finding], run_app=False)
sys.stdout = real
self.assertFalse(refused)
self.assertIn("terminal", piped.getvalue())
sys.stdout = FakeStdout(True)
opened = run_tui([finding], run_app=False)
sys.stdout = real
self.assertTrue(opened)
class TestTheBumpPromptSharesTheSameView(PromptCase):
"""Les deux invites doivent montrer la même chose, sans se dupliquer."""
def test_show_cow_drift_builds_the_three_modes(self):
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
for mode in ("diff", "shape", "tui"):
upgrade.show_cow_drift("db", 13, mode)
self.assertNotIn("--", lst_cmd[0].split("odoo13.0")[1])
self.assertTrue(lst_cmd[1].endswith("--shape"))
self.assertTrue(lst_cmd[2].endswith("--tui"))
def test_the_bump_prompt_no_longer_builds_its_own_command(self):
# Sans cela les deux invites peuvent diverger en silence.
import inspect
source = inspect.getsource(TodoUpgrade.neutralize_cow_views)
self.assertNotIn("cow_drift.py", source)
self.assertIn("self.show_cow_drift(", source)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,439 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'écran de reprise doit dire ce qui a eu lieu, pas ce qu'il croit.
Une base venant d'une vieille version se met à jour AVANT la neutralisation :
l'outil le propose au tout début. Ce travail est celui de l'étape 2, fait plus
tôt — mais il n'était enregistré que sous `state_1_update_all`, et l'écran
cherchait `state_2_*`.
Deux conséquences, l'une visible et l'autre chère : l'étape restait « non
démarrée » alors qu'elle venait de tourner, et la reprise suivante relançait
`update_addons_all` sur une base déjà à jour.
"""
import unittest
from script.todo import todo_i18n
from script.todo.todo_upgrade import MIGRATION_STEP, TodoUpgrade
class StatusCase(unittest.TestCase):
def setUp(self):
# PAS set_lang() : il persiste la langue dans env_var.sh, suivi par
# git. On écrit la mémoïsation, et on la rend.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
class TestUpdateAllStatus(StatusCase):
def status(self, dct):
return TodoUpgrade.step_status(dct, 2)
def test_an_old_log_knows_only_the_early_flag(self):
# Le cas signalé : un journal écrit avant le correctif. L'étape a bien
# eu lieu, il ne faut pas la dire « non démarrée ».
icon, detail = self.status({"state_1_update_all": True})
self.assertEqual(icon, "✅")
self.assertIn("early", detail)
def test_a_new_log_says_when_it_happened(self):
icon, detail = self.status(
{
"state_1_update_all": True,
"state_2_update_all": True,
"state_2_done_early": True,
}
)
self.assertEqual(icon, "✅")
self.assertIn("early", detail)
def test_done_at_its_normal_place_says_only_done(self):
icon, detail = self.status({"state_2_update_all": True})
self.assertEqual(icon, "✅")
self.assertNotIn("early", detail)
def test_really_not_started(self):
icon, detail = self.status({"state_0_install_odoo": True})
self.assertEqual(icon, "⬜")
self.assertIn("not started", detail)
def test_the_other_steps_are_untouched(self):
# Le correctif ne vaut que pour l'étape 2 : les autres gardent leur
# règle, sinon un drapeau d'une étape en éclairerait une autre.
dct = {"state_1_update_all": True}
for step, _ in MIGRATION_STEP:
if step in (1, 2):
continue
icon, _ = TodoUpgrade.step_status(dct, step)
self.assertEqual(icon, "⬜", f"étape {step}")
class TestNeedsUpdateAll(unittest.TestCase):
"""Ce qui décide de relancer, ou non, une mise à jour de plusieurs heures."""
def test_nothing_done_yet(self):
self.assertTrue(TodoUpgrade.needs_update_all({}))
def test_its_own_flag_is_enough(self):
self.assertFalse(
TodoUpgrade.needs_update_all({"state_2_update_all": True})
)
def test_the_early_flag_survives_a_resume(self):
# LE défaut : la variable de session repart à False à la reprise, et
# seule la trace écrite subsiste. Sans la lire, on relançait tout.
self.assertFalse(
TodoUpgrade.needs_update_all({"state_1_update_all": True})
)
def test_the_session_variable_still_counts(self):
self.assertFalse(TodoUpgrade.needs_update_all({}, True))
def test_an_unrelated_flag_does_not_count(self):
self.assertTrue(
TodoUpgrade.needs_update_all({"state_1_restore_database": True})
)
class TestRewindReallyReplays(StatusCase):
"""Rembobiner doit REJOUER l'étape, pas seulement effacer sa trace.
Un journal venant d'une vieille base porte `state_1_update_all` : la mise
à jour précoce, offerte avant la neutralisation. Le travail est celui de
l'étape 2, seul le nom dit 1. Rembobiner à l'étape 2 gardait donc ce
drapeau — son préfixe le rangeait dans l'étape 1 — et l'étape sautait
alors qu'on venait de demander à la rejouer.
Effacer les clés ne suffit pas à le prouver : il faut interroger les
gardes qui décident du travail.
"""
def journal(self):
"""La forme d'un vrai journal ayant fait la mise à jour précoce."""
return {
"config_database_name": "db",
"state_0_install_odoo": True,
"state_0_search_missing_module": True,
"state_1_restore_database": True,
"state_1_update_all": True,
"state_1_neutralize_database": True,
"state_3_clean_database": True,
"state_4_upgrade_odoo_lst": [True, False],
}
def rewind(self, step):
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
return TodoUpgrade.rewind_progression(self.journal(), step)
def test_going_back_to_step_2_runs_the_update_again(self):
# LE défaut signalé : « il continue en ignorant l'étape que j'ai
# choisie ». Trois heures de mise à jour silencieusement sautées.
self.assertTrue(
TodoUpgrade.needs_update_all(self.rewind(2)),
"rembobiner à l'étape 2 doit rejouer la mise à jour",
)
def test_the_earlier_steps_replay_it_too(self):
for step in (0, 1):
self.assertTrue(
TodoUpgrade.needs_update_all(self.rewind(step)),
f"étape {step}",
)
def test_going_back_to_a_later_step_leaves_it_done(self):
# Le symétrique : reculer à l'étape 3 ne doit PAS relancer l'étape 2.
for step in (3, 4):
self.assertFalse(
TodoUpgrade.needs_update_all(self.rewind(step)),
f"étape {step}",
)
def test_no_work_guard_of_the_chosen_step_or_later_survives(self):
# Les gardes sont lues dans la SOURCE : une garde ajoutée plus tard
# sera couverte sans que ce test soit retouché.
import ast
from script.todo import todo_upgrade
guards = set()
with open(todo_upgrade.__file__) as handle:
tree = ast.parse(handle.read())
for node in ast.walk(tree):
if not (
isinstance(node, ast.If) and isinstance(node.test, ast.UnaryOp)
):
continue
for sub in ast.walk(node.test):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "get"
and sub.args
and isinstance(sub.args[0], ast.Constant)
and str(sub.args[0].value).startswith("state_")
):
guards.add(str(sub.args[0].value))
self.assertTrue(
guards, "aucune garde trouvée : le test ne prouve rien"
)
for step, _ in MIGRATION_STEP:
kept = self.rewind(step)
still_closed = [
key
for key in sorted(guards)
if todo_upgrade.flag_step(key) >= step and kept.get(key)
]
self.assertEqual(still_closed, [], f"étape {step}")
class TestEveryKeyBelongsToSomeone(unittest.TestCase):
"""Aucune clé du journal ne doit échapper au classement.
Rembobiner ne gardait que ce qui ne commençait pas par « state_ ». Vingt-
cinq clés passaient donc au travers, dont les réponses par palier de
l'étape 4 : l'étape choisie se rejouait, les suivantes repartaient sur les
décisions de la course précédente et ne demandaient plus rien.
Ce test lit les clés dans la SOURCE. Une clé ajoutée demain sans être
classée le fera échouer, au lieu de survivre en silence au rembobinage.
"""
def keys_in_source(self):
import ast
from script.todo import todo_upgrade
with open(todo_upgrade.__file__) as handle:
tree = ast.parse(handle.read())
keys = set()
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if (
isinstance(target, ast.Subscript)
and isinstance(target.value, ast.Attribute)
and target.value.attr == "dct_progression"
and isinstance(target.slice, ast.Constant)
):
keys.add(str(target.slice.value))
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "get"
and node.args
and isinstance(node.args[0], ast.Constant)
):
owner = node.func.value
name = getattr(owner, "attr", getattr(owner, "id", ""))
if "progression" in str(name):
keys.add(str(node.args[0].value))
return keys
def test_no_key_is_left_unclassified(self):
from script.todo import todo_upgrade
keys = self.keys_in_source()
self.assertGreater(len(keys), 30, "l'extraction n'a rien trouvé")
orphans = sorted(
key
for key in keys
if key not in todo_upgrade.GLOBAL_PROGRESSION_KEY
and todo_upgrade.flag_step(key) is None
)
self.assertEqual(
orphans, [], "à classer : global, ou à quelle étape ?"
)
def test_a_key_is_never_both_global_and_owned(self):
from script.todo import todo_upgrade
for key in sorted(todo_upgrade.GLOBAL_PROGRESSION_KEY):
self.assertIsNone(todo_upgrade.flag_step(key), key)
class TestRewindDropsTheAnswersToo(StatusCase):
"""« Il faut aussi revert les variables mises à jour entre les étapes. »"""
def journal(self):
return {
"config_database_name": "db",
"target_odoo_version": "18.0",
"migration_file": "a.zip",
"config_state_1_install_module": ["sale"],
"config_state_4_install_module": [False, ["stock"]],
"config_state_4_uninstall_module": [False, False],
"config_state_4_module_to_migrate_code": [[], ["m"]],
"dct_module_exist": {"sale": "1.0"},
"state_0_install_odoo": True,
"state_2_update_all": True,
}
def rewind(self, step):
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
return TodoUpgrade.rewind_progression(self.journal(), step)
def test_the_step_4_answers_go_when_step_4_is_replayed(self):
# LE défaut signalé : l'étape rejouée marchait, les suivantes non.
kept = self.rewind(2)
for key in (
"config_state_4_install_module",
"config_state_4_uninstall_module",
"config_state_4_module_to_migrate_code",
):
self.assertNotIn(key, kept, key)
def test_the_step_1_answers_stay_when_step_1_is_kept(self):
# Le symétrique : rembobiner à 2 ne doit pas re-demander l'étape 1.
self.assertIn("config_state_1_install_module", self.rewind(2))
self.assertNotIn("config_state_1_install_module", self.rewind(1))
def test_the_step_0_module_search_output_goes_with_step_0(self):
# `dct_module_exist` ne nomme aucune étape mais est produit par la 0,
# et relu par la 4 : le garder après un rembobinage à 0 ferait juger
# la 4 sur un inventaire périmé.
self.assertNotIn("dct_module_exist", self.rewind(0))
self.assertIn("dct_module_exist", self.rewind(1))
def test_the_decisions_survive_every_rewind(self):
# Sans elles on ne saurait plus QUELLE base on migre : le rembobinage
# deviendrait une autre migration.
for step, _ in MIGRATION_STEP:
kept = self.rewind(step)
for key in ("config_database_name", "target_odoo_version"):
self.assertIn(key, kept, f"{key} à l'étape {step}")
class TestRewindOneVersionBump(unittest.TestCase):
"""Rejouer un palier de l'étape 4 doit aussi rendre ses réponses."""
def journal(self):
return {
"state_4_clone_odoo_lst": [True, True, True],
"state_4_uninstall_module": [True, True, False],
"config_state_4_install_module": [["sale"], ["stock"], False],
"config_state_4_module_to_migrate_code": [["m1"], ["m2"], []],
"state_4_lst_module_missing": ["a", "b"],
"state_4_len_lst_module_missing": 40,
}
def test_the_answers_of_the_replayed_bumps_are_cleared(self):
kept = TodoUpgrade.rewind_version_bump(self.journal(), 1)
self.assertEqual(
kept["config_state_4_install_module"][1:], [[], False]
)
self.assertEqual(kept["config_state_4_install_module"][0], ["sale"])
def test_a_list_entry_stays_a_list(self):
# Le code fait .append() sur cette entrée sans l'avoir revue : un
# False à sa place ferait planter le palier qu'on vient de rejouer.
kept = TodoUpgrade.rewind_version_bump(self.journal(), 1)
for entry in kept["config_state_4_module_to_migrate_code"]:
self.assertIsInstance(entry, list)
def test_a_flat_list_is_not_a_per_bump_list(self):
# `state_4_lst_module_missing` est une liste de modules, pas un
# tableau par palier : la tronquer perdrait l'inventaire.
kept = TodoUpgrade.rewind_version_bump(self.journal(), 1)
self.assertEqual(kept["state_4_lst_module_missing"], ["a", "b"])
self.assertEqual(kept["state_4_len_lst_module_missing"], 40)
def test_the_earlier_bumps_are_untouched(self):
kept = TodoUpgrade.rewind_version_bump(self.journal(), 2)
self.assertEqual(kept["state_4_clone_odoo_lst"], [True, True, False])
class TestBackGate(unittest.TestCase):
"""Revenir à une étape précédente depuis une invite en cours de route.
Ces invites ne demandaient qu'à continuer. S'apercevoir à ce moment-là
qu'une étape antérieure méritait un autre choix n'avait qu'une issue :
Ctrl+C, qui laisse la progression telle quelle et oblige à retrouver
l'écran de reprise. « b » fait le travail proprement.
"""
def setUp(self):
import os
import tempfile
self.addCleanup(os.chdir, os.getcwd())
os.chdir(tempfile.mkdtemp())
os.makedirs(".venv.erplibre", exist_ok=True)
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def gate(self, answers):
"""(a arrêté ?, valeur rendue, progression) après ces réponses."""
import builtins
import contextlib
import io
from script.todo.todo_upgrade import MigrationRewind
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {
"config_database_name": "db",
"state_0_install_odoo": True,
"state_1_restore_database": True,
"state_2_update_all": True,
"state_3_clean_database": True,
}
upgrade.lst_command_executed = []
seq = iter(answers)
original = builtins.input
builtins.input = lambda *a: next(seq)
self.addCleanup(setattr, builtins, "input", original)
stopped, returned = False, None
try:
with contextlib.redirect_stdout(io.StringIO()):
returned = upgrade.ask_gate("? ")
except MigrationRewind:
stopped = True
return stopped, returned, upgrade.dct_progression
def test_a_normal_answer_passes_straight_through(self):
stopped, returned, _ = self.gate(["y"])
self.assertFalse(stopped)
self.assertEqual(returned, "y")
def test_b_then_a_step_rewinds_and_stops(self):
stopped, _, progression = self.gate(["b", "2"])
self.assertTrue(stopped)
# L'étape choisie et les suivantes sont effacées, les précédentes non.
self.assertIn("state_1_restore_database", progression)
self.assertNotIn("state_2_update_all", progression)
self.assertNotIn("state_3_clean_database", progression)
def test_cancelling_the_rewind_does_not_stop_the_migration(self):
# LE piège : renoncer au retour en arrière arrêtait quand même tout.
# On revient à la même invite, exactement là où l'on était.
stopped, returned, progression = self.gate(["b", "", "y"])
self.assertFalse(stopped)
self.assertEqual(returned, "y")
self.assertIn("state_3_clean_database", progression)
def test_an_unknown_step_is_not_a_rewind(self):
stopped, returned, progression = self.gate(["b", "zzz", ""])
self.assertFalse(stopped)
self.assertEqual(returned, "")
self.assertIn("state_3_clean_database", progression)
def test_the_answer_is_case_insensitive(self):
stopped, _, _ = self.gate(["B", "1"])
self.assertTrue(stopped)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,488 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Désinstaller un thème n'est pas désinstaller son module.
`--install-theme` appelle `button_choose_theme()`, qui fait deux choses :
copier les vues et ressources du thème dans chaque site, et écrire dans
`user_values.scss` une personnalisation qui DÉFINIT `$o-theme-font-number`
et ses trois voisines. Le chemin de retrait d'Odoo, `_theme_remove()`, défait
les deux — et son premier geste est `_reset_default_config()`, celui qui écrit
ces définitions.
Un `--uninstall` nu saute tout cela. Mesuré sur une migration réelle 12 → 13 :
le bundle `web.assets_frontend` s'arrête sur « Undefined variable:
$o-theme-font-number ». La variable venait des fichiers `option_font_body_*`
d'Odoo 12, supprimés en 13.0 ; seul le thème la redéfinissait encore, et le
retirer a mis à nu un SCSS personnalisé figé depuis 2020.
Ces tests portent sur ce que le script fait, pas sur son texte.
"""
import os
import subprocess
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPT = os.path.join(REPO, "script", "addons", "uninstall_addons_theme.sh")
sys.path.insert(0, os.path.join(REPO, "script", "addons"))
import theme_leftover # noqa: E402
class TestTheScriptShape(unittest.TestCase):
def source(self):
with open(SCRIPT) as handle:
return handle.read()
def test_it_exists_and_is_executable(self):
self.assertTrue(os.access(SCRIPT, os.X_OK))
def test_it_parses(self):
done = subprocess.run(
["bash", "-n", SCRIPT], capture_output=True, text=True
)
self.assertEqual(done.returncode, 0, done.stderr)
def test_it_goes_through_theme_remove(self):
# LE point : sans cet appel, le script ne serait qu'un --uninstall
# sous un autre nom, et laisserait la même panne derrière lui.
self.assertIn("_theme_remove(website)", self.source())
def test_it_walks_every_website(self):
# Un site par thème : n'en traiter qu'un laisserait les autres avec
# des copies dont le module est parti.
source = self.source()
self.assertIn('env["website"].search([])', source)
def test_it_still_uninstalls_the_module(self):
self.assertIn("--uninstall", self.source())
def test_it_mirrors_the_installer_checks(self):
# Même garde-fou que install_addons_theme.sh : un nom de module
# inexistant doit s'arrêter avant de toucher la base.
self.assertIn("check_addons_exist.py", self.source())
def test_a_missing_argument_stops_before_anything(self):
done = subprocess.run(
["bash", SCRIPT, "onlydb"],
capture_output=True,
text=True,
cwd=REPO,
)
self.assertEqual(done.returncode, 1)
self.assertIn("Usage", done.stdout + done.stderr)
class TestTheLeftoverReport(unittest.TestCase):
"""Ce que le déchargement ne prend pas, et qu'il faut au moins savoir."""
def setUp(self):
# PAS set_lang() : il persiste la langue dans env_var.sh, suivi par
# git. On épingle la mémoïsation — sans quoi ces tests liraient la
# langue du poste, et passeraient ou non selon la machine.
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_nothing_left_is_said_plainly(self):
text = theme_leftover.render("theme_x", [], [])
self.assertIn("✅", text)
def test_attachments_are_listed_with_their_date(self):
rows = ["4457|/theme_x/static/a.scss|2021-03-04"]
text = theme_leftover.render("theme_x", rows, [])
self.assertIn("4457", text)
self.assertIn("2021-03-04", text)
def test_a_long_list_says_how_many_it_hid(self):
# Tronquer sans le dire se lit comme « c'est tout ».
rows = [f"{i}|/theme_x/a{i}.scss|2021-01-01" for i in range(30)]
text = theme_leftover.render("theme_x", rows, [])
self.assertIn("10", text)
def test_it_never_offers_to_delete(self):
# Le contenu d'une pièce jointe peut être la seule trace d'une
# personnalisation : c'est une décision, pas un ménage.
text = theme_leftover.render("theme_x", ["1|/theme_x/a|d"], [])
self.assertIn("Nothing was deleted", text)
def test_the_sql_escapes_a_quote_in_the_theme_name(self):
self.assertEqual(theme_leftover.quote_literal("a'b"), "'a''b'")
def test_the_query_is_read_only_on_the_server_side(self):
# Pas une promesse de l'outil : PostgreSQL refuse l'écriture.
with open(theme_leftover.__file__) as handle:
source = handle.read()
self.assertIn("default_transaction_read_only=on", source)
class TestTheMigrationOffersIt(unittest.TestCase):
"""La question doit venir AVANT le premier palier, et par défaut non.
Un thème installé traverse la migration : ses copies de vues et ses SCSS
suivent chaque palier, et chaque palier peut renommer ce dont ils
dépendent. Le proposer tôt retire d'un coup une famille de pannes.
Par défaut non : retirer un thème change l'apparence d'un site, et ce
n'est pas à une migration de trancher cela à la place de quelqu'un.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def upgrade(self, lst_theme, answer):
from script.todo.todo_upgrade import TodoUpgrade
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.lst_command_executed = []
obj.write_config = lambda: None
obj.installed_theme = lambda db: lst_theme
self.lst_cmd = []
obj.todo_upgrade_execute = lambda cmd, **kw: (
self.lst_cmd.append(cmd),
(False, cmd, []),
)[1]
# Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il
# pose une question, et un tube la rendrait invisible.
obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
# les tests de défaut ne testeraient que le doublon.
obj.ask_gate = lambda prompt, default="": answer or default
return obj
def run_prompt(self, lst_theme, answer):
import contextlib
import io
obj = self.upgrade(lst_theme, answer)
out = io.StringIO()
with contextlib.redirect_stdout(out):
obj.prompt_uninstall_theme("db")
return self.lst_cmd, out.getvalue()
def test_the_default_answer_uninstalls_them(self):
# « Entrée » désinstalle : c'est la réponse qu'on donnait à chaque
# migration, et un thème traversé sans être retiré est justement ce
# qui casse au palier suivant.
lst_cmd, _text = self.run_prompt(["theme_technolibre"], "")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("uninstall_addons_theme.sh", lst_cmd[0])
def test_saying_no_still_keeps_them(self):
# Le défaut ne doit pas retirer le choix : il ne fait qu'en proposer
# un. Sans cette issue, la question ne serait plus une question.
lst_cmd, text = self.run_prompt(["theme_technolibre"], "n")
self.assertEqual(lst_cmd, [])
self.assertIn("Kept", text)
def test_the_question_says_that_enter_uninstalls(self):
# Une invite qui annonce « y/N » et fait l'inverse est pire que pas
# d'invite du tout.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme)
self.assertIn("(Y/n,", source)
self.assertNotIn("(y/N,", source)
def test_no_theme_means_no_question(self):
lst_cmd, text = self.run_prompt([], "y")
self.assertEqual(lst_cmd, [])
self.assertEqual(text, "")
def test_yes_runs_the_proper_uninstaller(self):
lst_cmd, _ = self.run_prompt(["theme_technolibre"], "y")
self.assertEqual(len(lst_cmd), 1)
self.assertIn(
"uninstall_addons_theme.sh db theme_technolibre", lst_cmd[0]
)
def test_every_theme_is_offered_together(self):
lst_cmd, _ = self.run_prompt(["theme_a", "theme_b"], "Y")
self.assertEqual(len(lst_cmd), 2)
def test_it_is_asked_before_the_first_bump(self):
# L'ordre est le point : posée après le premier palier, la question
# arrive quand les copies ont déjà traversé un renommage.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
appel = source.index("prompt_uninstall_theme")
palier = source.index("4 - Upgrade version with OpenUpgrade")
self.assertLess(appel, palier)
def test_theme_default_is_not_a_theme_to_remove(self):
# theme_default EST l'absence de thème : le proposer au retrait
# ferait poser une question sans objet à chaque migration.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.installed_theme)
self.assertIn("name <> 'theme_default'", source)
class TestKeepOrDeleteTheLeftovers(unittest.TestCase):
"""Signaler sans offrir le geste oblige à le composer soi-même.
Le rapport listait quinze pièces jointes et s'arrêtait là. Les effacer
demandait de retrouver les identifiants et d'écrire un unlink() à la
main — au milieu d'une migration, c'est ce qu'on ne fait pas.
« Garder » reste le défaut, et rien n'est effacé sans avoir été écrit sur
disque d'abord : c'est la condition pour pouvoir répondre « efface ».
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
self.rows = ["4457|/theme_x/static/a.scss|2021-03-04"]
self.saved = []
self.deleted = []
self.original_backup = theme_leftover.backup_attachments
self.original_delete = theme_leftover.delete_attachments
theme_leftover.backup_attachments = (
lambda db, th, rows, fs=None: self.saved.append(rows) or ["/tmp/x"]
)
theme_leftover.delete_attachments = (
lambda db, rows, cfg="./config.conf": (
self.deleted.append(rows),
(0, "ok"),
)[1]
)
self.addCleanup(
setattr,
theme_leftover,
"backup_attachments",
self.original_backup,
)
self.addCleanup(
setattr, theme_leftover, "delete_attachments", self.original_delete
)
def run_prompt(self, answer, rows=None):
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out):
done = theme_leftover.prompt(
"db",
"theme_x",
self.rows if rows is None else rows,
[],
"./config.conf",
ask=lambda prompt: answer,
)
return done, out.getvalue()
def test_enter_keeps_them(self):
done, text = self.run_prompt("")
self.assertFalse(done)
self.assertEqual(self.deleted, [])
self.assertIn("Kept", text)
def test_d_saves_before_deleting(self):
# L'ORDRE est le point : effacer d'abord rendrait la sauvegarde vide.
done, _ = self.run_prompt("d")
self.assertTrue(done)
self.assertEqual(len(self.saved), 1)
self.assertEqual(len(self.deleted), 1)
def test_a_failed_deletion_says_nothing_was_removed(self):
theme_leftover.delete_attachments = (
lambda db, rows, cfg="./config.conf": (1, "boom")
)
done, text = self.run_prompt("d")
self.assertFalse(done)
self.assertIn("nothing was removed", text)
def test_no_leftover_asks_nothing(self):
done, text = self.run_prompt("d", rows=[])
self.assertFalse(done)
self.assertEqual(text, "")
def test_the_prompt_stays_out_of_a_pipe(self):
with open(theme_leftover.__file__) as handle:
self.assertIn("sys.stdin.isatty()", handle.read())
class TestTheQuestionMustBeVisible(unittest.TestCase):
"""Une invite qu'on ne voit pas est une invite à laquelle on répond mal.
Le script se termine par theme_leftover.py, qui pose une question. Lancé
par l'exécuteur qui CAPTURE la sortie, son stdout est un tube : Python
bufferise par blocs et l'invite reste invisible pendant que le processus
attend. Vécu — on croit à un blocage, on tape Entrée plusieurs fois, la
première frappe répond à l'aveugle et les suivantes vont à la question
d'après.
Deux verrous, car un seul ne suffit pas : la migration lance ce script
sur le vrai terminal, ET l'outil refuse de questionner s'il ne peut pas
se faire voir.
"""
def test_the_migration_runs_it_on_the_real_terminal(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme)
self.assertIn("run_on_terminal", source)
self.assertNotIn("todo_upgrade_execute", source)
def test_asking_needs_stdout_not_just_stdin(self):
# LE défaut : ne tester que stdin laisse poser une question dans un
# tube. Il faut de quoi LIRE la réponse ET MONTRER la question.
import io
import sys
class Fake(io.StringIO):
def __init__(self, tty):
super().__init__()
self.tty = tty
def isatty(self):
return self.tty
real_in, real_out = sys.stdin, sys.stdout
self.addCleanup(setattr, sys, "stdin", real_in)
self.addCleanup(setattr, sys, "stdout", real_out)
for stdin_tty, stdout_tty, expected in (
(True, True, True),
(True, False, False), # le cas mesuré
(False, True, False),
(False, False, False),
):
sys.stdin, sys.stdout = Fake(stdin_tty), Fake(stdout_tty)
got = theme_leftover.can_ask()
sys.stdin, sys.stdout = real_in, real_out
with self.subTest(stdin=stdin_tty, stdout=stdout_tty):
self.assertEqual(got, expected)
def test_the_other_tools_guard_the_same_way(self):
# Trois outils posent des questions ; un seul corrigé laisserait le
# même piège ailleurs.
import os
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for relative in (
"script/odoo/migration/smoke_public_url.py",
"script/odoo/migration/check_stale_scss.py",
"script/addons/theme_leftover.py",
):
with open(os.path.join(REPO_ROOT, relative)) as handle:
source = handle.read()
with self.subTest(tool=relative):
self.assertIn("def can_ask()", source)
self.assertIn("sys.stdout.isatty()", source)
class TestTheIdentifiersSentToOdoo(unittest.TestCase):
"""browse() veut des ENTIERS ; psql rend des chaînes.
Mesuré sur une vraie base : browse(['4457']) fait échouer Odoo sur
« la recherche en base n'a pas les identifiants (('4457',)) et a des
identifiants supplémentaires ((4457,)) ». Il compare des chaînes à des
entiers, ne retrouve rien, et refuse. L'effacement n'a rien retiré —
heureusement, la sauvegarde était déjà faite.
"""
def test_the_script_browses_integers(self):
import inspect
source = inspect.getsource(theme_leftover.delete_attachments)
self.assertIn("int(row.split", source)
def test_the_pushed_script_carries_no_quoted_id(self):
# Le rendu exact de ce qui part dans le shell : une seule apostrophe
# autour d'un identifiant et Odoo refuse tout le lot.
pushed = {}
original = theme_leftover.subprocess.run
theme_leftover.subprocess.run = (
lambda *a, **kw: pushed.update(script=kw.get("input", ""))
or type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
)
self.addCleanup(setattr, theme_leftover.subprocess, "run", original)
theme_leftover.delete_attachments(
"db", ["4457|/theme_x/a.scss|2021-03-04", "4768|/b.css|d"]
)
self.assertIn("browse([4457, 4768])", pushed["script"])
self.assertNotIn("'4457'", pushed["script"])
def test_a_non_numeric_row_is_not_sent_silently(self):
# Mieux vaut échouer ici que pousser un script qu'Odoo refusera.
with self.assertRaises(ValueError):
theme_leftover.delete_attachments("db", ["zz|/a|d"])
class TestTheMisleadingErrorCode(unittest.TestCase):
"""« 1 » veut dire « il reste des choses », pas « ça a raté »."""
def test_the_uninstaller_does_not_fail_on_leftovers(self):
# Le rapport était la dernière commande du script : son code
# devenait celui du script, et la migration annonçait une erreur sur
# un thème correctement retiré.
with open(SCRIPT) as handle:
source = handle.read()
self.assertIn("theme_leftover.py", source)
queue = source[source.index("theme_leftover.py") :]
self.assertIn("|| true", queue)
self.assertIn("exit 0", queue)
def test_the_cow_check_no_longer_goes_through_the_capturing_executor(self):
# exec_command_live imprime « Command returned error code: 1 » dès
# qu'un code non nul sort, y compris sur un rapport qui va bien.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
avant = source[: source.index("check_cow_views.py")]
self.assertGreater(
avant.rfind("run_on_terminal("),
avant.rfind("todo_upgrade_execute("),
)
class TestExitCodes(unittest.TestCase):
"""0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins."""
def test_a_dead_database_is_a_tool_failure(self):
done = subprocess.run(
[
sys.executable,
os.path.join(REPO, "script", "addons", "theme_leftover.py"),
"-d",
"erplibre_no_such_database_zz",
"-t",
"theme_x",
],
capture_output=True,
text=True,
cwd=REPO,
)
self.assertEqual(done.returncode, 2, done.stdout)
if __name__ == "__main__":
unittest.main()