Maintenir un module Odoo sur 19, 18 et 17 en même temps, ce n'est pas copier le dossier dans trois branches et changer le numéro du manifest. C'est une branche git par version, un build d'installation réel par version et un inventaire des différences entre versions revu à chaque release. Nous maintenons 105 modules publiés sur les trois versions ; voici la liste de ce qui casse, y compris nos propres erreurs.
Une fiche, trois branches (19.0, 18.0 et 17.0 avec le même nom technique) est expliqué dans publier un module sur l'App Store d'Odoo. Ici, on traite de ce qui se passe à l'intérieur de chaque branche.
Ce qui change entre 17, 18 et 19 et n'apparaît pas dans les guides des nouveautés
Les nouveautés d'Odoo 19 parlent de fonctionnalités ; ce tableau, de ce qui échoue à l'installation ou, pire, n'échoue pas. Il est vérifié sur le code source des trois versions, pas sur un changelog.
| Quoi | Odoo 17 | Odoo 18 | Odoo 19 | Comment ça échoue |
|---|---|---|---|---|
| Contraintes SQL | _sql_constraints | _sql_constraints | models.Constraint | En silence : 19 avertit et continue ; la contrainte n'existe pas |
| Cron sans limite | numbercall=-1 obligatoire | Le champ n'existe pas | Le champ n'existe pas | 17 : sans lui, il s'exécute une fois puis s'éteint. 18/19 : avec lui, l'installation échoue |
| Groupes de l'utilisateur | groups_id | groups_id | group_ids | Champ invalide à l'installation ou à l'exécution |
| Utilisateurs du groupe | users | users | user_ids | Identique ; il a fait planter un cron d'alertes en 17 et 18 |
| Catégorie du groupe | category_id | category_id | privilege_id → res.groups.privilege | Invalid field 'category_id' in 'res.groups' |
| Vue liste | <tree> | <list> | <list> | Type de vue invalide ; les xpath //tree ne correspondent pas |
Le plus dangereux : _sql_constraints
Dans Odoo 19, _sql_constraints est ignoré : le chargeur de modèles écrit « no longer supported » dans le log et continue. Le module s'installe, les tests passent et la contrainte d'unicité n'est pas dans la base de données ; un connecteur qui compte sur unique(external_id) pour ne pas dupliquer des commandes perd cette garantie sans que rien ne le signale. La nouvelle forme, models.Constraint('unique(...)', 'mensaje'), n'existe que dans la 19. Odoo fournit un convertisseur, odoo/cli/upgrade_code.py --script 18.1-00-sql-constraint, avec un piège que nous avons découvert en migrant les nôtres : il renvoie le code de sortie 1 quand il a modifié des fichiers, autrement dit 1 signifie succès. Seul un test qui tente le doublon prouve que la contrainte est bien active.
Celui qui nous a piégés : numbercall
Dans Odoo 17, ir.cron.numbercall vaut 1 par défaut : un cron déclaré sans ce champ s'exécute une fois puis se désactive tout seul, sans erreur ni ligne de log. Odoo 18 a supprimé le champ et, depuis, le déclarer empêche l'installation ; la branche 17 a donc besoin de <field name="numbercall">-1</field> dans chaque cron et les deux autres ne peuvent pas l'avoir. En septembre 2026, nous avons audité nos propres portages vers la 17 et trouvé 14 crons sans ce champ dans quatre connecteurs, dont deux avec une démo en cours : personne ne teste une synchronisation périodique en attendant la deuxième exécution. La correction tient en deux lignes par cron ; le coût, c'était de ne pas l'avoir dans l'inventaire.
Ce qu'Odoo 17 valide et que 18 et 19 laissent passer
Le trafic ne va pas que vers le haut. Odoo 17 exige qu'un champ utilisé dans un modificateur de vue (invisible, readonly) figure dans cette même vue ; 18 et 19 ne le vérifient pas. Un module écrit en 19 et porté vers l'arrière échoue en 17 avec Field 'state' used in modifier 'invisible' (state == 'draft') must be present in view but is missing. C'est pourquoi un portage n'est validé qu'une fois installé sur la version la plus ancienne. Autres cas dans un module Odoo 19 compile mais ne s'installe pas et erreurs de migration vers Odoo 19.
Stratégie de branches : une par version et aucune condition
La branche principale est celle de la version la plus récente, aujourd'hui 19.0. Chaque modification y est faite et testée, puis reportée vers l'arrière par cherry-pick sur 18.0 et 17.0, en adaptant ce que le tableau oblige à adapter. Ce que nous ne faisons jamais, c'est un seul code avec if version >= 18 à l'intérieur : on obtient un module avec les trois API à la fois, dont personne ne sait laquelle des trois branches il teste. Pour les nouveaux connecteurs, nous générons les branches 18 et 17 à partir de la source 19 avec un script qui encode le tableau ci-dessus ; il ne remplace pas le test, il remplace le copier-coller.
Deux exceptions, et toutes deux interrogent une capacité, pas un numéro de version :
- Détection d'un champ ou d'un modèle à l'exécution. Si le module écrit dans un champ qui n'existe pas dans toutes les versions, on interroge le registre :
'mobile' in partner._fieldsou'hr.expense.sheet' in self.env. On regarde ce qui existe, pas ce qui devrait exister. - Tests qui sont ignorés quand la capacité est absente, avec un
skipTestconditionné par la présence du champ. Ce qui ne vaut pas, c'est passer avec 0 sur 0 : un de nos modules avait zéro test sur les trois branches ; en lui en écrivant 25, trois vrais défauts sont apparus, dont le cron d'alertes qui utilisaituser_idsen 17 et 18.
Le test qui compte : installer pour de vrai sur les trois
Ni le linter, ni import, ni une suite verte sur votre machine ne prouvent qu'un module s'installe : un XML avec numbercall est un XML valide et un xpath sur //tree est un xpath valide. L'échec n'apparaît que lorsque Odoo charge le module sur une base de données de cette version. Notre règle : une modification n'existe pas tant qu'elle n'a pas été installée, proprement, sur un Odoo réel de chaque version et, si le module est déjà déployé chez quelqu'un, aussi en mise à jour depuis la version précédente.
La mise à jour compte pour une raison précise : les groupes de base portent noupdate="1", donc un module qui ajoute son groupe de gestionnaire à base.group_system avec implied_ids y parvient sur une nouvelle installation, et Odoo l'ignore en silence lors de la mise à jour. Nous l'avons vu sur une production Odoo.sh : le groupe s'est retrouvé sans membres et même l'administrateur ne pouvait pas lire le champ d'identifiants qui en dépendait. Il faut une migration dans migrations/ en plus du XML, et un test qui échoue si le groupe se retrouve vide.
Nous utilisons deux types de banc. Un banc de tests, un Odoo par version (en local ou en conteneurs) qui installe chaque module et exécute sa suite sur les trois : c'est là qu'ont tourné les 37 apps de notre première campagne, là que le connecteur Mirakl passe 163 tests sur chaque version et celui d'Amazon 786 en 19 et 685 en 18 et en 17. Et Odoo.sh, un build par branche, pour l'installation réelle. Un tour de validation dure une dizaine de minutes et chacune compte : 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 (tests verts et le connecteur échoue en production).
Notre propre échec : nous avions considéré la première campagne de 37 apps comme terminée avec installation et tests au vert. Quatre jours plus tard, une seconde passe sur 47 apps en Odoo 17 et 18 Community, en exerçant chaque vue avec des données, a laissé 61 exécutions propres sur 94 : 28 échecs d'installation et 4 d'exercice, soit une onze de vrais défauts. Des xpath sur //list en 17, des ancrages sur des champs qui n'existent pas en 17. Du code porté vers l'arrière sans l'installer sur la version cible.
Le coût réel et quand abandonner la 17
Trois branches ne triplent pas le développement, car la logique s'écrit une seule fois. Elles triplent le test et la publication : trois installations par modification, trois suites, trois fiches à vérifier, et un inventaire à relire en entier à chaque nouvelle version, comme la 20 (septembre 2026), qui ajoute des lignes au tableau. Odoo sort une version majeure par an et maintient les trois dernières ; le jour où elle publie la 20, la 17 sort de cette fenêtre et n'est plus maintenue. D'ici là, la branche 17 reçoit chaque correction : qui est encore en 17 prépare une migration, et migrera avec votre module ou sans lui.
Ce qui réduit vraiment le coût, c'est d'avoir l'inventaire comme outil : un scanner en lecture seule qui signale quelle ligne heurte quelle différence selon la version du manifest. Le nôtre a fini par signaler 67 modules pour avoir écrit category_id sur un groupe, et les 67 étaient corrects : cette ligne se trouvait dans un <record> de res.groups.privilege, ce qui est exactement la bonne correction pour la 19. La règle regardait la ligne et non l'enregistrement qui l'entoure. Un décompte sans ouvrir le code n'est pas une donnée, et une règle qui signale la bonne correction apprend à ignorer l'outil.
Checklist avant chaque push
- Contraintes :
models.Constrainten 19,_sql_constraintsen 18 et 17, et un test qui tente le doublon. - Crons :
numbercall=-1dans tous ceux de la branche 17 ; aucun en 18 et 19. - Groupes :
group_ids,user_idsetprivilege_id(modèleres.groups.privilege) en 19 ;groups_id,usersetcategory_iden 18 et 17. - Vues :
<list>en 19 et 18,<tree>en 17 ; en 17, tout champ utilisé dans un modificateur figure dans la vue. - Installation propre sur les trois, et mise à jour depuis la version précédente si le module est déjà déployé.
- Suite complète au vert sur les trois, et non vide : 0 sur 0 n'est pas au vert.
- Pas de
__pycache__dans le commit. Ungit add -Aaprès les tests nous a mis 1 078 fichiers.pycdans 35 dépôts sur 37.
C'est ce que nous faisons quand un partner d'Odoo nous confie la maintenance d'un de ses modules sur plusieurs versions, ou quand un cabinet de conseil a besoin qu'un développement qui fonctionne pour lui en 17 arrive en 19 sans perdre la 17. Le raccourci, c'est ce que découvre le premier acheteur de la fiche.
Questions fréquentes
Puis-je maintenir un seul code avec des conditions par version ?
L'App Store publie depuis les branches 17.0, 18.0 et 19.0 ; vous vous retrouvez donc de toute façon avec trois branches, simplement avec les trois API mélangées dans chacune. Une branche par version, c'est plus de code répété et beaucoup moins de défauts. Les seules conditions acceptables interrogent une capacité, jamais le numéro de version.
Pourquoi ne suffit-il pas que ça compile et que les tests passent en local ?
Parce que les échecs liés à la version apparaissent au chargement du module sur une base de données de cette version, pas à l'import du code ; et certains, comme _sql_constraints en 19, n'échouent même pas : il faut prouver que la contrainte existe.
Liens utiles chez FlexigoTech
Un module sur une version, et il vous le faut sur les trois ?
Nous l'installons sur 19, 18 et 17, nous vous disons ce qui casse dans chacune et nous le laissons avec une branche par version et son build au vert. Écrivez à comercial@flexigobe.com ou appelez le +34 616 809 504.

