Si vous êtes arrivé ici, c'est que le build vous a renvoyé quelque chose comme ValueError: Invalid field 'category_id' in 'res.groups' alors qu'en local tout allait bien. Ce n'est pas votre environnement. Le linter, le import et les tests unitaires n'exécutent pas la partie d'Odoo qui échoue : le registre qui se construit à l'installation.
Le terrain du « qu'est-ce qui change dans Odoo 19 » est déjà bien balisé. Ici, on parle de l'étape suivante : le module passe le linter, s'importe sans erreur, et pourtant le -i échoue. Ou pire, il s'installe et se comporte mal en silence.
Ce que votre CI vérifie, et ce qu'elle ne vérifie pas
python -m compileall vérifie la syntaxe Python. xmllint --noout vérifie que le XML est bien formé. Une suite de tests « purs », sans Odoo démarré, vérifie votre logique. Aucun des trois n'ouvre le registre d'Odoo, et c'est là que vit l'erreur.
Installer, c'est autre chose : Odoo construit le registre et chaque <record> aboutit à un create() ou un write() de l'ORM. Et write() valide les noms de champs un par un : dans odoo/orm/models.py de la 19.0, un KeyError sur self._fields[field_name] ressort comme ValueError: Invalid field 'category_id' in 'res.groups'. Ce n'est pas une erreur de syntaxe ; c'est une erreur d'exécution du XML.
Et le piège majeur, celui qui transforme une gate en théâtre : un -i sur un module déjà installé ne fait rien. Dans odoo/modules/loading.py de la 19.0, update_operation vaut 'install' seulement si l'état est to install ; s'il est installed, il vaut None. Et avec None, quand la ligne de commande contient un -i ou un -u, ni les données ne sont chargées ni le bloc de tests n'est exécuté. Build vert, zéro test. Avant de croire une gate, regardez combien de tests elle dit avoir exécutés.
res.groups.privilege n'est pas un champ renommé : c'est un nouveau modèle
Les guides de migration fournissent un tableau de renommages, et il règle la moitié des cas. category_id n'y figure pas, car Odoo 19 ne l'a pas renommé : il l'a retiré de res.groups. Un rechercher-remplacer n'a rien à cibler.
En 17 et 18, res.groups.category_id pointe vers ir.module.category. En 19, il y a un nouveau modèle intermédiaire, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, quatorze lignes) : le groupe porte privilege_id et c'est le privilège qui porte category_id. Au passage, la 19 ajoute UNIQUE (privilege_id, name) : deux groupes appelés « Manager » sous le même privilège ne s'installent pas.
La correction ne consiste pas à changer un nom : c'est créer un enregistrement d'un modèle qui n'existait pas dans votre version d'origine. Voici ce que cela donne dans nos modules, avec le commentaire que nous laissons dans le fichier pour que cela ne se reproduise pas :
<record id="module_category_mi_modulo" model="ir.module.category">
<field name="name">Mi modulo</field>
</record>
<!-- Odoo 19 moved the grouping of access groups out of res.groups: a group
now points at a res.groups.privilege, and the privilege is what carries
the module category. Setting category_id on res.groups raises
"Invalid field 'category_id' in 'res.groups'". -->
<record id="privilege_mi_modulo" model="res.groups.privilege">
<field name="name">Mi modulo</field>
<field name="category_id" ref="module_category_mi_modulo"/>
</record>
<record id="group_mi_modulo_user" model="res.groups">
<field name="name">User</field>
<field name="privilege_id" ref="privilege_mi_modulo"/>
</record>
Autour, il y a de vrais renommages, et ceux-là, un remplacement les corrige : res.groups.users devient user_ids, res.users.groups_id devient group_ids, et de même pour ir.actions.act_window et ir.actions.server. Ce qui n'a pas changé, ce sont les tables : res_groups_users_rel(gid, uid) et res_groups_implied_rel(gid, hid) sont identiques en 17, 18 et 19. Le SQL d'une migration survit aux trois séries ; le code ORM qui l'entoure, non.
base.group_system avec noupdate : s'installe bien et les droits restent tels quels
L'idiome habituel pour que l'administrateur hérite de votre groupe est un <record> sur un xmlid externe :
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_mi_manager'))]"/>
</record>
Cela s'applique lors d'une nouvelle installation. Lors d'une mise à jour, Odoo l'ignore en silence. La règle se trouve dans _load_records (odoo/orm/models.py) et tient en une ligne : if not (update and d_noupdate): to_update.append(data). Ce qui décide, ce n'est pas votre fichier, mais la colonne noupdate de la ligne qui existe déjà dans ir_model_data pour ce xmlid. Ne le présumez dans aucun sens : demandez-le à votre base de données.
SELECT module, name, noupdate FROM ir_model_data
WHERE module = 'base' AND name IN ('group_system', 'group_user');
Dans notre production Odoo.sh, vérifié le 3 septembre 2026, les deux lignes reviennent avec noupdate = t. Avec cette valeur, le XML ci-dessus n'est jamais réappliqué lors d'un upgrade.
Pourquoi c'est important : si vous mettez en plus groups="mi_modulo.group_manager" sur un champ d'identifiants, dans une base déjà installée ce groupe reste sans aucun membre et personne (pas même l'administrateur) ne peut lire ni écrire ce champ. Le code interne avec sudo() continue de fonctionner, donc aucun test ne le voit : les tests tournent sur une installation vierge, où le <record> a bien été appliqué. C'est exactement ce qui nous est arrivé.
La recette : conserver le XML, qui couvre les nouvelles installations, et ajouter une migration migrations/<serie>.<version>/post-migrate.py qui l'écrit en SQL idempotent sur res_groups_implied_rel et res_groups_users_rel : implied_ids ne propage aux utilisateurs que via l'ORM, il faut donc les insérer aussi. Et un test qui échoue si le groupe reste vide. La clôture transitive est all_implied_ids en 19 et trans_implied_ids en 17 et 18.
ir.cron sans numbercall : le cron s'exécute une fois et s'éteint tout seul
Celui-ci ne se voit que la semaine suivante. Dans Odoo 17, odoo/addons/base/models/ir_cron.py déclare numbercall = fields.Integer(..., default=1, ...). Un <record model="ir.cron"> qui ne le précise pas hérite de ce 1. À la fin de la première exécution, call_count_left tombe à 0 et le UPDATE qui clôt l'exécution écrit active = job['active'] and bool(call_count_left) : faux. Au cas où il resterait un doute, la requête qui sélectionne les tâches filtre sur AND numbercall != 0.
Résultat : le cron s'exécute une fois, se désactive de lui-même et ne laisse aucune trace. Ni exception, ni ligne de log, ni rien dans l'interface. Cela ressemble exactement à « le cron ne se déclenche pas ». La correction est <field name="numbercall">-1</field>, négatif pour « sans limite ».
Et voici la symétrie désagréable : Odoo 18 a supprimé le champ et la 19 ne l'a jamais eu. Mettre numbercall en 19 empêche l'installation (Invalid field 'numbercall' in 'ir.cron') ; ne pas le mettre en 17 casse le fonctionnement sans rien dire. Le même fichier échoue dans deux directions opposées. Avec doall, c'est pareil.
Nous commettons aussi cette erreur chez nous. En auditant notre catalogue, nous avons trouvé quatorze enregistrements ir.cron dans quatre connecteurs portés en 17 sans un seul numbercall ; en élargissant le balayage à la branche 17.0 du déploiement réellement exécuté, 130 enregistrements dans quinze modules, aucun avec le champ. À dire pour ce que c'est : nous l'avons trouvé parce que nous sommes allés le chercher avec un grep, pas parce qu'il s'est déclenché.
L'inventaire, et dans quelle version chaque piège mord
Voici ce que nous avons relevé en installant réellement sur Odoo.sh dans les trois séries, avec notre propre catalogue. Aucun de ces pièges n'est détecté par un compilateur ni par un linter. Les lignes marquées « silencieux » sont pires : le module s'installe et le build sort vert.
| Ce que vous écrivez | Ce qui se passe | Où ça mord |
|---|---|---|
category_id dans res.groups | Invalid field 'category_id' in 'res.groups' | Ne s'installe pas · 19 |
users ou groups_id | Renommés en user_ids et group_ids | Ne s'installe pas · 19 |
numbercall ou doall dans ir.cron | Invalid field 'numbercall' in 'ir.cron' | Ne s'installe pas · 18 et 19 |
manque numbercall dans ir.cron | S'installe ; le cron s'exécute une fois et s'éteint | Silencieux · 17 uniquement |
_sql_constraints dans un modèle de la 19 | S'installe ; un warning dans le log et la contrainte n'est pas créée | Silencieux · 19 |
models.Constraint en 18 ou 17 | module 'odoo.models' has no attribute 'Constraint' | Ne s'installe pas · 18 et 17 |
<record id="base.group_system"> | S'installe ; lors d'un upgrade, n'est pas réappliqué | Silencieux · les trois |
<tree> dans un héritage | Le xpath ne correspond pas et la vue plante | Ne s'installe pas · 18 et 19 |
| Champ cité dans un modificateur et non rendu | must be present in view but is missing | Ne s'installe pas · uniquement 17 |
Remarquez la dernière ligne : cette vérification n'est faite que par la 17. Le message figure toujours dans le code de la 18 et de la 19, mais il n'y couvre que les noms et les actions, pas un champ cité dans un modificateur. Si vous développez sous la 19 et portez vers l'arrière, la vue qui s'installe parfaitement chez vous casse en 17. Les pièges ne vont pas tous dans le même sens.
Comment le détecter vraiment : installer deux fois, sur deux bases différentes
Test A : base vierge, -i. Il attrape tout ce qui empêche l'installation : champs qui n'existent plus, xpath qui ne correspond pas, vues invalides, contraintes que la base refuse. Il n'attrape rien de ce qui n'arrive qu'à la mise à jour : ici update est faux, les noupdate sont ignorés et les scripts de migrations/ ne s'exécutent même pas, car migrate_module (odoo/modules/migration.py) sort par la porte si le module n'est pas dans to upgrade.
Test B : base avec la version précédente, -u. Il attrape ce qui n'apparaît qu'à la mise à jour : enregistrements non réappliqués, nouvelles colonnes non remplies, migrations qui échouent en cours de route, anciennes données qui ne conviennent plus. Ici vos pre-migrate et post-migrate s'exécutent bien, et c'est la seule façon de savoir s'ils fonctionnent.
Aucun ne remplace l'autre, et si vous publiez pour 17, 18 et 19, cela fait six installations, pas deux. Un détail pratique : avant le -i, réinitialisez par une vraie désinstallation via l'ORM. Changer l'état à la main avec psql laisse le graphe incohérent et le chargeur ignore le module en silence : encore un build vert qui n'a rien testé.
La liste de vérification avant de pousser
grepdecategory_iddans n'importe quel<record model="res.groups">. S'il en ressort un, il faut créer unres.groups.privilege.grepdegroups_idet deusersdans le XML : en 19, ce sontgroup_idsetuser_ids.grepdenumbercalletdoall: supprimé en 18 et 19, obligatoire à-1en 17.grepde_sql_constraintsen 19 : cela reste un warning du log et la contrainte n'est pas créée. Et demodels.Constrainten 18 et 17 : ce nom n'y existe pas et le module ne s'importe même pas.grepde<treeen 18/19 et de<listen 17, sans oublier leview_modedes actions.- Pour chaque
<record id="base....">: y a-t-il une migration pour l'upgrade et un test qui échoue si le groupe reste vide ? - Installer sur une base vierge. Compter les tests exécutés. S'il en affiche zéro, la gate est vide.
- Installer sur une base avec la version précédente. Compter à nouveau les tests.
- Répéter dans chaque série que vous comptez publier.
Les cinq premiers points forment un script statique qui s'exécute en une seconde ; le vrai build prend des minutes. Faites d'abord le peu coûteux, mais ne le laissez pas remplacer le coûteux : le grep trouve ce que vous savez déjà chercher, et le build trouve ce que vous ne savez pas.
Si vous venez d'une migration, le niveau précédent se trouve dans les erreurs courantes lors de la migration vers Odoo 19.
La règle de fond, la seule à retenir : compiler, parser et avoir des tests verts n'est pas la même chose qu'installer. Les deux modules qui nous ont coûté le plus cher cette année avaient tout au vert en local et ne s'installaient pas.
Questions fréquentes
Pourquoi mes tests passent-ils alors que le module ne s'installe pas ?
Parce qu'une suite en Python pur n'ouvre pas le registre d'Odoo : elle valide votre logique, pas vos fichiers de données. Et un -i sur un module déjà installé ne traite rien : le bloc de tests n'est même pas exécuté et le build sort vert avec zéro test.
Dois-je créer un res.groups.privilege aussi en 17 et 18 ?
Non : ce modèle n'existe pas avant la 19. En 17 et 18, la catégorie se place dans le groupe lui-même, avec category_id. C'est l'une des raisons pour lesquelles le XML de sécurité ne peut pas être le même fichier dans les trois séries.
Puis-je laisser numbercall pour qu'il soit valable dans les trois versions ?
Non. En 18 et 19, le champ n'existe pas et sa présence empêche d'installer le module. Et le retirer en 17 fait que le cron s'éteint tout seul après la première exécution. Il faut générer le fichier par série.
Si j'installe sur une base vierge et que tout va bien, cela ne suffit-il pas ?
Non, car dans une installation vierge, les scripts de migrations/ ne s'exécutent pas et les enregistrements marqués noupdate sont bien appliqués. Justement, les deux comportements qui échouent à la mise à jour restent en dehors de ce test.
Votre module ne s'installe pas et vous ne savez pas pourquoi ?
Nous examinons votre XML de sécurité, vos crons, vos vues héritées et vos scripts de migration, et nous vous disons ce qui casse dans chaque série avant qu'un client ne le découvre. Réservez un appel sur Calendly ou appelez-nous au +34 616 809 504.
