Odoo 20 breekt veel dingen met een duidelijke fout: een import die niet meer bestaat, een hernoemd veld, een module die niet installeert. Die zie je bij de eerste start en ze zijn te verhelpen. Dit artikel gaat over de andere: tien wijzigingen die helemaal geen exception geven. De module installeert, de tests slagen en toch is het resultaat niet meer hetzelfde.
Werkwijze. Elk punt is gelezen in de code van odoo/odoo, branch 20.0 (commit 2fec203, 2 november 2026), en vergeleken met branch 19.0 (commit 4d2d4b5, van dezelfde dag). De links in elk onderdeel verwijzen naar precies die regels. Zeven van de tien hebben we op 6 november uitgevoerd met hetzelfde script in twee wegwerpdatabases, één op Odoo 19 en één op Odoo 20 (Community, modules base en account); de uitvoer staat bij elk onderdeel.
Wat we zagen bij het porten van onze catalogus
We onderhouden een catalogus van connectoren en modules voor Odoo 17, 18, 19 en 20. Om te meten wat er breekt bij de stap naar 20, hebben we alles automatisch geport op kopieën (de migratiescripts die Odoo meelevert in odoo/upgrade_code plus onze eigen) en elke module getest door hem te installeren op een echte Odoo 20 en al zijn tests uit te voeren. De laatste volledige run eindigde op 1 november 2026, met onze code van 31 oktober:
| Resultaat van de automatische port naar 20 | Modules |
|---|---|
| Installeren en slagen voor al hun tests | 151 |
| Installeren zonder enige fout, met falende tests | 55 |
| Installeren niet | 12 |
| Totaal, uit 147 repositories | 218 |
De 12 die niet installeren zijn het makkelijke deel: de log zegt wat er ontbreekt. De 55 in het midden installeerden schoon en hadden, zonder tests die het echte pad doorlopen, klaar geleken. In de log falen die tests op exceptions (KeyError, AttributeError, TypeError, ValueError) of op asserties die niet kloppen. De tien wijzigingen in dit artikel geven geen enkele exception: alleen een test die naar het resultaat kijkt ziet ze, zoals de tests die we er bewust voor schreven (een gebruiker zonder rechten, een wisselkoers met de datum van vandaag, een rapport vergeleken met dat van 19), of een sonde op een echte Odoo 20.
Dit zijn gegevens van de automatische port op kopieën, niet van wat gepubliceerd is: een module van ons verschijnt pas op 20.0 nadat al zijn tests op een echte Odoo 20 geslaagd zijn.
De tien in één oogopslag
| Wijziging in Odoo 20 | Hoe je het merkt |
|---|---|
1. t-esc en t-raw | De waarde blijft leeg in rapporten en op het portaal |
2. account.payment.state | paid betekent nu "gevalideerd, niet afgeletterd" |
3. is_company | Zonder btw-nummer wordt het contact als persoon aangemaakt |
| 4. Wisselkoersen | De koers van vandaag geldt pas vanaf morgen |
5. b64decode van een Binary | Beschadigde bytes of Incorrect padding |
6. read() van een Binary | Een dictionary in plaats van de base64-tekst |
7. Velden hernoemd naar uom_id | getattr valt altijd terug op de standaardwaarde |
8. Toegangsrechten in ir.access | De module installeert zonder enig toegangsrecht |
| 9. Hernoemde methoden | Je overschrijving wordt niet meer uitgevoerd |
10. migrations/20.0.x | Het migratiescript draait twee keer |
1. t-esc en t-raw blijven leeg
Wat er gebeurt. De QWeb-engine van 20 compileert t-esc en t-raw niet meer. In 19 werden ze nog gecompileerd als t-out; in 20 wordt het element leeg weergegeven en de log krijgt alleen een WARNING: Unknown directives or unused attributes: {'t-esc'}.
Hoe je het merkt. De pdf van de factuur of de portaalpagina wordt zonder problemen gegenereerd, maar de waarde ontbreekt. In de sonde geeft dezelfde template <p>HOLA</p> in 19 en <p></p> in 20. Het script van Odoo voor de overstap naar OWL 3 (owl3-migration.py) vervangt t-esc wel door t-out, maar alleen in bestanden onder /static/: de templates van rapporten en portaal blijven zoals ze waren.
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"/>Oplossing. t-out, dat bestaat in 17, 18, 19 en 20. Wat eerder met t-raw werd opgehaald, moet nu als Markup aankomen; anders escapet t-out het.
Bron. 20.0: ir_qweb.py:1927 (de waarschuwing) en owl3-migration.py:547-554 (het OWL 3-script kijkt alleen naar /static/). 19.0: ir_qweb.py:2370-2374 (t-esc en t-raw worden nog gecompileerd).
2. Een betaling paid is niet meer afgeletterd
Wat er gebeurt. Het veld state van account.payment krijgt andere waarden. In 19: draft, in_process, paid, canceled, rejected. In 20: draft, paid, reconciled, canceled, rejected. De paid van 20 is de oude in_process (gevalideerd, niet afgeletterd) en de oude paid heet nu reconciled. Bij facturen behoudt payment_state zijn waarden van 19 en komt er één bij, todo.
Hoe je het merkt. Een filter op 'paid' blijft werken en geeft iets anders terug: gevalideerde betalingen die nog niet afgeletterd zijn. Een filter op 'in_process' geeft in 20 ook geen fout: in de sonde levert het 0 records op zonder enige 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)])Oplossing. Loop de grep-resultaten die over betalingen gaan handmatig na: dezelfde 'paid' in payment_state van een factuur betekent nog steeds hetzelfde.
Bron. 19.0: account_payment.py:36-43. 20.0: account_payment.py:37-44.
3. is_company wordt bepaald door het btw-nummer
Wat er gebeurt. In 20 is res.partner.is_company een berekend, opgeslagen veld dat afhangt van has_vat en commercial_partner_id: een contact is een bedrijf als het zijn eigen commerciële entiteit is en een btw-nummer heeft (niet leeg, en niet "/", "na" of "NA"). In 19 was het een gewone boolean.
Hoe je het merkt. Een create() met is_company: True en zonder btw-nummer maakt een persoon aan, zonder fout. In de sonde geeft 19 zonder btw-nummer True en 20 False; met btw-nummer True in beide. Een latere write() zet het wel op True, tot Odoo het opnieuw berekent doordat het btw-nummer of de commerciële entiteit wijzigt. Een marketplace-connector die bedrijfsklanten zonder btw-nummer aanmaakt, krijgt personen, en als hij het contact daarna opzoekt op is_company om het te hergebruiken, vindt hij niets en maakt hij bij elke order een nieuw aan. Dat overkwam ons bij het porten van een van onze connectoren.
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})Oplossing. Geef het btw-nummer mee als je het hebt, zoek contacten niet op is_company en controleer, als je het veld schrijft, eerst of het berekend is: Partner._fields['is_company'].compute.
Bron. 20.0: res_partner.py:371 (het veld), res_partner.py:942-945 (has_vat) en res_partner.py:950-951 (waarvan het afhangt). 19.0: res_partner.py:277 (gewone boolean).
4. De wisselkoers van vandaag geldt morgen
Wat er gebeurt. res.currency._get_rates zoekt de laatste koers met een datum op of vóór die van de bewerking in 19 ('<=') en strikt vóór die datum in 20 ('<'). In 20 telt een koers met de datum van vandaag niet mee voor bewerkingen van vandaag.
Hoe je het merkt. In de sonde staat het pond gisteren op 1,10 en vandaag op 1,20; 100 dollar omrekenen naar ponden met de datum van vandaag geeft 120 in 19 en 110 in 20. Zonder fout. Een proces dat 's ochtends de koers van de dag laadt en diezelfde dag factureert, boekt met die van gisteren, en een test die een koers met de datum van vandaag aanmaakt en verwacht dat die wordt toegepast, faalt zonder dat er iets in je module is veranderd.
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.0Oplossing. In de code is er geen optie om terug te keren naar het gedrag van 19. Maak in tests de koers aan met de datum van gisteren. Heeft je integratie de exacte koers van de dag nodig, pas die dan expliciet toe in plaats van via _convert te gaan, of sla hem op met de datum die bij de regel van 20 hoort.
Bron. 19.0: res_currency.py:126-129. 20.0: res_currency.py:185-187.
5. b64decode(registro.campo) geeft rommel terug
Wat er gebeurt. In 19 gaf het lezen van een Binary-veld de inhoud als base64 terug. In 20 geeft het een BinaryValue terug, die de ruwe bytes blootlegt via het bufferprotocol van Python. base64.b64decode() accepteert elke buffer: het leest die ruwe bytes alsof het base64 is en gooit, zonder validate=True, weg wat niet in het alfabet zit.
Hoe je het merkt. In de sonde, met een gewoon Binary-veld: met een tekst van 12 bytes geeft 20 9 andere bytes terug en geen enkele fout; met een PNG binascii.Error: Incorrect padding. In 19 zijn beide identiek aan het origineel. Afhankelijk van de inhoud komt een afbeelding of pdf die je naar een ander systeem stuurt beschadigd aan, of loopt de aanroep vast.
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)Bron. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) en fields_binary.py:128-129 (wat het veld bij het lezen teruggeeft).
6. read() van een Binary geeft een dictionary terug
Wat er gebeurt. In 20 geeft read() van een Binary-veld {'content': <base64>, 'size': N} terug, plus filename als die er is. In 19 gaf het rechtstreeks de base64-tekst terug.
Hoe je het merkt. In de sonde geeft read() bytes in 19 en een dictionary met content en size in 20. Als die waarde in een str() of in een tekstsjabloon belandt die naar een ander systeem gaat, wordt de tekst van de dictionary verstuurd en faalt er niets in 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 vBron. 20.0: fields_binary.py:134-146.
7. Hernoemde velden die je met getattr leest
Wat er gebeurt. In 20 worden stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id en mrp.bom.line.product_uom_id hernoemd naar uom_id. De oude naam rechtstreeks gebruiken geeft een fout (in een create(), ValueError: Invalid field). Het probleem is code voor meerdere versies die het veld met een standaardwaarde leest: in 20 geeft getattr(bom, 'product_uom_id', False) altijd de standaardwaarde terug.
Hoe je het merkt. Hoeveelheden in de verkeerde eenheid: een kit in dozijnen, of een regel in kilo's tegenover een component in grammen. Het overkwam ons in een marketplace-connector bij het uitsplitsen van kits: in 20 viel hij altijd terug op de eenheid van het product.
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_idBron. 19.0: mrp_bom.py:46, stock_move.py:67 en purchase_order_line.py:43. 20.0: mrp_bom.py:47, stock_move.py:68 en purchase_order_line.py:49; de ValueError van create(), models.py:4151-4153.
8. Een module zonder toegangsrechten installeert zonder waarschuwing
Wat er gebeurt. Odoo 20 vervangt ir.model.access en ir.rule door een nieuw model, ir.access, met een eigen bestand security/ir.access.csv. Het script van Odoo dat de toegangsrechten omzet (odoo/upgrade_code/19.4-00-ir-access.py) haalt de oude CSV uit het manifest en zoekt, om de nieuwe toe te voegen, een regel die met 'data': [ begint. Staat 'data' in jouw manifest halverwege een regel, dan schrijft het ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually en gaat het verder met de rest.
Hoe je het merkt. De module installeert zonder enig toegangsrecht. In 19 gaf een model zonder toegangsrechten bij de installatie een waarschuwing ("The models [...] have no access rules"); in 20 bestaat die waarschuwing niet meer. De tests, die als superuser draaien, slagen. De gewone gebruiker krijgt "No group currently allows this operation". Het overkwam ons bij modules die al geport waren: een test met een gebruiker zonder rechten ving het op, en sindsdien stopt onze portketen als een ir.access.csv niet wordt geladen.
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'],Oplossing. Voeg 'security/ir.access.csv' handmatig toe aan data en schrijf een test die met een gewone gebruiker van elk model een record aanmaakt en leest (new_test_user en with_user). Het is de enige test die dit ziet.
Bron. 20.0: 19.4-00-ir-access.py:415-434 (waar 'data' zoekt en de ERROR) en ir_access.py:342 (het bericht aan de gebruiker). 19.0: loading.py:250-257 (de waarschuwing die in 20 niet meer bestaat).
9. Een methode overschrijven die Odoo 20 niet meer aanroept
Wat er gebeurt. Python waarschuwt niet als je een methode overschrijft die de basisklasse niet meer heeft: jouw methode bestaat, maar niemand roept hem aan. In 20 vervangt het betaalframework payment.provider._get_compatible_providers door _find_available_providers, met een andere signatuur (wat na amount komt, wordt alleen nog bij naam doorgegeven).
Hoe je het merkt. Als je _get_compatible_providers overschreef om een betaalprovider per land of bedrag te verbergen, verschijnt die provider in 20 weer bij het afrekenen. In de sonde heeft het model van 19 _get_compatible_providers en niet _find_available_providers; dat van 20 juist andersom.
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):Bron. 19.0: payment_provider.py:555, aangeroepen vanuit portal.py:113. 20.0: payment_provider.py:671.
10. Naar 20.0.x gekopieerde migraties draaien opnieuw
Wat er gebeurt. Het is niet nieuw in 20, maar het komt precies bij het porten naar boven. Odoo voert een map migrations/<versión completa> uit als de geïnstalleerde versie lager is dan die van de map en de nieuwe gelijk of hoger. Kopieer je bij het porten migrations/19.0.1.3.0 als migrations/20.0.1.3.0, dan draait dat script bij het upgraden naar 20 van een database met de module op 19.0.1.3.1 opnieuw over gegevens die het al had omgezet. Mappen zonder serie (migrations/1.3.0) vergelijken alleen het modulegedeelte en worden niet herhaald.
Hoe je het merkt. De -u eindigt zonder problemen en de gegevens zijn twee keer omgezet: uren opnieuw verschoven, regels dubbel gekopieerd.
ls -d */migrations/20.0.* 2>/dev/nullmigrations/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 againOplossing. Mappen zonder serie, idempotente scripts (die de toestand controleren voordat ze iets aanraken) en een test die de migratie twee keer uitvoert.
Bron. 20.0: migration.py:197-210 (regels 205 tot 208 voor mappen zonder serie, 210 voor die met volledige versie).
En eentje die niet meer stil is: datas bij bijlagen
In september maakte ir.attachment.create({'datas': ...}) in 20 een lege bijlage aan zonder enige fout (in een connector voor digitale handtekeningen zou de ondertekende pdf met nul bytes zijn opgeslagen). Sinds commit c06a072 van 31 oktober ("[FIX] base: remove fallback for datas") loopt het vast met AssertionError: _check_contents should handle that (ir_attachment.py:502). De oplossing is dezelfde en werkt in 17, 18, 19 en 20: 'raw' met de niet-gecodeerde bytes. De branch 20.0 krijgt nog elke week wijzigingen, dus wat je in september testte, test je best opnieuw met de code van vandaag.
Zo vang je ze op vóór je gebruikers
- Laat de grep van elk onderdeel over je code lopen en bekijk wat eruit komt handmatig.
- Voer je tests ook uit met een gebruiker zonder rechten, niet alleen als superuser.
- Render de rapporten en portaalpagina's en vergelijk de tekst met die van 19.
- Maak testgegevens met de datum van vandaag, niet alleen van gisteren: wisselkoersen, betalingen, termijnen.
- Upgrade een database van 19 met gegevens naar 20 en voer de migratie twee keer uit.
Veelgestelde vragen
Lossen de migratiescripts van Odoo (upgrade_code) deze wijzigingen op?
Nee. Geen enkel script in odoo/upgrade_code van de branch 20.0 lost deze tien op: dat van de toegangsrechten (19.4-00-ir-access) is juist het script dat een module zonder rechten kan achterlaten, en dat van OWL 3 vervangt t-esc door t-out alleen in bestanden van /static/.
Waarom zien de tests ze niet?
Omdat ze als superuser draaien, met gegevens die die paden niet doorlopen en zonder te kijken naar wat er gegenereerd wordt. Een rapport met een leeg veld geeft niets en 'paid' blijft een geldige waarde. Je hebt tests nodig die naar het resultaat kijken: de tekst van het rapport, het omgerekende bedrag, wat een gewone gebruiker ziet.
Kan de code al in 19 klaargemaakt worden vóór het porten?
In meerdere gevallen wel: t-out in plaats van t-esc en 'raw' bij bijlagen werken al in 17, 18 en 19, en de hernoemde velden lezen via _fields maakt dezelfde code klaar voor 20.
Nuttige links binnen FlexigoTech
Connectoren voor Odoo 17, 18, 19 en 20
FlexigoTech maakt connectoren en modules voor Odoo 17, 18, 19 en 20 en test ze zo op elke versie voordat ze gepubliceerd worden.

