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:
commit
e7c54c0a15
64 changed files with 23598 additions and 293 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
315
script/addons/theme_leftover.py
Executable 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())
|
||||
96
script/addons/uninstall_addons_theme.sh
Executable file
96
script/addons/uninstall_addons_theme.sh
Executable 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
0
script/analyse/analyse_custom_field.py
Normal file → Executable file
0
script/analyse/analyse_diff_tui.py
Normal file → Executable file
0
script/analyse/analyse_diff_tui.py
Normal file → Executable file
0
script/analyse/analyse_schema_size.py
Normal file → Executable file
0
script/analyse/analyse_schema_size.py
Normal file → Executable file
0
script/analyse/analyse_view_custom.py
Normal file → Executable file
0
script/analyse/analyse_view_custom.py
Normal file → Executable file
1146
script/analyse/check_migration_quality.py
Executable file
1146
script/analyse/check_migration_quality.py
Executable file
File diff suppressed because it is too large
Load diff
361
script/analyse/check_migration_quality_tui.py
Normal file
361
script/analyse/check_migration_quality_tui.py
Normal 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
0
script/analyse/lib_analyse.py
Normal file → Executable file
267
script/analyse/openupgrade_analysis.py
Normal file
267
script/analyse/openupgrade_analysis.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Ce qu'OpenUpgrade DÉCLARE changer, pour l'opposer à ce qui a changé.
|
||||
|
||||
Le rapport de qualité comptait des « + » et des « − ». C'est vrai mais
|
||||
insuffisant : un champ qui cesse d'être stocké disparaît de la base sans
|
||||
rien perdre — il est calculé maintenant. Compté comme une perte, il
|
||||
alarme ; ignoré, il masquerait une vraie perte. Il faut un troisième
|
||||
état, et OpenUpgrade le connaît déjà.
|
||||
|
||||
Chaque module cœur d'Odoo porte, dans OpenUpgrade, un fichier
|
||||
`upgrade_analysis.txt` qui liste ce que le palier change :
|
||||
|
||||
---Models in module 'account'---
|
||||
obsolete model account.unreconcile [transient]
|
||||
new model product.combo (renamed from pos.combo in module point_of_sale)
|
||||
---Fields in module 'account'---
|
||||
account / account.account / code (char) : not stored anymore
|
||||
account / account.cash.rounding / loss_account_id (many2one) : needs
|
||||
conversion to v18-style company dependent
|
||||
|
||||
C'est la même information que la page « coverage analysis » publiée par
|
||||
l'OCA — elle est engendrée depuis ces fichiers — mais lue dans le dépôt,
|
||||
donc sans réseau, et à la version exacte du checkout.
|
||||
|
||||
Ce qu'on n'y trouvera pas
|
||||
-------------------------
|
||||
Ces fichiers ne couvrent que les modules CŒUR d'Odoo. Un champ d'un
|
||||
module OCA ou maison n'y sera jamais, et son absence ne veut donc pas
|
||||
dire « perte non déclarée » au même titre. Le rapport le dit plutôt que
|
||||
de laisser conclure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
REPO_ROOT = os.path.normpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
)
|
||||
|
||||
# `new model x.y [transient]` ou `new model x.y (renamed from a.b in module m)`
|
||||
MODELE_NEUF = re.compile(
|
||||
r"^new model (\S+)(?:\s+\(renamed from (\S+) in module (\S+)\))?"
|
||||
)
|
||||
MODELE_OBSOLETE = re.compile(r"^obsolete model (\S+)")
|
||||
# `module / model / champ (type) : description` — le type est absent sur
|
||||
# les pseudo-champs comme `_order`, et le « : » n'est pas toujours espacé.
|
||||
CHAMP = re.compile(
|
||||
r"^(\S+)\s+/\s+(\S+)\s+/\s+(\S+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$"
|
||||
)
|
||||
# `module is now 'sale' ('point_of_sale')`
|
||||
DEPLACE = re.compile(r"module is now '([^']+)'")
|
||||
|
||||
SECTIONS = {
|
||||
"Models": "model",
|
||||
"Fields": "field",
|
||||
"XML records": "xml",
|
||||
}
|
||||
|
||||
|
||||
def scripts_dir(version, root=None):
|
||||
"""Où vivent les analyses pour ce palier d'ARRIVÉE."""
|
||||
return os.path.join(
|
||||
root or REPO_ROOT,
|
||||
f"odoo{version}.0",
|
||||
"OCA_OpenUpgrade",
|
||||
"openupgrade_scripts",
|
||||
"scripts",
|
||||
)
|
||||
|
||||
|
||||
def analysis_files(version, root=None):
|
||||
"""Les `upgrade_analysis.txt` de ce palier, triés.
|
||||
|
||||
On filtre sur le dossier de version : à côté des `18.0.1.0` vivent un
|
||||
`tests` et, en 14, un `0.0` — les lire mélangerait les paliers.
|
||||
"""
|
||||
base = scripts_dir(version, root)
|
||||
trouves = []
|
||||
if not os.path.isdir(base):
|
||||
return trouves
|
||||
prefixe = f"{version}."
|
||||
for module in sorted(os.listdir(base)):
|
||||
dossier = os.path.join(base, module)
|
||||
if not os.path.isdir(dossier):
|
||||
continue
|
||||
for release in sorted(os.listdir(dossier)):
|
||||
if not release.startswith(prefixe):
|
||||
continue
|
||||
chemin = os.path.join(dossier, release, "upgrade_analysis.txt")
|
||||
if os.path.isfile(chemin):
|
||||
trouves.append(chemin)
|
||||
return trouves
|
||||
|
||||
|
||||
_CACHE_MODULES = {}
|
||||
|
||||
|
||||
def analysed_modules(version, root=None):
|
||||
"""Les modules cœur qu'OpenUpgrade analyse à ce palier.
|
||||
|
||||
Les noms de dossiers SONT la liste. Elle sert à distinguer « aucune
|
||||
déclaration » de « hors de son champ » : OpenUpgrade ne dit rien d'un
|
||||
module OCA, et le compter comme non déclaré accuserait à tort.
|
||||
"""
|
||||
if version in _CACHE_MODULES:
|
||||
return _CACHE_MODULES[version]
|
||||
base = scripts_dir(version, root)
|
||||
trouves = set()
|
||||
if os.path.isdir(base):
|
||||
trouves = {
|
||||
nom
|
||||
for nom in os.listdir(base)
|
||||
if os.path.isdir(os.path.join(base, nom))
|
||||
}
|
||||
if root is None:
|
||||
_CACHE_MODULES[version] = trouves
|
||||
return trouves
|
||||
|
||||
|
||||
def blank_index():
|
||||
return {
|
||||
"models_new": set(),
|
||||
"models_obsolete": set(),
|
||||
"models_renamed": {},
|
||||
"fields_new": set(),
|
||||
"fields_del": set(),
|
||||
"fields_unstored": set(),
|
||||
"fields_stored": set(),
|
||||
"fields_moved": {},
|
||||
"fields_company_dependent": set(),
|
||||
"fields_other": {},
|
||||
"xml_new": 0,
|
||||
"xml_del": 0,
|
||||
"modules": 0,
|
||||
}
|
||||
|
||||
|
||||
def classify(description):
|
||||
"""La catégorie d'un changement de champ, d'après sa description.
|
||||
|
||||
L'ordre compte : « NEW relation: … » commence par NEW, et
|
||||
« not stored anymore » contient « stored ». On teste donc du plus
|
||||
précis au plus général plutôt que par appartenance de sous-chaîne.
|
||||
"""
|
||||
texte = description.strip()
|
||||
if texte.startswith("NEW"):
|
||||
return "new"
|
||||
if texte.startswith("DEL"):
|
||||
return "del"
|
||||
if texte.startswith("not stored anymore"):
|
||||
return "unstored"
|
||||
if texte.startswith("is now stored"):
|
||||
return "stored"
|
||||
if "needs conversion to" in texte and "company dependent" in texte:
|
||||
return "company_dependent"
|
||||
if DEPLACE.search(texte):
|
||||
return "moved"
|
||||
return "other"
|
||||
|
||||
|
||||
def parse(texte, index=None):
|
||||
"""Lire UN fichier d'analyse dans l'index (créé au besoin)."""
|
||||
index = blank_index() if index is None else index
|
||||
section = None
|
||||
for ligne in texte.splitlines():
|
||||
entete = re.match(r"^---(.+?) in module '([^']+)'---", ligne)
|
||||
if entete:
|
||||
section = SECTIONS.get(entete.group(1))
|
||||
continue
|
||||
if not ligne.strip():
|
||||
continue
|
||||
if section == "model":
|
||||
_read_model(ligne, index)
|
||||
elif section == "field":
|
||||
_read_field(ligne, index)
|
||||
elif section == "xml":
|
||||
if ligne.startswith("NEW"):
|
||||
index["xml_new"] += 1
|
||||
elif ligne.startswith("DEL"):
|
||||
index["xml_del"] += 1
|
||||
return index
|
||||
|
||||
|
||||
def _read_model(ligne, index):
|
||||
trouve = MODELE_NEUF.match(ligne)
|
||||
if trouve:
|
||||
index["models_new"].add(trouve.group(1))
|
||||
if trouve.group(2):
|
||||
# Le renommage se lit du côté du NOUVEAU modèle : c'est là
|
||||
# qu'OpenUpgrade note d'où il vient. L'ancien n'apparaît nulle
|
||||
# part comme « obsolete », donc sans cette ligne sa
|
||||
# disparition passerait pour une perte sèche.
|
||||
index["models_renamed"][trouve.group(2)] = trouve.group(1)
|
||||
return
|
||||
trouve = MODELE_OBSOLETE.match(ligne)
|
||||
if trouve:
|
||||
index["models_obsolete"].add(trouve.group(1))
|
||||
|
||||
|
||||
def _read_field(ligne, index):
|
||||
trouve = CHAMP.match(ligne)
|
||||
if not trouve:
|
||||
return
|
||||
_module, modele, champ, _type, description = trouve.groups()
|
||||
if champ.startswith("_"):
|
||||
# `_order`, `_sql_constraints` : ce ne sont pas des champs, et les
|
||||
# compter fausserait le rapprochement avec `ir_model_fields`.
|
||||
return
|
||||
cle = f"{modele}.{champ}"
|
||||
genre = classify(description)
|
||||
if genre == "new":
|
||||
index["fields_new"].add(cle)
|
||||
elif genre == "del":
|
||||
index["fields_del"].add(cle)
|
||||
elif genre == "unstored":
|
||||
index["fields_unstored"].add(cle)
|
||||
elif genre == "stored":
|
||||
index["fields_stored"].add(cle)
|
||||
elif genre == "company_dependent":
|
||||
index["fields_company_dependent"].add(cle)
|
||||
elif genre == "moved":
|
||||
index["fields_moved"][cle] = DEPLACE.search(description).group(1)
|
||||
else:
|
||||
index["fields_other"].setdefault(cle, []).append(description.strip())
|
||||
|
||||
|
||||
def load(version, root=None):
|
||||
"""L'index complet d'un palier. `modules` à 0 = rien de lisible."""
|
||||
index = blank_index()
|
||||
for chemin in analysis_files(version, root):
|
||||
try:
|
||||
with open(chemin, "r", encoding="utf-8") as handle:
|
||||
parse(handle.read(), index)
|
||||
except OSError:
|
||||
continue
|
||||
index["modules"] += 1
|
||||
return index
|
||||
|
||||
|
||||
def model_change(nom, index):
|
||||
"""Ce qu'OpenUpgrade déclare pour ce modèle disparu, ou None."""
|
||||
if nom in index["models_renamed"]:
|
||||
return ("renamed", index["models_renamed"][nom])
|
||||
if nom in index["models_obsolete"]:
|
||||
return ("obsolete", None)
|
||||
return None
|
||||
|
||||
|
||||
def field_change(cle, index):
|
||||
"""Ce qu'OpenUpgrade déclare pour ce champ disparu, ou None.
|
||||
|
||||
« unstored » est le cas qui justifie tout l'outil : le champ EXISTE
|
||||
toujours, il n'a plus de colonne parce qu'il est calculé. Ce n'est ni
|
||||
un gain ni une perte — c'est une transformation, et la confondre avec
|
||||
une perte fait crier au loup à chaque palier.
|
||||
"""
|
||||
if cle in index["fields_del"]:
|
||||
return ("del", None)
|
||||
if cle in index["fields_unstored"]:
|
||||
return ("unstored", None)
|
||||
if cle in index["fields_company_dependent"]:
|
||||
return ("company_dependent", None)
|
||||
if cle in index["fields_moved"]:
|
||||
return ("moved", index["fields_moved"][cle])
|
||||
return None
|
||||
0
script/analyse/shell/view_file_arch.py
Normal file → Executable file
0
script/analyse/shell/view_file_arch.py
Normal file → Executable 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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
206
script/odoo/migration/check_hidden_models.py
Executable file
206
script/odoo/migration/check_hidden_models.py
Executable 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())
|
||||
667
script/odoo/migration/check_stale_scss.py
Executable file
667
script/odoo/migration/check_stale_scss.py
Executable 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())
|
||||
207
script/odoo/migration/check_stale_scss_tui.py
Executable file
207
script/odoo/migration/check_stale_scss_tui.py
Executable 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
|
||||
390
script/odoo/migration/cow_drift.py
Executable file
390
script/odoo/migration/cow_drift.py
Executable 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())
|
||||
202
script/odoo/migration/cow_drift_tui.py
Executable file
202
script/odoo/migration/cow_drift_tui.py
Executable 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
|
||||
606
script/odoo/migration/database_cleanup.py
Executable file
606
script/odoo/migration/database_cleanup.py
Executable 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())
|
||||
249
script/odoo/migration/dms_access_repair.py
Executable file
249
script/odoo/migration/dms_access_repair.py
Executable 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())
|
||||
0
script/odoo/migration/fix_migration_odoo140_to_odoo150.py
Normal file → Executable file
0
script/odoo/migration/fix_migration_odoo140_to_odoo150.py
Normal file → Executable file
36
script/odoo/migration/fix_migration_odoo170_to_odoo180.sql
Normal file
36
script/odoo/migration/fix_migration_odoo170_to_odoo180.sql
Normal 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 $$;
|
||||
364
script/odoo/migration/fix_view_type.py
Executable file
364
script/odoo/migration/fix_view_type.py
Executable 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())
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
198
script/odoo/migration/reset_stale_cow_tui.py
Executable file
198
script/odoo/migration/reset_stale_cow_tui.py
Executable 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
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
1016
script/odoo/migration/smoke_internal_ui.py
Executable file
1016
script/odoo/migration/smoke_internal_ui.py
Executable file
File diff suppressed because it is too large
Load diff
914
script/odoo/migration/smoke_public_url.py
Executable file
914
script/odoo/migration/smoke_public_url.py
Executable 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())
|
||||
|
|
@ -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
128
script/todo/auto_ask.py
Normal 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
|
||||
|
|
@ -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
533
script/todo/migration_status.py
Executable 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())
|
||||
420
script/todo/migration_status_tui.py
Normal file
420
script/todo/migration_status_tui.py
Normal 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
|
||||
|
|
@ -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
271
test/test_auto_execute.py
Executable 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()
|
||||
241
test/test_check_hidden_models.py
Normal file
241
test/test_check_hidden_models.py
Normal 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()
|
||||
1334
test/test_check_migration_quality.py
Normal file
1334
test/test_check_migration_quality.py
Normal file
File diff suppressed because it is too large
Load diff
423
test/test_check_stale_scss.py
Executable file
423
test/test_check_stale_scss.py
Executable 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
972
test/test_database_cleanup.py
Executable 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()
|
||||
182
test/test_dms_access_repair.py
Normal file
182
test/test_dms_access_repair.py
Normal 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()
|
||||
254
test/test_error_retry_loop.py
Normal file
254
test/test_error_retry_loop.py
Normal 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
604
test/test_fix_view_type.py
Normal 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()
|
||||
119
test/test_migration_form_back.py
Normal file
119
test/test_migration_form_back.py
Normal 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()
|
||||
1611
test/test_migration_status.py
Normal file
1611
test/test_migration_status.py
Normal file
File diff suppressed because it is too large
Load diff
244
test/test_migration_tools_i18n.py
Normal file
244
test/test_migration_tools_i18n.py
Normal 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()
|
||||
186
test/test_openupgrade_analysis.py
Normal file
186
test/test_openupgrade_analysis.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Lire ce qu'OpenUpgrade déclare, sans le déformer.
|
||||
|
||||
La catégorie qui porte tout l'outil est « unstored » : le champ EXISTE
|
||||
encore, il n'a plus de colonne parce qu'il est calculé. Le ranger avec
|
||||
les suppressions ferait crier au loup à chaque palier ; l'oublier
|
||||
masquerait une vraie perte.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
)
|
||||
|
||||
from script.analyse import openupgrade_analysis as oa # noqa: E402
|
||||
|
||||
EXEMPLE = """---Models in module 'account'---
|
||||
obsolete model account.unreconcile [transient]
|
||||
new model account.code.mapping [sql_view]
|
||||
new model product.combo (renamed from pos.combo in module point_of_sale)
|
||||
---Fields in module 'account'---
|
||||
account / account.account / _order : _order is now 'code' ('id')
|
||||
account / account.account / code (char) : not stored anymore
|
||||
account / account.account / code_store (char) : NEW
|
||||
account / account.journal / secure_sequence_id (many2one) : DEL relation: ir.sequence
|
||||
account / account.account / company_id (many2one) : is now stored
|
||||
account / account.cash.rounding / loss_account_id (many2one) : needs conversion to v18-style company dependent
|
||||
account / pos.combo / base_price (float) : module is now 'product' ('point_of_sale')
|
||||
account / account.journal / type (selection) : selection_keys added: [credit]
|
||||
---XML records in module 'account'---
|
||||
NEW ir.ui.view: account.view_x
|
||||
DEL ir.ui.view: account.view_y
|
||||
DEL ir.ui.view: account.view_z
|
||||
"""
|
||||
|
||||
|
||||
class TestParsing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.index = oa.parse(EXEMPLE)
|
||||
|
||||
def test_obsolete_and_new_models(self):
|
||||
self.assertIn("account.unreconcile", self.index["models_obsolete"])
|
||||
self.assertIn("account.code.mapping", self.index["models_new"])
|
||||
|
||||
def test_a_rename_is_read_from_the_NEW_side(self):
|
||||
# OpenUpgrade ne note le renommage que sur le nouveau modèle ;
|
||||
# l'ancien n'apparaît jamais comme « obsolete ». Sans cette
|
||||
# lecture, sa disparition passerait pour une perte sèche.
|
||||
self.assertEqual(
|
||||
self.index["models_renamed"].get("pos.combo"), "product.combo"
|
||||
)
|
||||
|
||||
def test_each_field_lands_in_its_category(self):
|
||||
for cle, categorie in (
|
||||
("account.account.code", "fields_unstored"),
|
||||
("account.account.code_store", "fields_new"),
|
||||
("account.journal.secure_sequence_id", "fields_del"),
|
||||
("account.account.company_id", "fields_stored"),
|
||||
(
|
||||
"account.cash.rounding.loss_account_id",
|
||||
"fields_company_dependent",
|
||||
),
|
||||
):
|
||||
self.assertIn(cle, self.index[categorie], f"{cle} → {categorie}")
|
||||
|
||||
def test_a_moved_field_keeps_its_new_module(self):
|
||||
self.assertEqual(
|
||||
self.index["fields_moved"].get("pos.combo.base_price"), "product"
|
||||
)
|
||||
|
||||
def test_pseudo_fields_are_not_fields(self):
|
||||
# `_order` n'est pas dans `ir_model_fields` : le compter fausserait
|
||||
# le rapprochement avec la base.
|
||||
for cle in list(self.index["fields_other"]) + list(
|
||||
self.index["fields_new"]
|
||||
):
|
||||
self.assertNotIn("._order", cle)
|
||||
|
||||
def test_an_unclassified_change_is_kept_not_dropped(self):
|
||||
# « selection_keys added » n'a pas de catégorie : le perdre
|
||||
# silencieusement ferait mentir les totaux.
|
||||
self.assertIn("account.journal.type", self.index["fields_other"])
|
||||
|
||||
def test_xml_records_are_counted(self):
|
||||
self.assertEqual(self.index["xml_new"], 1)
|
||||
self.assertEqual(self.index["xml_del"], 2)
|
||||
|
||||
|
||||
class TestClassify(unittest.TestCase):
|
||||
def test_new_with_details_is_still_new(self):
|
||||
self.assertEqual(
|
||||
oa.classify("NEW relation: res.company, required"), "new"
|
||||
)
|
||||
|
||||
def test_not_stored_is_not_confused_with_stored(self):
|
||||
# « not stored anymore » CONTIENT « stored » : tester par
|
||||
# appartenance de sous-chaîne inverserait le sens.
|
||||
self.assertEqual(oa.classify("not stored anymore"), "unstored")
|
||||
self.assertEqual(oa.classify("is now stored"), "stored")
|
||||
|
||||
def test_an_unknown_wording_falls_back_to_other(self):
|
||||
self.assertEqual(oa.classify("something brand new"), "other")
|
||||
|
||||
|
||||
class TestFindingTheFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.racine = tempfile.mkdtemp()
|
||||
base = os.path.join(
|
||||
self.racine,
|
||||
"odoo18.0",
|
||||
"OCA_OpenUpgrade",
|
||||
"openupgrade_scripts",
|
||||
"scripts",
|
||||
)
|
||||
for module, release in (
|
||||
("account", "18.0.1.0"),
|
||||
("account", "17.0.1.0"),
|
||||
("sale", "18.0.2.0"),
|
||||
("tests", "18.0.1.0"),
|
||||
):
|
||||
dossier = os.path.join(base, module, release)
|
||||
os.makedirs(dossier)
|
||||
with open(
|
||||
os.path.join(dossier, "upgrade_analysis.txt"),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
handle.write(EXEMPLE)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.racine)
|
||||
|
||||
def test_only_the_target_version_is_read(self):
|
||||
# À côté des `18.0.x` vivent des dossiers d'autres paliers : les
|
||||
# mélanger attribuerait à la 18 des changements de la 17.
|
||||
trouves = oa.analysis_files(18, self.racine)
|
||||
self.assertTrue(all("18.0" in c for c in trouves), trouves)
|
||||
self.assertEqual(len(trouves), 3)
|
||||
|
||||
def test_a_missing_checkout_is_empty_not_a_crash(self):
|
||||
self.assertEqual(oa.analysis_files(13, self.racine), [])
|
||||
self.assertEqual(oa.load(13, self.racine)["modules"], 0)
|
||||
|
||||
def test_the_module_list_comes_from_the_directories(self):
|
||||
modules = oa.analysed_modules(18, self.racine)
|
||||
self.assertIn("account", modules)
|
||||
self.assertIn("sale", modules)
|
||||
|
||||
def test_loading_counts_the_modules_it_read(self):
|
||||
self.assertEqual(oa.load(18, self.racine)["modules"], 3)
|
||||
|
||||
|
||||
class TestTheRealCheckout(unittest.TestCase):
|
||||
"""Le dépôt lui-même, quand il est là. Sinon on ne prétend rien."""
|
||||
|
||||
def setUp(self):
|
||||
if not oa.analysis_files(18):
|
||||
self.skipTest("OCA_OpenUpgrade absent du checkout 18.0")
|
||||
|
||||
def test_it_reads_hundreds_of_core_modules(self):
|
||||
index = oa.load(18)
|
||||
self.assertGreater(index["modules"], 300)
|
||||
|
||||
def test_it_finds_the_company_dependent_conversion(self):
|
||||
# C'est la refonte qui a fait disparaître `ir_property` en 18 :
|
||||
# si l'outil ne la voit pas, il ne sert à rien sur ce palier-là.
|
||||
index = oa.load(18)
|
||||
self.assertTrue(
|
||||
any(
|
||||
cle.startswith("account.cash.rounding.")
|
||||
for cle in index["fields_company_dependent"]
|
||||
),
|
||||
sorted(index["fields_company_dependent"])[:5],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
595
test/test_prompt_defaults.py
Normal file
595
test/test_prompt_defaults.py
Normal 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()
|
||||
173
test/test_reset_stale_cow_prompt.py
Executable file
173
test/test_reset_stale_cow_prompt.py
Executable 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
175
test/test_reset_stale_cow_tui.py
Executable 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
162
test/test_script_permissions.py
Executable 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()
|
||||
918
test/test_smoke_internal_ui.py
Normal file
918
test/test_smoke_internal_ui.py
Normal 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
511
test/test_smoke_public_url.py
Executable 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()
|
||||
196
test/test_target_version_default.py
Executable file
196
test/test_target_version_default.py
Executable 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
184
test/test_theme_on_error.py
Normal 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()
|
||||
164
test/test_todo_upgrade_archive.py
Normal file
164
test/test_todo_upgrade_archive.py
Normal 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()
|
||||
223
test/test_todo_upgrade_cow_prompt.py
Normal file
223
test/test_todo_upgrade_cow_prompt.py
Normal 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()
|
||||
439
test/test_todo_upgrade_steps.py
Normal file
439
test/test_todo_upgrade_steps.py
Normal 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()
|
||||
488
test/test_uninstall_addons_theme.py
Executable file
488
test/test_uninstall_addons_theme.py
Executable 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()
|
||||
Loading…
Reference in a new issue