Aller au contenu principal
Odoo 20 · Guide technique

Ce qu'Odoo 20 casse sans prévenir : dix changements qui ne lèvent aucune erreur

Le module s'installe, les tests passent et pourtant le résultat n'est plus le même. Dix changements d'Odoo 20 qui ne lèvent aucune exception, avec le grep qui les trouve, la correction et la ligne du code d'Odoo où se trouve chacun.

Code source d'Odoo 20 comparé à celui d'Odoo 19 pour trouver les changements qui ne génèrent pas d'erreur

Odoo 20 casse beaucoup de choses avec une erreur claire : un import qui n'existe plus, un champ renommé, un module qui ne s'installe pas. Celles-là apparaissent dès le premier démarrage et se corrigent. Cet article traite des autres : dix changements qui ne lèvent aucune exception. Le module s'installe, ses tests passent et le résultat n'est plus le même.

Méthode. Chaque point a été lu dans le code d'odoo/odoo, branche 20.0 (commit 2fec203, 2 novembre 2026), et comparé à la branche 19.0 (commit 4d2d4b5, du même jour). Les liens de chaque section pointent vers ces lignes exactes. Nous avons exécuté sept des dix le 6 novembre avec le même script sur deux bases jetables, une sous Odoo 19 et une sous Odoo 20 (Community, modules base et account) ; leur sortie figure dans chaque section.

Ce que nous avons constaté en portant notre catalogue

Nous maintenons un catalogue de connecteurs et de modules pour Odoo 17, 18, 19 et 20. Pour mesurer ce qui casse en passant à 20, nous l'avons porté entièrement de façon automatique sur des copies (les scripts de migration fournis par Odoo dans odoo/upgrade_code plus les nôtres) et nous avons testé chaque module en l'installant sur un vrai Odoo 20 et en exécutant tous ses tests. Le dernier passage complet s'est terminé le 1 novembre 2026, avec notre code du 31 octobre :

Résultat du portage automatique vers 20Modules
S'installent et passent tous leurs tests151
S'installent sans aucune erreur, avec des tests en échec55
Ne s'installent pas12
Total, sur 147 dépôts218

Les 12 qui ne s'installent pas sont la partie facile : le log dit ce qui manque. Les 55 du milieu se sont installés proprement et, sans tests qui parcourent le vrai chemin, auraient paru prêts. Dans le log, ces tests échouent sur des exceptions (KeyError, AttributeError, TypeError, ValueError) ou sur des assertions non vérifiées. Les dix changements de cet article ne lèvent aucune exception : seul les voit un test qui regarde le résultat, comme ceux que nous avons écrits exprès (un utilisateur sans privilèges, un taux de change daté d'aujourd'hui, un rapport comparé à celui de 19), ou une sonde sur un vrai Odoo 20.

Ce sont des données du portage automatique sur des copies, pas de ce qui est publié : un de nos modules sort en 20.0 après avoir passé tous ses tests sur un vrai Odoo 20.

Les dix en un coup d'œil

Changement dans Odoo 20Comment ça se manifeste
1. t-esc et t-rawLa donnée sort en blanc dans les rapports et sur le portail
2. account.payment.statepaid devient « validé, non réconcilié »
3. is_companySans numéro de TVA, le contact est créé comme personne
4. Taux de changeLe taux du jour ne s'applique qu'à partir de demain
5. b64decode d'un BinaryOctets corrompus ou Incorrect padding
6. read() d'un BinaryUn dictionnaire au lieu du base64
7. Champs renommés en uom_idgetattr retombe toujours sur la valeur par défaut
8. Droits d'accès dans ir.accessLe module s'installe sans aucun droit d'accès
9. Méthodes renomméesVotre surcharge n'est plus exécutée
10. migrations/20.0.xLe script de migration s'exécute deux fois

1. t-esc et t-raw restent en blanc

Ce qui se passe. Le moteur QWeb de 20 ne compile plus t-esc ni t-raw. En 19, ils étaient encore compilés comme t-out ; en 20, l'élément s'affiche vide et le log ne laisse qu'un WARNING : Unknown directives or unused attributes: {'t-esc'}.

Comment ça se manifeste. Le PDF de la facture ou la page du portail se génèrent bien, mais la donnée est absente. Dans la sonde, le même modèle donne <p>HOLA</p> en 19 et <p></p> en 20. Le script d'Odoo pour passer à OWL 3 (owl3-migration.py) remplace bien t-esc par t-out, mais seulement dans les fichiers situés sous /static/ : les modèles de rapports et du portail restent tels quels.

grep -rnE 't-(esc|raw)=' --include='*.xml' .
<!-- Odoo 17-19: prints the name. Odoo 20: empty, only a WARNING in the log -->
<span t-esc="o.partner_id.name"/>

<!-- Odoo 17, 18, 19 and 20 -->
<span t-out="o.partner_id.name"/>

Correction. t-out, qui existe en 17, 18, 19 et 20. Ce qui passait avec t-raw doit arriver comme Markup ; sinon t-out l'échappe.

Source. 20.0 : ir_qweb.py:1927 (l'avertissement) et owl3-migration.py:547-554 (le script d'OWL 3 ne regarde que /static/). 19.0 : ir_qweb.py:2370-2374 (t-esc et t-raw sont encore compilés).

2. Un paiement paid n'est plus réconcilié

Ce qui se passe. Le champ state de account.payment change de valeurs. En 19 : draft, in_process, paid, canceled, rejected. En 20 : draft, paid, reconciled, canceled, rejected. Le paid de 20 est l'ancien in_process (validé, non réconcilié) et l'ancien paid s'appelle désormais reconciled. Sur les factures, payment_state conserve ses valeurs de 19 et en ajoute une, todo.

Comment ça se manifeste. Un filtre sur 'paid' continue de fonctionner et renvoie autre chose : des paiements validés qui ne sont pas encore réconciliés. Un filtre sur 'in_process' ne donne pas non plus d'erreur en 20 : dans la sonde, il renvoie 0 enregistrement sans aucune exception.

grep -rnE "'(in_process|paid)'" --include='*.py' --include='*.xml' . | grep -i payment
# Odoo 19: reconciled payments. Odoo 20: validated payments, NOT reconciled yet
payments = env['account.payment'].search([('state', '=', 'paid')])

# Odoo 18, 19 and 20
states = dict(env['account.payment']._fields['state'].selection)
done = 'reconciled' if 'reconciled' in states else 'paid'
payments = env['account.payment'].search([('state', '=', done)])

Correction. Relisez à la main les résultats du grep qui concernent des paiements : le même 'paid' dans payment_state d'une facture continue de signifier la même chose.

Source. 19.0 : account_payment.py:36-43. 20.0 : account_payment.py:37-44.

3. is_company dépend du numéro de TVA

Ce qui se passe. En 20, res.partner.is_company est un champ calculé et stocké qui dépend de has_vat et de commercial_partner_id : un contact est une entreprise s'il est sa propre entité commerciale et possède un numéro de TVA (non vide, ni « / », « na » ou « NA »). En 19, c'était un booléen ordinaire.

Comment ça se manifeste. Un create() avec is_company: True et sans numéro de TVA crée une personne, sans erreur. Dans la sonde, sans numéro de TVA, 19 donne True et 20 donne False ; avec un numéro, True dans les deux. Un write() ultérieur le met bien à True, jusqu'à ce qu'Odoo le recalcule parce que le numéro de TVA ou l'entité commerciale change. Un connecteur de marketplace qui crée des clients entreprise sans numéro de TVA obtient des personnes, et s'il cherche ensuite le contact par is_company pour le réutiliser, il ne le trouve pas et en crée un autre à chaque commande. C'est ce qui nous est arrivé en portant l'un des nôtres.

grep -rn "is_company" --include='*.py' --include='*.xml' .
# Odoo 19: a company. Odoo 20: a person (no VAT number), no error
env['res.partner'].create({'name': 'ACME SL', 'is_company': True})

# Odoo 20: pass the VAT number when you have it,
# and do not look existing contacts up by is_company
env['res.partner'].create({'name': 'ACME SL', 'is_company': True, 'vat': vat})

Correction. Transmettez le numéro de TVA quand vous l'avez, ne cherchez pas les contacts par is_company et, si vous l'écrivez, vérifiez d'abord si le champ est calculé : Partner._fields['is_company'].compute.

Source. 20.0 : res_partner.py:371 (le champ), res_partner.py:942-945 (has_vat) et res_partner.py:950-951 (de quoi il dépend). 19.0 : res_partner.py:277 (booléen ordinaire).

4. Le taux de change d'aujourd'hui s'applique demain

Ce qui se passe. res.currency._get_rates cherche le dernier taux dont la date est antérieure ou égale à celle de l'opération en 19 ('<=') et strictement antérieure en 20 ('<'). En 20, un taux daté d'aujourd'hui ne compte pas pour les opérations d'aujourd'hui.

Comment ça se manifeste. Dans la sonde, la livre vaut 1,10 hier et 1,20 aujourd'hui ; convertir 100 dollars en livres à la date du jour donne 120 en 19 et 110 en 20. Sans erreur. Un processus qui charge le taux du jour le matin et facture le même jour comptabilise avec celui de la veille, et un test qui crée un taux daté d'aujourd'hui et s'attend à le voir appliqué échoue sans que rien n'ait changé dans votre module.

grep -rn "res.currency.rate" --include='*.py' .
# rate dated today: used today on Odoo 19, from tomorrow on Odoo 20
env['res.currency.rate'].create({'currency_id': gbp.id, 'name': today, 'rate': 1.20})
usd._convert(100, gbp, company, today)   # Odoo 19: 120.0 / Odoo 20: 110.0

Correction. Dans le code, il n'existe aucune option pour revenir au comportement de 19. Dans les tests, créez le taux avec la date d'hier. Si votre intégration a besoin du taux exact du jour, appliquez-le explicitement au lieu de passer par _convert, ou enregistrez-le avec la date qui lui correspond selon la règle de 20.

Source. 19.0 : res_currency.py:126-129. 20.0 : res_currency.py:185-187.

5. b64decode(registro.campo) renvoie n'importe quoi

Ce qui se passe. En 19, lire un champ Binary renvoyait le contenu en base64. En 20, il renvoie un BinaryValue, qui expose les octets bruts via le protocole de buffer de Python. base64.b64decode() accepte n'importe quel buffer : il lit ces octets bruts comme s'ils étaient du base64 et, sans validate=True, élimine ce qui n'appartient pas à l'alphabet.

Comment ça se manifeste. Dans la sonde, avec un champ Binary ordinaire : avec un texte de 12 octets, 20 renvoie 9 octets différents et aucune erreur ; avec un PNG, binascii.Error: Incorrect padding. En 19, les deux ressortent identiques à l'original. Selon le contenu, une image ou un PDF que vous envoyez à un autre système arrive corrompu ou l'appel plante.

grep -rn "b64decode(" --include='*.py' .    # then check which ones read a record's field
# Odoo 19: the original bytes. Odoo 20: garbage or binascii.Error
data = base64.b64decode(record.file_data)

# Odoo 17, 18, 19 and 20
value = record.file_data
data = value.content if hasattr(value, 'content') else base64.b64decode(value)

Source. 20.0 : binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) et fields_binary.py:128-129 (ce que renvoie le champ à la lecture).

6. read() d'un Binary renvoie un dictionnaire

Ce qui se passe. En 20, read() d'un champ Binary renvoie {'content': <base64>, 'size': N}, plus filename s'il en a un. En 19, il renvoyait directement le base64.

Comment ça se manifeste. Dans la sonde, read() renvoie bytes en 19 et un dictionnaire avec content et size en 20. Si cette valeur finit dans un str() ou dans un modèle de texte destiné à un autre système, c'est le texte du dictionnaire qui est envoyé, et rien n'échoue dans Odoo.

grep -rnE "\.(read|search_read)\(" --include='*.py' .    # then check which ones read Binary fields
# Odoo 19: base64. Odoo 20: {'content': ..., 'size': ...}
b64 = record.read(['file_data'])[0]['file_data']

# Odoo 17, 18, 19 and 20
v = record.read(['file_data'])[0]['file_data']
b64 = v['content'] if isinstance(v, dict) else v

Source. 20.0 : fields_binary.py:134-146.

7. Champs renommés lus avec getattr

Ce qui se passe. En 20, stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id et mrp.bom.line.product_uom_id sont renommés en uom_id. Utiliser directement l'ancien nom provoque une erreur (dans un create(), ValueError: Invalid field). Le problème vient du code conçu pour plusieurs versions qui le lit avec une valeur par défaut : en 20, getattr(bom, 'product_uom_id', False) renvoie toujours la valeur par défaut.

Comment ça se manifeste. Des quantités dans la mauvaise unité : un kit en douzaines, ou une ligne en kilos face à un composant en grammes. C'est ce qui nous est arrivé dans un connecteur de marketplace lors du découpage des kits : en 20, il retombait toujours sur l'unité du produit.

grep -rnE "(getattr\(|\.get\()[^)]*'product_uom(_id)?'" --include='*.py' .
# Odoo 20: always falls back to the product's unit
uom = getattr(bom, 'product_uom_id', False) or product.uom_id

# Odoo 17, 18, 19 and 20
fname = 'uom_id' if 'uom_id' in bom._fields else 'product_uom_id'
uom = bom[fname] or product.uom_id

Source. 19.0 : mrp_bom.py:46, stock_move.py:67 et purchase_order_line.py:43. 20.0 : mrp_bom.py:47, stock_move.py:68 et purchase_order_line.py:49 ; le ValueError de create(), models.py:4151-4153.

8. Un module sans droits d'accès s'installe sans prévenir

Ce qui se passe. Odoo 20 remplace ir.model.access et ir.rule par un nouveau modèle, ir.access, avec son fichier security/ir.access.csv. Le script d'Odoo qui convertit les droits d'accès (odoo/upgrade_code/19.4-00-ir-access.py) retire l'ancien CSV du manifeste et, pour ajouter le nouveau, cherche une ligne qui commence par 'data': [. Si dans votre manifeste 'data' se trouve au milieu d'une ligne, il écrit ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually et continue avec le reste.

Comment ça se manifeste. Le module s'installe sans aucun droit d'accès. En 19, un modèle sans droits d'accès donnait un avertissement à l'installation (« The models [...] have no access rules ») ; en 20, cet avertissement n'existe plus. Les tests, qui s'exécutent en superutilisateur, passent. L'utilisateur normal reçoit « No group currently allows this operation ». C'est ce qui nous est arrivé avec des modules déjà portés : un test avec un utilisateur sans privilèges l'a détecté, et depuis, notre chaîne de portage s'arrête si un ir.access.csv n'est pas chargé.

grep -L "ir.access.csv" */__manifest__.py     # manifests that do not load it
grep -n "Cannot add" upgrade.log              # the script's own complaint
# the script cannot add ir.access.csv here ('data' in the middle of a line)
'depends': ['base'], 'data': ['views/views.xml'],

# here it can
'depends': ['base'],
'data': ['security/ir.access.csv', 'views/views.xml'],

Correction. Ajoutez 'security/ir.access.csv' à data à la main et écrivez un test qui crée et lit un enregistrement de chaque modèle avec un utilisateur normal (new_test_user et with_user). C'est le seul test qui le voit.

Source. 20.0 : 19.4-00-ir-access.py:415-434 (où 'data' cherche et l'ERROR) et ir_access.py:342 (le message destiné à l'utilisateur). 19.0 : loading.py:250-257 (l'avertissement qui n'existe plus en 20).

9. Surcharger une méthode qu'Odoo 20 n'appelle plus

Ce qui se passe. Python ne prévient pas si vous surchargez une méthode que la classe de base n'a plus : votre méthode existe, mais personne ne l'appelle. En 20, le framework de paiement remplace payment.provider._get_compatible_providers par _find_available_providers, avec une autre signature (ce qui suit amount ne s'écrit plus que par nom).

Comment ça se manifeste. Si vous surchargiez _get_compatible_providers pour masquer un fournisseur de paiement par pays ou par montant, en 20 le fournisseur réapparaît au moment du paiement. Dans la sonde, le modèle de 19 a _get_compatible_providers et pas _find_available_providers ; celui de 20, l'inverse.

grep -rhoE "def _[a-z0-9_]+" models/ | sort -u                  # private methods you override
grep -rn "def _get_compatible_providers" odoo-20.0/addons/      # no output = Odoo 20 no longer has it
# Odoo 19
def _get_compatible_providers(self, company_id, partner_id, amount, **kwargs):

# Odoo 20
def _find_available_providers(self, company_id, partner_id, amount, **kwargs):

Source. 19.0 : payment_provider.py:555, appelé depuis portal.py:113. 20.0 : payment_provider.py:671.

10. Les migrations copiées vers 20.0.x se répètent

Ce qui se passe. Ce n'est pas nouveau en 20, mais cela apparaît justement au portage. Odoo exécute un dossier migrations/<versión completa> si la version installée est inférieure à celle du dossier et que la nouvelle est égale ou supérieure. Si, en portant, vous copiez migrations/19.0.1.3.0 vers migrations/20.0.1.3.0, lorsque vous passez à 20 une base avec le module en 19.0.1.3.1, ce script s'exécute de nouveau sur des données qu'il a déjà transformées. Les dossiers sans série (migrations/1.3.0) ne comparent que la partie du module et ne se répètent pas.

Comment ça se manifeste. Le -u se termine bien et les données sont transformées deux fois : des heures décalées une seconde fois, des lignes copiées deux fois.

ls -d */migrations/20.0.* 2>/dev/null
migrations/20.0.1.3.0/post-migrate.py   # runs again when a 19.0.1.3.x database moves to 20
migrations/1.3.0/post-migrate.py        # compares only 1.3.0: does not run again

Correction. Des dossiers sans série, des scripts idempotents (qui vérifient l'état avant de toucher à quoi que ce soit) et un test qui exécute la migration deux fois.

Source. 20.0 : migration.py:197-210 (lignes 205 à 208 pour les dossiers sans série, 210 pour ceux à version complète).

Et un qui n'est plus silencieux : datas sur les pièces jointes

En septembre, ir.attachment.create({'datas': ...}) créait en 20 une pièce jointe vide sans aucune erreur (dans un connecteur de signature, le PDF signé aurait été enregistré avec zéro octet). Depuis le commit c06a072 du 31 octobre (« [FIX] base: remove fallback for datas »), il plante avec AssertionError: _check_contents should handle that (ir_attachment.py:502). La correction est la même et vaut pour 17, 18, 19 et 20 : 'raw' avec les octets non encodés. La branche 20.0 continue de recevoir des changements chaque semaine ; ce que vous avez testé en septembre mérite donc d'être retesté avec le code d'aujourd'hui.

Comment les repérer avant vos utilisateurs

  1. Passez sur votre code les grep de chaque section et relisez à la main ce qui sort.
  2. Exécutez aussi les tests avec un utilisateur sans privilèges, pas seulement en superutilisateur.
  3. Générez les rapports et les pages du portail et comparez le texte avec celui de 19.
  4. Créez des données de test datées d'aujourd'hui, pas seulement d'hier : taux de change, paiements, échéances.
  5. Faites passer une base de 19 à 20 avec des données et exécutez la migration deux fois.

Questions fréquentes

Les scripts de migration d'Odoo (upgrade_code) corrigent-ils ces changements ?

Non. Aucun des scripts de odoo/upgrade_code de la branche 20.0 ne résout ces dix changements : celui des droits d'accès (19.4-00-ir-access) est justement celui qui peut laisser un module sans eux, et celui d'OWL 3 remplace t-esc par t-out seulement dans les fichiers sous /static/.

Pourquoi les tests ne les voient-ils pas ?

Parce qu'ils s'exécutent en superutilisateur, avec des données qui ne passent pas par ces chemins et sans regarder ce qui est généré. Un rapport avec un champ en blanc ne lève rien et 'paid' reste une valeur valide. Il faut des tests qui regardent le résultat : le texte du rapport, le montant converti, ce que voit un utilisateur normal.

Peut-on préparer le code en 19 avant de porter ?

Dans plusieurs cas, oui : t-out à la place de t-esc et 'raw' sur les pièces jointes fonctionnent déjà en 17, 18 et 19, et lire les champs renommés via _fields rend le même code prêt pour 20.

Liens utiles chez FlexigoTech

Odoo 20 : ce qui change pour vos connecteursCe qui disparaît et ne s'installe pasMigrer vers Odoo 20 : checklistPour les entreprises avec des intégrations activesNos connecteurs déjà sur Odoo 20La liste, avec un lien vers chaque fiche

Connecteurs pour Odoo 17, 18, 19 et 20

FlexigoTech développe des connecteurs et des modules pour Odoo 17, 18, 19 et 20, et les teste ainsi sur chaque version avant de les publier.

Voir le catalogue