Odoo 20 rompe molte cose con un errore chiaro: un import che non esiste più, un campo rinominato, un modulo che non si installa. Quelle emergono al primo avvio e si correggono. Questo articolo parla delle altre: dieci modifiche che non sollevano alcuna eccezione. Il modulo si installa, i suoi test passano e il risultato non è più lo stesso.
Metodo. Ogni punto è stato letto nel codice di odoo/odoo, ramo 20.0 (commit 2fec203, 2 novembre 2026), e confrontato con il ramo 19.0 (commit 4d2d4b5, dello stesso giorno). I link di ogni sezione portano a quelle righe esatte. Sette dei dieci li abbiamo eseguiti il 6 novembre con lo stesso script su due database usa e getta, uno di Odoo 19 e uno di Odoo 20 (Community, moduli base e account); il risultato è riportato in ogni sezione.
Cosa abbiamo visto portando il nostro catalogo
Manteniamo un catalogo di connettori e moduli per Odoo 17, 18, 19 e 20. Per misurare cosa si rompe passando alla 20, lo abbiamo portato per intero in modo automatico su copie (gli script di migrazione che Odoo include in odoo/upgrade_code più i nostri) e abbiamo provato ogni modulo installandolo su un Odoo 20 reale ed eseguendo tutti i suoi test. L'ultima verifica completa è terminata il 1 novembre 2026, con il nostro codice del 31 ottobre:
| Risultato del porting automatico alla 20 | Moduli |
|---|---|
| Si installano e superano tutti i test | 151 |
| Si installano senza alcun errore, con test falliti | 55 |
| Non si installano | 12 |
| Totale, da 147 repository | 218 |
I 12 che non si installano sono la parte facile: il log dice cosa manca. I 55 centrali si sono installati senza problemi e, senza test che percorrano il flusso reale, sarebbero sembrati pronti. Nel log, quei test falliscono per eccezioni (KeyError, AttributeError, TypeError, ValueError) o per asserzioni non soddisfatte. Le dieci modifiche di questo articolo non sollevano alcuna eccezione: le vede solo un test che controlli il risultato, come quelli che abbiamo scritto apposta (un utente senza privilegi, un tasso di cambio con data di oggi, un report confrontato con quello della 19), oppure una sonda su un Odoo 20 reale.
Sono dati del porting automatico su copie, non di ciò che è pubblicato: un nostro modulo esce in 20.0 dopo aver superato tutti i suoi test su un Odoo 20 reale.
Le dieci, a colpo d'occhio
| Modifica in Odoo 20 | Come si nota |
|---|---|
1. t-esc e t-raw | Il dato appare vuoto nei report e nel portale |
2. account.payment.state | paid diventa «convalidato, non riconciliato» |
3. is_company | Senza partita IVA, il contatto viene creato come persona |
| 4. Tassi di cambio | Il tasso di oggi si applica da domani |
5. b64decode di un Binary | Byte corrotti o Incorrect padding |
6. read() di un Binary | Un dizionario al posto del base64 |
7. Campi rinominati in uom_id | getattr ricade sempre sul valore predefinito |
8. Permessi in ir.access | Il modulo si installa senza alcun permesso |
| 9. Metodi rinominati | Il tuo override smette di essere eseguito |
10. migrations/20.0.x | Lo script di migrazione viene eseguito due volte |
1. t-esc e t-raw appaiono vuoti
Cosa succede. Il motore QWeb della 20 non compila più t-esc né t-raw. Nella 19 venivano ancora compilati come t-out; nella 20 l'elemento viene reso vuoto e il log lascia solo un WARNING: Unknown directives or unused attributes: {'t-esc'}.
Come si nota. Il PDF della fattura o la pagina del portale vengono generati correttamente, ma il dato non c'è. Nella sonda, lo stesso template dà <p>HOLA</p> nella 19 e <p></p> nella 20. Lo script di Odoo per passare a OWL 3 (owl3-migration.py) sostituisce sì t-esc con t-out, ma solo nei file che dipendono da /static/: i template dei report e del portale restano come erano.
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"/>Correzione. t-out, che esiste in 17, 18, 19 e 20. Ciò che prima passava con t-raw deve arrivare come Markup; altrimenti t-out lo effettua l'escape.
Fonte. 20.0: ir_qweb.py:1927 (l'avviso) e owl3-migration.py:547-554 (lo script di OWL 3 guarda solo /static/). 19.0: ir_qweb.py:2370-2374 (t-esc e t-raw vengono ancora compilati).
2. Un pagamento paid non è più riconciliato
Cosa succede. Il campo state di account.payment cambia valori. Nella 19: draft, in_process, paid, canceled, rejected. Nella 20: draft, paid, reconciled, canceled, rejected. Il paid della 20 è il vecchio in_process (convalidato, non riconciliato) e il vecchio paid ora si chiama reconciled. Nelle fatture, payment_state mantiene i valori della 19 e ne aggiunge uno, todo.
Come si nota. Un filtro per 'paid' continua a funzionare e restituisce altro: pagamenti convalidati non ancora riconciliati. Uno per 'in_process' non dà errore nemmeno nella 20: nella sonda restituisce 0 record senza alcuna eccezione.
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)])Correzione. Controlla a mano i risultati del grep che riguardano i pagamenti: lo stesso 'paid' in payment_state di una fattura continua a significare la stessa cosa.
Fonte. 19.0: account_payment.py:36-43. 20.0: account_payment.py:37-44.
3. is_company lo decide la partita IVA
Cosa succede. Nella 20, res.partner.is_company è un campo calcolato e memorizzato che dipende da has_vat e da commercial_partner_id: un contatto è un'azienda se è la propria entità commerciale e ha una partita IVA (non vuota, né «/», «na» o «NA»). Nella 19 era un booleano normale.
Come si nota. Un create() con is_company: True e senza partita IVA crea una persona, senza errori. Nella sonda, senza partita IVA, la 19 dà True e la 20 dà False; con la partita IVA, True in entrambe. Un write() successivo lo porta sì a True, finché Odoo non lo ricalcola perché cambia la partita IVA o l'entità commerciale. A un connettore di marketplace che crea clienti azienda senza partita IVA risultano persone e, se poi cerca il contatto per is_company per riutilizzarlo, non lo trova e ne crea un altro a ogni ordine. Ci è successo portando uno dei nostri.
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})Correzione. Passa la partita IVA quando ce l'hai, non cercare contatti per is_company e, se lo scrivi, verifica prima se il campo è calcolato: Partner._fields['is_company'].compute.
Fonte. 20.0: res_partner.py:371 (il campo), res_partner.py:942-945 (has_vat) e res_partner.py:950-951 (da cosa dipende). 19.0: res_partner.py:277 (booleano normale).
4. Il tasso di cambio di oggi si applica domani
Cosa succede. res.currency._get_rates cerca l'ultimo tasso con data precedente o uguale a quella dell'operazione nella 19 ('<=') e strettamente precedente nella 20 ('<'). Nella 20, un tasso con data di oggi non conta per le operazioni di oggi.
Come si nota. Nella sonda, la sterlina vale 1,10 ieri e 1,20 oggi; convertire 100 dollari in sterline con data di oggi dà 120 nella 19 e 110 nella 20. Senza errori. Un processo che carica il tasso del giorno al mattino e fattura lo stesso giorno contabilizza con quello di ieri, e un test che crea un tasso con data di oggi e si aspetta di vederlo applicato fallisce senza che nel tuo modulo sia cambiato nulla.
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.0Correzione. Nel codice non esiste alcuna opzione per tornare al comportamento della 19. Nei test, crea il tasso con data di ieri. Se la tua integrazione ha bisogno del tasso esatto del giorno, applicalo in modo esplicito invece di passare per _convert, oppure salvalo con la data che gli spetta secondo la regola della 20.
Fonte. 19.0: res_currency.py:126-129. 20.0: res_currency.py:185-187.
5. b64decode(registro.campo) restituisce spazzatura
Cosa succede. Nella 19, leggere un campo Binary restituiva il contenuto in base64. Nella 20 restituisce un BinaryValue, che espone i byte grezzi con il protocollo buffer di Python. base64.b64decode() accetta qualsiasi buffer: legge quei byte grezzi come se fossero base64 e, senza validate=True, scarta ciò che non appartiene all'alfabeto.
Come si nota. Nella sonda, con un normale campo Binary: con un testo di 12 byte, la 20 restituisce 9 byte diversi e nessun errore; con un PNG, binascii.Error: Incorrect padding. Nella 19 entrambi risultano identici all'originale. A seconda del contenuto, un'immagine o un PDF che invii a un altro sistema arriva corrotto oppure la chiamata va in errore.
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)Fonte. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) e fields_binary.py:128-129 (ciò che il campo restituisce alla lettura).
6. read() di un Binary restituisce un dizionario
Cosa succede. Nella 20, read() di un campo Binary restituisce {'content': <base64>, 'size': N}, più filename se presente. Nella 19 restituiva direttamente il base64.
Come si nota. Nella sonda, read() restituisce bytes nella 19 e un dizionario con content e size nella 20. Se quel valore finisce in un str() o in un template di testo destinato a un altro sistema, viene inviato il testo del dizionario e in Odoo non fallisce nulla.
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 vFonte. 20.0: fields_binary.py:134-146.
7. Campi rinominati che si leggono con getattr
Cosa succede. Nella 20, stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id e mrp.bom.line.product_uom_id vengono rinominati in uom_id. Usare direttamente il vecchio nome dà errore (in un create(), ValueError: Invalid field). Il problema è il codice pensato per più versioni che lo legge con un valore predefinito: nella 20, getattr(bom, 'product_uom_id', False) restituisce sempre il valore predefinito.
Come si nota. Quantità nell'unità sbagliata: un kit in dozzine, o una riga in chili contro un componente in grammi. Ci è successo in un connettore di marketplace nello scomporre i kit: nella 20 ricadeva sempre sull'unità del prodotto.
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_idFonte. 19.0: mrp_bom.py:46, stock_move.py:67 e purchase_order_line.py:43. 20.0: mrp_bom.py:47, stock_move.py:68 e purchase_order_line.py:49; il ValueError di create(), models.py:4151-4153.
8. Un modulo senza permessi si installa senza avvisare
Cosa succede. Odoo 20 sostituisce ir.model.access e ir.rule con un nuovo modello, ir.access, con il suo file security/ir.access.csv. Lo script di Odoo che converte i permessi (odoo/upgrade_code/19.4-00-ir-access.py) toglie il vecchio CSV dal manifest e, per aggiungere il nuovo, cerca una riga che inizi con 'data': [. Se nel tuo manifest 'data' si trova a metà riga, scrive ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually e prosegue con il resto.
Come si nota. Il modulo si installa senza alcun permesso. Nella 19, un modello senza permessi dava un avviso all'installazione («The models [...] have no access rules»); nella 20 quell'avviso non esiste più. I test, che girano come superutente, passano. L'utente normale riceve «No group currently allows this operation». Ci è successo con moduli già portati: l'ha individuato un test con un utente senza privilegi e da allora la nostra catena di porting si ferma se un ir.access.csv non viene caricato.
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'],Correzione. Aggiungi 'security/ir.access.csv' a data a mano e scrivi un test che crei e legga un record di ogni modello con un utente normale (new_test_user e with_user). È l'unico test che lo vede.
Fonte. 20.0: 19.4-00-ir-access.py:415-434 (dove cerca 'data' e l'ERROR) e ir_access.py:342 (il messaggio all'utente). 19.0: loading.py:250-257 (l'avviso che nella 20 non c'è più).
9. Sovrascrivere un metodo che Odoo 20 non chiama più
Cosa succede. Python non avvisa se sovrascrivi un metodo che la classe base non ha più: il tuo metodo esiste, ma nessuno lo chiama. Nella 20, il framework dei pagamenti sostituisce payment.provider._get_compatible_providers con _find_available_providers, con una firma diversa (ciò che segue amount diventa solo per nome).
Come si nota. Se sovrascrivevi _get_compatible_providers per nascondere un fornitore di pagamento per paese o per importo, nella 20 il fornitore ricompare al momento del pagamento. Nella sonda, il modello della 19 ha _get_compatible_providers e non _find_available_providers; quello della 20, al contrario.
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):Fonte. 19.0: payment_provider.py:555, chiamato da portal.py:113. 20.0: payment_provider.py:671.
10. Le migrazioni copiate in 20.0.x vengono ripetute
Cosa succede. Non è una novità della 20, ma emerge proprio durante il porting. Odoo esegue una cartella migrations/<versión completa> se la versione installata è minore di quella della cartella e la nuova è uguale o maggiore. Se nel porting copi migrations/19.0.1.3.0 come migrations/20.0.1.3.0, passando alla 20 un database con il modulo alla 19.0.1.3.1, quello script torna a girare su dati già trasformati. Le cartelle senza serie (migrations/1.3.0) confrontano solo la parte del modulo e non si ripetono.
Come si nota. Il -u termina senza problemi e i dati risultano trasformati due volte: orari spostati di nuovo, righe copiate due volte.
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 againCorrezione. Cartelle senza serie, script idempotenti (che controllino lo stato prima di toccare qualcosa) e un test che esegua la migrazione due volte.
Fonte. 20.0: migration.py:197-210 (righe da 205 a 208 per le cartelle senza serie, 210 per quelle con versione completa).
E una che non è più silenziosa: datas negli allegati
A settembre, ir.attachment.create({'datas': ...}) creava nella 20 un allegato vuoto senza alcun errore (in un connettore di firma, il PDF firmato sarebbe stato salvato con zero byte). Dal commit c06a072 del 31 ottobre («[FIX] base: remove fallback for datas») va in errore con AssertionError: _check_contents should handle that (ir_attachment.py:502). La correzione è la stessa e vale in 17, 18, 19 e 20: 'raw' con i byte non codificati. Il ramo 20.0 continua a ricevere modifiche ogni settimana, quindi ciò che hai provato a settembre conviene riprovarlo con il codice di oggi.
Come individuarle prima dei tuoi utenti
- Passa nel tuo codice i grep di ogni sezione e controlla a mano ciò che esce.
- Esegui i test anche con un utente senza privilegi, non solo come superutente.
- Genera i report e le pagine del portale e confronta il testo con quello della 19.
- Crea dati di prova con data di oggi, non solo di ieri: tassi di cambio, pagamenti, scadenze.
- Aggiorna un database dalla 19 alla 20 con dati ed esegui la migrazione due volte.
Domande frequenti
Gli script di migrazione di Odoo (upgrade_code) correggono queste modifiche?
No. Nessuno degli script di odoo/upgrade_code del ramo 20.0 risolve queste dieci: quello dei permessi (19.4-00-ir-access) è proprio quello che può lasciare un modulo senza, e quello di OWL 3 sostituisce t-esc con t-out solo nei file di /static/.
Perché i test non le vedono?
Perché girano come superutente, con dati che non passano da quei percorsi e senza controllare ciò che viene generato. Un report con un campo vuoto non solleva nulla e 'paid' resta un valore valido. Servono test che controllino il risultato: il testo del report, l'importo convertito, ciò che vede un utente normale.
Si può preparare il codice nella 19 prima del porting?
In diversi casi, sì: t-out al posto di t-esc e 'raw' negli allegati funzionano già in 17, 18 e 19, e leggere i campi rinominati tramite _fields lascia lo stesso codice pronto per la 20.
Link utili in FlexigoTech
Connettori per Odoo 17, 18, 19 e 20
FlexigoTech crea connettori e moduli per Odoo 17, 18, 19 e 20, e li prova così su ogni versione prima di pubblicarli.

