erplibre/script/odoo/migration/smoke_internal_ui.py
Mathieu Benoit e214df7329 [FIX] smoke: judge the back office AFTER the repair, and say why it failed
No timeout was hit and no test was skipped. The back office failed for the
same reason the site did: the COW copy of website.submenu breaks
/web/login exactly as it breaks the public pages — both go through the
same layout. The pass simply ran BEFORE the reset, and unlike the URLs it
was never looked at again. It is now, on the same second server.

« Session expired » described the consequence and hid the cause. The login
page returned 500 and nothing said so, so one looks at the password. The
status of that page, and a missing CSRF token, are now named.

Diagnosing this, I started Odoo 18 on a 17 database and got 500 on all
thirty-seven URLs and on /web/login: a report that says the site is
entirely broken when nothing is. database_cleanup already refused that;
the smoke tools now share the same guard rather than reimplementing it.

--- FR ---

[FIX] smoke : juger le back-office APRÈS la réparation, et dire pourquoi

Aucun délai n'a été atteint et aucun test n'a été sauté. Le back-office a
échoué pour la raison même qui cassait le site : la copie COW de
website.submenu casse /web/login comme elle casse les pages publiques —
les deux passent par le même gabarit. La passe tournait avant la
réinitialisation et, contrairement aux URL, n'était jamais revue.

« Session expired » décrivait la conséquence et cachait la cause : la page
de connexion rendait 500 sans que rien ne le dise, et l'on cherchait du
côté du mot de passe.

En diagnostiquant, j'ai lancé un Odoo 18 sur une base 17 : 500 partout, un
rapport qui déclare le site entièrement cassé quand rien ne l'est.
database_cleanup refusait déjà cela ; les outils de fumée partagent
désormais sa garde au lieu d'en écrire une seconde.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

1016 lines
36 KiB
Python
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Log in as `test` and open the first page of every Odoo app.
Why this exists
---------------
The public smoke test opens what a visitor can reach. It says nothing about
the BACK OFFICE, which is where a migration does most of its damage: a field
dropped from a model but still named in a form view, a widget renamed between
two versions, a stored compute that no longer resolves. None of it stops the
module loading — it stops the day someone opens the app.
Why the `test` user
-------------------
Neutralizing a database installs `user_test`, which copies the SYSTEM user's
groups onto a `test` / `test` login (see its `post_init_hook`). The module is
uninstalled right after, but the user survives — its uninstall hook only
warns. So a neutralized database, and only a neutralized one, can be browsed
without knowing anyone's real password. The tool checks for that user rather
than trusting a flag: a resumed migration may have skipped neutralization,
and a promise in a progression file is not a login.
What is actually exercised
--------------------------
For each app — a root menu — its first page, meaning the first menu below it
that carries an action, which is what the web client opens on a click. Two
calls per app, both server-side:
- ``get_views`` (12→15: ``load_views``) renders EVERY view arch of the
action. This is where a migration's damage surfaces, and it surfaces as an
exception rather than an empty screen.
- ``web_search_read`` (12: ``search_read``) loads the first page of records.
A view can render on an empty model and still explode on real data.
Exit codes: 0 every app answered, 1 some failed, 2 the tool failed.
"""
import argparse
import ast
import json
import os
import re
import sys
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
DEFAULT_LOGIN = "test"
DEFAULT_PASSWORD = "test"
DEFAULT_RECORD_LIMIT = 20
# Deux façons d'écrire le même champ caché selon la version du gabarit :
# l'ordre des attributs n'est pas garanti, et un seul motif en manquait la
# moitié — la connexion échouait alors sans rien dire d'utile.
RE_CSRF = re.compile(
r"""name=["']csrf_token["']\s+value=["']([^"']+)["']"""
r"""|value=["']([^"']+)["']\s+name=["']csrf_token["']"""
)
RE_DB_FIELD = re.compile(r"""name=["']db["']""")
def user_state(database, login=DEFAULT_LOGIN, run_psql=None):
"""« present », « absent » ou « unknown ». La nuance n'est pas cosmétique.
On INTERROGE plutôt que de croire un drapeau : une migration reprise a
pu sauter la neutralisation, et `state_1_neutralize_database` dirait
quand même « fait ».
Mais `run_psql` rend une liste vide DANS LES DEUX CAS — aucune ligne, ou
requête refusée. Mesuré ici même : un « id » ambigu passait pour « pas
d'utilisateur test », et l'outil annonçait tranquillement que la base
n'avait pas été neutralisée. Un COUNT distingue les deux : il rend
toujours une ligne quand la requête aboutit.
"""
if run_psql is None:
from smoke_public_url import run_psql
sql = (
"SELECT count(*) FROM res_users" f" WHERE login = '{login}' AND active"
)
try:
rows = run_psql(database, sql)
except Exception:
return "unknown"
if not rows:
return "unknown"
try:
return "present" if int(rows[0][0]) else "absent"
except (TypeError, ValueError, IndexError):
return "unknown"
def user_exists(database, login=DEFAULT_LOGIN, run_psql=None):
"""Raccourci : seul « present » vaut oui."""
return user_state(database, login, run_psql=run_psql) == "present"
class Session:
"""Un client HTTP qui garde son cookie de session.
`urllib` sans gestionnaire de cookies renvoie chaque appel comme un
inconnu : on se connecte, puis on interroge en anonyme sans qu'aucune
erreur ne le signale — juste des résultats vides.
"""
def __init__(self, base_url, timeout=60):
import http.cookiejar
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.jar)
)
self.uid = None
self.last_url = None
# Le nom des méthodes a changé en cours de route (get_views en 16,
# load_views avant). On retient celui qui a répondu : chercher à
# chaque appel doublerait le nombre de requêtes.
self._views_method = None
self._read_method = None
def open(self, path, data=None, headers=None):
url = self.base_url + path
request = urllib.request.Request(url, data=data, headers=headers or {})
# Où l'on a ATTERRI, pas où l'on voulait aller : urllib suit les
# redirections en silence, et une session perdue renvoie /my vers
# /web/login avec un 200 parfaitement rassurant.
self.last_url = url
try:
with self.opener.open(request, timeout=self.timeout) as answer:
self.last_url = answer.geturl()
return answer.getcode(), answer.read().decode(
"utf-8", errors="replace"
)
except urllib.error.HTTPError as exc:
self.last_url = exc.geturl()
return exc.code, exc.read().decode("utf-8", errors="replace")
except Exception as exc:
return 0, str(exc)
def rpc(self, path, params):
"""Un appel JSON-RPC. Rend (résultat, erreur) — jamais une exception.
Une erreur Odoo arrive avec le statut 200 et un objet `error` dans
le corps : la traiter comme un succès ferait passer une vue cassée
pour une vue vide.
"""
payload = json.dumps(
{
"jsonrpc": "2.0",
"method": "call",
"params": params,
"id": 1,
}
).encode("utf-8")
status, body = self.open(
path, data=payload, headers={"Content-Type": "application/json"}
)
if not status:
return None, {"name": "transport", "message": body[:200]}
try:
answer = json.loads(body)
except ValueError:
return None, {
"name": f"HTTP {status}",
"message": body.strip()[:200],
}
if "error" in answer:
error = answer["error"] or {}
data = error.get("data") or {}
return None, {
"name": data.get("name") or error.get("message") or "error",
"message": (
data.get("message") or error.get("message") or ""
).strip(),
"debug": data.get("debug") or "",
}
return answer.get("result"), None
def call_kw(self, model, method, args, kwargs=None):
return self.rpc(
"/web/dataset/call_kw",
{
"model": model,
"method": method,
"args": args,
"kwargs": kwargs or {},
},
)
def log_in(self, database, login, password):
"""Se connecter par le formulaire, comme le ferait un navigateur.
Le jeton CSRF n'est pas une formalité : sans lui Odoo rend la page
de connexion avec un statut 200, et l'on croirait être entré.
"""
status, body = self.open("/web/login")
if not status:
return False, t("The server did not serve the login page.")
if status >= 400:
# DIRE le statut. Sans cela on rapportait « Session expired »,
# qui décrit la conséquence et cache la cause : la page de
# connexion elle-même rendait 500. Mesuré — une copie COW de
# `website.submenu` casse /web/login comme elle casse le site,
# et l'on cherchait du côté du mot de passe.
return False, (
f"{t('The login page itself failed')} : HTTP {status} —"
f" {error_from_page(body)}"
)
match = RE_CSRF.search(body)
if not match:
# Sans jeton, Odoo refuse le POST et rend la page de connexion
# avec un statut 200 : on croirait à un mot de passe refusé.
return False, t("No CSRF token on the login page")
fields = {
"login": login,
"password": password,
"redirect": "",
}
if match:
fields["csrf_token"] = match.group(1) or match.group(2)
# Le champ `db` n'existe que si le serveur en propose plusieurs.
# L'envoyer toujours n'est pas anodin : certaines versions le
# refusent quand la liste des bases est masquée.
if RE_DB_FIELD.search(body):
fields["db"] = database
self.open(
"/web/login",
data=urllib.parse.urlencode(fields).encode("utf-8"),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
# On ne LIT pas la redirection pour juger : on demande au serveur
# qui il croit que nous sommes.
info, error = self.rpc("/web/session/get_session_info", {})
if error:
return False, f"{error['name']} : {error['message']}"[:200]
uid = (info or {}).get("uid")
if not uid:
return False, t("Wrong login or password for user")
self.uid = uid
return True, ""
def views_of(self, model, lst_view, context=None):
"""Faire rendre les arch côté serveur. C'est LE test.
`get_views` depuis la 16, `load_views` avant, et la 18 n'a plus que
le premier. On essaie le moderne, on retombe sur l'ancien.
"""
kwargs = {"views": lst_view, "options": {}, "context": context or {}}
lst_try = (
[self._views_method]
if self._views_method
else ["get_views", "load_views"]
)
last = None
for method in lst_try:
result, error = self.call_kw(model, method, [], dict(kwargs))
if error is None:
self._views_method = method
return result, None
if not _is_missing_method(error):
return None, error
last = error
return None, last
def first_page(self, model, domain, context, limit, lst_field=None):
"""Charger la première page d'enregistrements.
`search_read` et non `web_search_read` : ce dernier a changé de
signature en cours de route — `fields` (une liste) est devenu
`specification` (un dictionnaire) en 17. Mesuré sur une base 18 :
seize applications sur vingt-cinq échouaient sur
« unexpected keyword argument 'fields' », c'est-à-dire sur MON
appel, pas sur la base. `search_read` n'a pas bougé depuis la 12.
Une vue peut se rendre sur un modèle vide et exploser sur de
vraies données : un champ calculé qui ne résout plus ne se voit
qu'une fois qu'il y a quelque chose à calculer.
"""
return self.call_kw(
model,
"search_read",
[domain, lst_field or ["display_name"]],
{"limit": limit, "context": context or {}},
)
def known_fields(self, model, context=None):
"""Les champs que le modèle porte VRAIMENT, d'après le serveur."""
result, error = self.call_kw(
model, "fields_get", [[], ["type"]], {"context": context or {}}
)
if error:
return None, error
return sorted(result or {}), None
def arch_fields(views):
"""Les champs que la première page affiche, et RIEN de plus.
Analyse XML et non expression régulière, pour une raison mesurée : une
vue formulaire embarque les sous-vues de ses one2many, et un
`<field name="product_id"/>` qui s'y trouve appartient à la LIGNE de
facture, pas à la facture. Le motif à plat les ramassait aussi et
rapportait neuf applications « nommant un champ que le modèle n'a
plus » — toutes fausses. On ne descend donc jamais dans un `<field>`.
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 une migration se manifeste.
"""
import xml.etree.ElementTree as ET
lst_arch = []
def descendre(noeud):
if isinstance(noeud, dict):
arch = noeud.get("arch")
if isinstance(arch, str):
lst_arch.append(arch)
for valeur in noeud.values():
descendre(valeur)
elif isinstance(noeud, list):
for valeur in noeud:
descendre(valeur)
descendre(views)
lst_name = []
def parcourir(element):
for enfant in element:
if enfant.tag == "field":
nom = enfant.get("name")
# Un champ pointé vise un sous-modèle : le demander au
# modèle principal serait une erreur de NOTRE fait.
if nom and "." not in nom and nom not in lst_name:
lst_name.append(nom)
# On s'arrête là : ce qui est SOUS un champ appartient au
# modèle de ce champ, pas au nôtre.
continue
parcourir(enfant)
for arch in lst_arch:
try:
racine = ET.fromstring(arch)
except ET.ParseError:
# Un arch illisible n'est pas une liste de champs vide : c'est
# une inconnue. La taire vaut mieux que d'inventer.
continue
if racine.tag == "field":
nom = racine.get("name")
if nom and "." not in nom and nom not in lst_name:
lst_name.append(nom)
else:
parcourir(racine)
return lst_name
def model_is_unregistered(error):
"""L'erreur dit-elle « ce modèle n'existe pas dans ce registre » ?
Odoo rend un 404 nu quand `call_kw` vise un modèle absent du registre :
« 404 Not Found: The requested URL was not found ». C'est illisible, et
pourtant c'est la trouvaille la plus nette d'une migration — le module
est installé dans la base, mais son code n'est plus dans l'addons path
de la version cible. Mesuré : cinq applications sur vingt-cinq.
"""
return "notfound" in (error.get("name") or "").lower().replace(".", "")
def _is_missing_method(error):
"""L'erreur dit-elle « cette méthode n'existe pas » ?
Il faut la distinguer d'une vraie panne : retomber sur l'ancien nom
après une AccessError masquerait l'AccessError derrière un
« méthode inconnue » qui n'a rien à voir.
"""
texte = f"{error.get('name', '')} {error.get('message', '')}".lower()
return (
"not exist" in texte
or "unknown method" in texte
or "attributeerror" in texte
or "has no attribute" in texte
)
def menu_rows(session):
"""Les menus que CET utilisateur voit, dans l'ordre où il les voit.
Par RPC et non par SQL : `ir.ui.menu` filtre selon les droits et rend
les noms traduits. Lire la table donnerait des menus inaccessibles et,
depuis la 16, un nom en jsonb qu'il faudrait décoder à la main.
"""
return session.call_kw(
"ir.ui.menu",
"search_read",
[[], ["id", "name", "parent_id", "sequence", "action"]],
{"context": {"lang": "en_US"}},
)
def apps(lst_menu):
"""(application, menu de sa première page) pour chaque application.
La première page d'une application est ce que le client web ouvre au
clic : le premier menu, en descendant, qui porte une action. Une
application qui n'en a aucune n'est pas une erreur — elle n'a rien à
ouvrir, et le dire évite de la chercher.
"""
par_parent = {}
for menu in lst_menu:
parent = menu.get("parent_id")
cle = parent[0] if isinstance(parent, (list, tuple)) else None
par_parent.setdefault(cle, []).append(menu)
for lst in par_parent.values():
lst.sort(key=lambda m: (m.get("sequence") or 0, m.get("id") or 0))
def descendre(menu, vus):
if menu["id"] in vus:
return None
vus.add(menu["id"])
if menu.get("action"):
return menu
for enfant in par_parent.get(menu["id"], []):
trouve = descendre(enfant, vus)
if trouve:
return trouve
return None
resultat = []
for racine in par_parent.get(None, []):
resultat.append((racine, descendre(racine, set())))
return resultat
def actionable(lst_menu):
"""TOUS les menus portant une action, pour le balayage complet."""
return [menu for menu in lst_menu if menu.get("action")]
def split_action(reference):
"""« ir.actions.act_window,42 » -> (« ir.actions.act_window », 42)."""
if not reference or "," not in str(reference):
return None, None
model, _sep, ident = str(reference).partition(",")
try:
return model.strip(), int(ident)
except ValueError:
return model.strip(), None
def literal(value, fallback):
"""Un domaine ou un contexte, s'il est LITTÉRAL. Sinon le repli.
Ils contiennent parfois des expressions Python — `uid`, `context_today`
— que seul le client sait évaluer. Les évaluer ici serait exécuter du
code venu de la base ; les refuser tout court retirerait la moitié des
applications du balayage. On teste donc avec un domaine vide, et on le
DIT dans le rapport.
"""
if not value or value in ("[]", "{}"):
return fallback, False
try:
return ast.literal_eval(value), False
except (ValueError, SyntaxError):
return fallback, True
def view_pairs(view_mode):
"""« list,form » -> [[False, « list »], [False, « form »]].
`qweb` est écarté : son rendu demande un enregistrement précis et un
contexte de rapport, pas une ouverture de menu.
"""
lst = []
for mode in (view_mode or "list,form").split(","):
mode = mode.strip()
if mode and mode != "qweb":
lst.append([False, mode])
return lst or [[False, "form"]]
def blank_entry(app, menu, kind=None, action=None):
"""La forme commune d'un résultat. Une seule, pour un seul rapport.
Le portail et les applications ne se testent pas de la même façon —
l'un par HTTP, l'autre par RPC — mais un lecteur n'a pas à connaître
cette différence pour lire ce qui a cassé.
"""
return {
"app": app,
"menu": menu,
"action": action,
"model": None,
"kind": kind,
"error": None,
"stage": None,
"domain_ignored": False,
"unknown_fields": [],
"fields_read": 0,
"status": None,
}
def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT):
"""Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé."""
resultat = blank_entry(
app["name"], menu["name"], action=menu.get("action")
)
model_action, ident = split_action(menu.get("action"))
resultat["kind"] = model_action
if model_action != "ir.actions.act_window" or not ident:
# Une action client n'a pas d'arch à rendre, une action serveur
# ÉCRIT. Ni l'une ni l'autre ne se teste en ouvrant une page.
return resultat
fields = ["res_model", "view_mode", "domain", "context", "limit"]
lst_action, error = session.call_kw(
"ir.actions.act_window", "read", [[ident], fields]
)
if error:
resultat["error"] = error
resultat["stage"] = "action"
return resultat
if not lst_action:
return resultat
action = lst_action[0]
model = action.get("res_model")
resultat["model"] = model
if not model:
return resultat
domain, brut_domaine = literal(action.get("domain"), [])
context, brut_contexte = literal(action.get("context"), {})
if not isinstance(domain, list):
domain, brut_domaine = [], True
if not isinstance(context, dict):
context, brut_contexte = {}, True
resultat["domain_ignored"] = brut_domaine or brut_contexte
views, error = session.views_of(
model, view_pairs(action.get("view_mode")), context
)
if error:
resultat["error"] = error
resultat["stage"] = (
"registry" if model_is_unregistered(error) else "views"
)
return resultat
# Les champs de la page, et ceux que le modèle porte vraiment. L'écart
# entre les deux EST le dégât d'une migration : une vue qui nomme un
# champ disparu. Certaines versions le laissent passer au rendu et
# n'échouent qu'à la lecture — autant le nommer tout de suite.
lst_arch = arch_fields(views)
lst_known, error = session.known_fields(model, context)
if error:
resultat["error"] = error
resultat["stage"] = "fields"
return resultat
resultat["unknown_fields"] = [
name for name in lst_arch if name not in lst_known
]
lst_read = [name for name in lst_arch if name in lst_known]
resultat["fields_read"] = len(lst_read)
_rows, error = session.first_page(
model,
domain,
context,
min(limit, action.get("limit") or limit),
lst_field=lst_read or ["display_name"],
)
if error:
resultat["error"] = error
resultat["stage"] = "records"
return resultat
PORTAL_KIND = "portal"
DEFAULT_PORTAL_PATHS = ("/my",)
# La page de connexion se reconnaît à SES DEUX champs. Chercher « login »
# seul attraperait la moitié des pages du portail, où le mot est partout.
RE_LOGIN_FORM = re.compile(
r"""name=["']login["'][\s\S]{0,2000}?name=["']password["']""", re.I
)
# La trace d'Odoo dans une page d'erreur : la ligne qui NOMME l'exception.
RE_TRACE = re.compile(r"^\s*([\w.]+(?:Error|Exception)):\s*(.+)$", re.M)
def error_from_page(body):
"""Ce qu'une page d'erreur dit d'elle-même, ou son début à défaut.
Un « HTTP 500 » sans plus arrête l'enquête là où elle commence : Odoo
imprime pourtant l'exception dans la page quand il est en mode debug,
et c'est exactement ce qu'on veut lire.
"""
match = RE_TRACE.search(body or "")
if match:
return f"{match.group(1)}: {match.group(2).strip()}"[:200]
# Retirer style et script AVANT les balises : sinon on rend « html {
# font-size: 14px; } Home Back 500 » — du CSS, mesuré tel quel.
texte = re.sub(
r"<(script|style)[^>]*>[\s\S]*?</\1>", " ", body or "", flags=re.I
)
texte = re.sub(r"<[^>]+>", " ", texte)
texte = " ".join(texte.split())
return texte[:200]
# La dernière ligne d'une trace Python : celle qui NOMME la panne.
RE_LOG_EXCEPTION = re.compile(
r"^(\w[\w.]*(?:Error|Exception|NotFound)):\s*(.*)$", re.M
)
def log_exceptions(lst_log):
"""Les exceptions du journal du serveur, dans l'ordre.
La page d'erreur d'Odoo en production ne montre PAS la trace : on
obtient « 500: Internal Server Error » et rien d'autre. Le journal, lui,
la contient — et c'est la seule chose qui permette de réparer plutôt que
de constater.
"""
texte = "\n".join(lst_log or [])
return [
f"{nom}: {reste.strip()}"[:200]
for nom, reste in RE_LOG_EXCEPTION.findall(texte)
]
# La ligne d'accès de werkzeug : c'est elle qui porte le CHEMIN, donc la
# seule façon de savoir à qui appartient une trace.
RE_ACCESS = re.compile(r'"(?:GET|POST|PUT|DELETE) (\S+) HTTP/[\d.]+" (\d{3})')
def reason_for_path(lst_log, path):
"""La dernière exception loggée AVANT la réponse en échec de ce chemin.
Prendre simplement la dernière trace du journal était faux, et l'essai
réel l'a montré tout de suite : le portail est interrogé en PREMIER,
donc sa trace est la première du fichier, et on lui attribuait celle
d'une application testée bien après. Une cause fausse coûte plus cher
qu'une cause absente — on cherche alors du mauvais côté, avec
confiance.
Une réponse SAINE pour ce chemin efface l'ardoise : ce qui précédait
appartenait à quelqu'un d'autre.
"""
attente = None
prefixe = (path or "").rstrip("/")
for ligne in lst_log or []:
trouve = RE_LOG_EXCEPTION.search(ligne)
if trouve:
attente = f"{trouve.group(1)}: {trouve.group(2).strip()}"[:200]
continue
acces = RE_ACCESS.search(ligne)
if not acces:
continue
chemin = acces.group(1).split("?")[0]
vise = chemin == path or chemin.startswith(prefixe + "/")
if vise and int(acces.group(2)) >= 400:
return attente
attente = None
return None
def attach_log_reason(lst_failure, lst_log):
"""Donner à une page en échec la trace qui la concerne, ELLE.
On ne touche jamais à un message venu d'un appel RPC : celui-ci nomme
précisément sa propre erreur, alors que le journal est un flux partagé.
Seules les pages ouvertes en HTTP ont besoin de cette recherche — leur
page d'erreur, en production, ne montre rien d'utile.
"""
for item in lst_failure:
if item.get("kind") != PORTAL_KIND:
continue
raison = reason_for_path(lst_log, item.get("menu") or "")
if raison:
(item.get("error") or {})["log"] = raison
return lst_failure
def check_portal(session, path):
"""Ouvrir une page du portail AVEC la session connectée.
Le portail 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. Ni le sitemap — qui ne liste pas /my, la page
demandant une session — ni les appels RPC des applications ne passent
par là. Une migration peut donc le casser sans que rien ne le dise.
"""
resultat = blank_entry(t("Portal"), path, kind=PORTAL_KIND)
status, body = session.open(path)
resultat["status"] = status
if not status:
resultat["error"] = {
"name": "transport",
"message": (body or "")[:200],
}
resultat["stage"] = PORTAL_KIND
return resultat
if status >= 400:
resultat["error"] = {
"name": f"HTTP {status}",
"message": error_from_page(body),
}
resultat["stage"] = PORTAL_KIND
return resultat
# 200 ET la page de connexion : on n'a pas vu /my, on a vu le portier.
# Le compter comme une réussite serait le mensonge le plus coûteux ici.
redirige = "/web/login" in (session.last_url or "")
if redirige or RE_LOGIN_FORM.search(body or ""):
resultat["error"] = {
"name": t("not signed in"),
"message": t("the page served was the login form"),
}
resultat["stage"] = PORTAL_KIND
return resultat
def crawl(
session,
limit=DEFAULT_RECORD_LIMIT,
every_menu=False,
lst_portal=DEFAULT_PORTAL_PATHS,
):
"""Parcourir le portail et les applications. Rend (visitées, échecs)."""
lst_menu, error = menu_rows(session)
if error:
raise RuntimeError(
f"{t('Could not read the menus')} : {error['name']}"
f" {error['message']}"[:300]
)
if every_menu:
par_id = {menu["id"]: menu for menu in lst_menu}
lst_entry = [
(_root_of(menu, par_id), menu) for menu in actionable(lst_menu)
]
else:
lst_entry = [
(app, first) for app, first in apps(lst_menu) if first is not None
]
lst_result = [check_portal(session, path) for path in lst_portal or ()]
lst_result += [
check_entry(session, app, menu, limit=limit) for app, menu in lst_entry
]
return lst_result, [item for item in lst_result if item["error"]]
def _root_of(menu, par_id):
"""Remonter jusqu'à l'application, pour dire d'où vient le menu."""
vus = set()
courant = menu
while True:
parent = courant.get("parent_id")
cle = parent[0] if isinstance(parent, (list, tuple)) else None
if cle is None or cle in vus or cle not in par_id:
return courant
vus.add(cle)
courant = par_id[cle]
def render(lst_result, lst_failure):
"""Le rapport. Ce qui a ouvert, et ce qui a refusé de s'ouvrir."""
lignes = []
# Le portail est compté À PART : noyé dans le total des applications,
# « 22/23 » ne dirait pas laquelle des deux choses a cassé, alors que
# ce sont deux rendus sans rapport — QWeb frontend d'un côté, vues
# back-office de l'autre.
portail = [item for item in lst_result if item["kind"] == PORTAL_KIND]
lst_result = [item for item in lst_result if item["kind"] != PORTAL_KIND]
lst_failure = [item for item in lst_failure if item["kind"] != PORTAL_KIND]
if portail:
casse = [item for item in portail if item["error"]]
lignes.append(
f"\n✨ {t('Portal pages opened as the test user')} :"
f" {len(portail) - len(casse)}/{len(portail)}"
)
for item in portail:
marque = "❌" if item["error"] else "✅"
detail = (
f" — {item['error']['name']} :"
f" {item['error']['message'][:120]}"
if item["error"]
else f" — HTTP {item['status']}"
)
lignes.append(f" {marque} {item['menu']}{detail}")
raison = (item["error"] or {}).get("log")
if raison:
lignes.append(f" {t('server log')} : {raison}")
lignes.append(
f"\n✨ {t('Apps opened as the test user')} :"
f" {len(lst_result) - len(lst_failure)}/{len(lst_result)}"
)
saute = [
item
for item in lst_result
if not item["error"] and item["kind"] != "ir.actions.act_window"
]
if saute:
lignes.append(
f" {len(saute)} {t('not openable this way (client or server')}"
f" {t('action): nothing to render.')}"
)
approx = [item for item in lst_result if item["domain_ignored"]]
if approx:
lignes.append(
f" {len(approx)} {t('opened with an empty domain: theirs')}"
f" {t('needs the browser to evaluate it.')}"
)
fantomes = [item for item in lst_result if item.get("unknown_fields")]
if fantomes:
lignes.append(
f"\n⚠️ {t('Views naming a field the model no longer has')} :"
)
for item in fantomes:
lignes.append(
f" · {item['app']} [{item['model']}] :"
f" {', '.join(item['unknown_fields'][:8])}"
)
if not lst_failure:
if not any(item["error"] for item in portail):
lignes.append(f"✅ {t('Every app opened its first page.')}")
return "\n".join(lignes)
lignes.append(f"\n❌ {t('Apps that failed to open')} :")
ETAPE = {
"action": t("reading the action"),
"views": t("rendering the views"),
"fields": t("listing the model fields"),
"records": t("loading the first records"),
"registry": t("model absent from the running registry"),
}
for item in lst_failure:
error = item["error"]
lignes.append(
f" · {item['app']} → {item['menu']}"
f" [{item['model'] or '-'}]"
)
lignes.append(
f" {ETAPE.get(item['stage'], item['stage'])} :"
f" {error['name']}"
)
if item["stage"] == "registry":
# Le 404 nu d'Odoo ne dit rien d'utile ; ce qu'il faut savoir,
# c'est que le code du module manque à CETTE version.
lignes.append(
f" {t('installed in the database, but its module code')}"
f" {t('is not in this addons path.')}"
)
else:
message = (error.get("message") or "").strip().splitlines()
if message:
lignes.append(f" {message[0][:160]}")
lignes.append(
f"\nℹ {t('These are back-office failures: the public smoke test')}"
f" {t('cannot see them.')}"
)
return "\n".join(lignes)
def run(
base_url,
database,
login,
password,
limit,
every_menu=False,
lst_portal=DEFAULT_PORTAL_PATHS,
):
"""Se connecter puis parcourir. Lève RuntimeError si l'on ne peut pas."""
session = Session(base_url)
ok, raison = session.log_in(database, login, password)
if not ok:
raise RuntimeError(f"{t('Could not log in as')} '{login}' : {raison}")
return crawl(
session, limit=limit, every_menu=every_menu, lst_portal=lst_portal
)
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Log in as the neutralization test user and open the first page"
" of every Odoo app, reporting what fails."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="./config.conf")
parser.add_argument("-p", "--port", type=int, default=None)
parser.add_argument("--login", default=DEFAULT_LOGIN)
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_RECORD_LIMIT,
help="how many records to load per app",
)
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(
"--portal",
default=",".join(DEFAULT_PORTAL_PATHS),
help="portal paths to open while signed in (comma separated, empty"
" to skip)",
)
parser.add_argument(
"--boot-timeout",
type=int,
default=180,
help="how long to wait for the server to answer",
)
config = parser.parse_args(argv)
from smoke_public_url import (
DEFAULT_PORT,
port_is_taken,
read_log,
start_server,
stop_server,
wait_ready,
)
port = config.port or DEFAULT_PORT
etat = user_state(config.database, config.login)
if etat == "unknown":
# Ne PAS dire « pas neutralisée » : on ne le sait pas. Le dire
# ferait croire que le back-office va bien alors qu'on n'a rien vu.
print(
f"❌ {t('Could not tell whether the')} '{config.login}'"
f" {t('user exists in')} '{config.database}'."
)
return 2
if etat == "absent":
print(
f"ℹ️ {t('No')} '{config.login}' {t('user in')}"
f" '{config.database}' :"
f" {t('the database was not neutralized, nothing to browse.')}"
)
return 0
from smoke_public_url import require_matching_version
mismatch = require_matching_version(config.database)
if mismatch:
print(f"⛔ {mismatch}")
return 2
if port_is_taken(port):
print(
f"❌ {t('Something already listens on port')} {port} :"
f" {t('it would be tested instead of this database.')}"
)
return 2
base_url = f"http://127.0.0.1:{port}"
print(
f"⧖ {t('Starting Odoo on')} '{config.database}'"
f" ({t('port')} {port})…"
)
import tempfile
log_path = os.path.join(
tempfile.gettempdir(),
f"erplibre_internal_{config.database}_{port}.log",
)
server = start_server(
config.database, port, config.config, log_path=log_path
)
try:
if not wait_ready(base_url, timeout=config.boot_timeout):
print(f"❌ {t('The server never answered on')} {base_url}")
return 2
lst_result, lst_failure = run(
base_url,
config.database,
config.login,
config.password,
config.limit,
every_menu=config.all_menus,
lst_portal=[
path.strip()
for path in (config.portal or "").split(",")
if path.strip()
],
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
finally:
# Le journal se lit APRÈS l'arrêt : un serveur qui écrit dans un
# fichier bufferise et ne vide qu'en s'arrêtant. Lire avant donnait
# les lignes de démarrage et rien de la panne.
stop_server(server)
attach_log_reason(lst_failure, read_log(log_path))
print(render(lst_result, lst_failure))
return 1 if lst_failure else 0
if __name__ == "__main__":
sys.exit(main())