[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
#!/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
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
self.last_url = None
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
# 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 {})
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
# 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
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
try:
|
|
|
|
|
|
with self.opener.open(request, timeout=self.timeout) as answer:
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
self.last_url = answer.geturl()
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
return answer.getcode(), answer.read().decode(
|
|
|
|
|
|
"utf-8", errors="replace"
|
|
|
|
|
|
)
|
|
|
|
|
|
except urllib.error.HTTPError as exc:
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
self.last_url = exc.geturl()
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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.")
|
[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-19 04:53:43 -04:00
|
|
|
|
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)}"
|
|
|
|
|
|
)
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
match = RE_CSRF.search(body)
|
[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-19 04:53:43 -04:00
|
|
|
|
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")
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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"]]
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
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,
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
"model": None,
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
"kind": kind,
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
"error": None,
|
|
|
|
|
|
"stage": None,
|
|
|
|
|
|
"domain_ignored": False,
|
|
|
|
|
|
"unknown_fields": [],
|
|
|
|
|
|
"fields_read": 0,
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
"status": None,
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
}
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
)
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
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)."""
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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
|
|
|
|
|
|
]
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
lst_result = [check_portal(session, path) for path in lst_portal or ()]
|
|
|
|
|
|
lst_result += [
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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 = []
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
# 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}")
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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:
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
if not any(item["error"] for item in portail):
|
|
|
|
|
|
lignes.append(f"✅ {t('Every app opened its first page.')}")
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
def run(
|
|
|
|
|
|
base_url,
|
|
|
|
|
|
database,
|
|
|
|
|
|
login,
|
|
|
|
|
|
password,
|
|
|
|
|
|
limit,
|
|
|
|
|
|
every_menu=False,
|
|
|
|
|
|
lst_portal=DEFAULT_PORTAL_PATHS,
|
|
|
|
|
|
):
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
"""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}")
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
return crawl(
|
|
|
|
|
|
session, limit=limit, every_menu=every_menu, lst_portal=lst_portal
|
|
|
|
|
|
)
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
)
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--portal",
|
|
|
|
|
|
default=",".join(DEFAULT_PORTAL_PATHS),
|
|
|
|
|
|
help="portal paths to open while signed in (comma separated, empty"
|
|
|
|
|
|
" to skip)",
|
|
|
|
|
|
)
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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,
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
read_log,
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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
|
[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-19 04:53:43 -04:00
|
|
|
|
from smoke_public_url import require_matching_version
|
|
|
|
|
|
|
|
|
|
|
|
mismatch = require_matching_version(config.database)
|
|
|
|
|
|
if mismatch:
|
|
|
|
|
|
print(f"⛔ {mismatch}")
|
|
|
|
|
|
return 2
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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})…"
|
|
|
|
|
|
)
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
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
|
|
|
|
|
|
)
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
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,
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
lst_portal=[
|
|
|
|
|
|
path.strip()
|
|
|
|
|
|
for path in (config.portal or "").split(",")
|
|
|
|
|
|
if path.strip()
|
|
|
|
|
|
],
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
)
|
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
|
print(f"❌ {exc}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
finally:
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
# 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.
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
stop_server(server)
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
|
attach_log_reason(lst_failure, read_log(log_path))
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. 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, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
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 module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
|
print(render(lst_result, lst_failure))
|
|
|
|
|
|
return 1 if lst_failure else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|