Saltar para o conteúdo principal
Odoo 20 · Guia técnico

O que o Odoo 20 quebra sem avisar: dez mudanças que não dão qualquer erro

O módulo instala, os testes passam e o resultado já não é o mesmo. Dez mudanças do Odoo 20 que não lançam nenhuma exceção, com o grep que as encontra, a correção e a linha do código do Odoo onde está cada uma.

Código-fonte do Odoo 20 comparado com o do Odoo 19 para encontrar as mudanças que não dão erro

O Odoo 20 quebra muita coisa com um erro claro: um import que já não existe, um campo renomeado, um módulo que não instala. Esses surgem no primeiro arranque e corrigem-se. Este artigo trata dos outros: dez mudanças que não lançam nenhuma exceção. O módulo instala, os seus testes passam e o resultado já não é o mesmo.

Método. Cada ponto foi lido no código de odoo/odoo, ramo 20.0 (commit 2fec203, 2 de novembro de 2026), e comparado com o ramo 19.0 (commit 4d2d4b5, do mesmo dia). As ligações de cada secção levam a essas linhas exatas. Sete dos dez foram executados por nós a 6 de novembro com o mesmo guião em duas bases descartáveis, uma de Odoo 19 e outra de Odoo 20 (Community, módulos base e account); o resultado vem em cada secção.

O que vimos ao portar o nosso catálogo

Mantemos um catálogo de conectores e módulos para Odoo 17, 18, 19 e 20. Para medir o que quebra ao subir para a 20, portámo-lo todo de forma automática sobre cópias (os guiões de migração que o Odoo traz em odoo/upgrade_code mais os nossos) e testámos cada módulo instalando-o num Odoo 20 real e executando todos os seus testes. A última passagem completa terminou a 1 de novembro de 2026, com o nosso código de 31 de outubro:

Resultado da portagem automática para a 20Módulos
Instalam e passam todos os testes151
Instalam sem qualquer erro, com testes a vermelho55
Não instalam12
Total, de 147 repositórios218

Os 12 que não instalam são a parte fácil: o log diz o que falta. Os 55 do meio instalaram sem problemas e, sem testes que percorram o caminho real, teriam parecido prontos. No log, esses testes falham por exceções (KeyError, AttributeError, TypeError, ValueError) ou por asserções que não se cumprem. As dez mudanças deste artigo não lançam nenhuma exceção: só as vê um teste que olhe para o resultado, como os que escrevemos de propósito (um utilizador sem privilégios, uma taxa de câmbio com data de hoje, um relatório comparado com o da 19), ou uma sonda num Odoo 20 real.

São dados da portagem automática sobre cópias, não do que está publicado: um módulo nosso sai na 20.0 depois de passar todos os testes num Odoo 20 real.

As dez, num relance

Mudança no Odoo 20Como se nota
1. t-esc e t-rawO dado sai em branco nos relatórios e no portal
2. account.payment.statepaid passa a ser «validado, por reconciliar»
3. is_companySem NIF, o contacto é criado como pessoa
4. Taxas de câmbioA taxa de hoje aplica-se a partir de amanhã
5. b64decode de um BinaryBytes corrompidos ou Incorrect padding
6. read() de um BinaryUm dicionário em vez do base64
7. Campos renomeados para uom_idgetattr cai sempre no valor por defeito
8. Permissões em ir.accessO módulo instala sem nenhuma permissão
9. Métodos renomeadosA sua sobrescrita deixa de ser executada
10. migrations/20.0.xO guião de migração corre duas vezes

1. t-esc e t-raw saem em branco

O que acontece. O motor QWeb da 20 já não compila t-esc nem t-raw. Na 19 ainda eram compilados como t-out; na 20 o elemento é desenhado vazio e o log deixa apenas um WARNING: Unknown directives or unused attributes: {'t-esc'}.

Como se nota. O PDF da fatura ou a página do portal geram-se bem, mas o dado não está lá. Na sonda, o mesmo template dá <p>HOLA</p> na 19 e <p></p> na 20. O guião do Odoo para passar a OWL 3 (owl3-migration.py) troca de facto t-esc por t-out, mas só nos ficheiros que estão em /static/: os templates de relatórios e do portal ficam como estavam.

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"/>

Correção. t-out, que existe na 17, 18, 19 e 20. O que antes ia com t-raw tem de chegar como Markup; caso contrário, t-out escapa-o.

Fonte. 20.0: ir_qweb.py:1927 (o aviso) e owl3-migration.py:547-554 (o guião de OWL 3 só olha para /static/). 19.0: ir_qweb.py:2370-2374 (t-esc e t-raw ainda são compilados).

2. Um pagamento paid já não está reconciliado

O que acontece. O campo state de account.payment muda de valores. Na 19: draft, in_process, paid, canceled, rejected. Na 20: draft, paid, reconciled, canceled, rejected. O paid da 20 é o antigo in_process (validado, por reconciliar) e o antigo paid passa a chamar-se reconciled. Nas faturas, payment_state mantém os valores da 19 e acrescenta um, todo.

Como se nota. Um filtro por 'paid' continua a funcionar e devolve outra coisa: pagamentos validados que ainda não estão reconciliados. Um por 'in_process' também não dá erro na 20: na sonda devolve 0 registos, sem qualquer exceção.

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)])

Correção. Reveja à mão os resultados do grep que sejam de pagamentos: o mesmo 'paid' em payment_state de uma fatura continua a significar o mesmo.

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

3. is_company é decidido pelo NIF

O que acontece. Na 20, res.partner.is_company é um campo calculado e guardado que depende de has_vat e de commercial_partner_id: um contacto é empresa se for a sua própria entidade comercial e tiver NIF (não vazio, nem «/», «na» ou «NA»). Na 19 era um booleano normal.

Como se nota. Um create() com is_company: True e sem NIF cria uma pessoa, sem erro. Na sonda, sem NIF, a 19 dá True e a 20 dá False; com NIF, True nas duas. Um write() posterior deixa-o de facto em True, até o Odoo o recalcular porque muda o NIF ou a entidade comercial. A um conector de marketplace que cria clientes empresa sem NIF saem pessoas e, se depois procurar o contacto por is_company para o reutilizar, não o encontra e cria outro em cada encomenda. Aconteceu-nos ao portar um dos nossos.

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})

Correção. Passe o NIF quando o tiver, não procure contactos por is_company e, se o escrever, verifique antes se o campo é calculado: Partner._fields['is_company'].compute.

Fonte. 20.0: res_partner.py:371 (o campo), res_partner.py:942-945 (has_vat) e res_partner.py:950-951 (de que depende). 19.0: res_partner.py:277 (booleano normal).

4. A taxa de câmbio de hoje aplica-se amanhã

O que acontece. res.currency._get_rates procura a última taxa com data anterior ou igual à da operação na 19 ('<=') e estritamente anterior na 20 ('<'). Na 20, uma taxa com data de hoje não conta para as operações de hoje.

Como se nota. Na sonda, a libra tem 1,10 ontem e 1,20 hoje; converter 100 dólares em libras com data de hoje dá 120 na 19 e 110 na 20. Sem erro. Um processo que carrega a taxa do dia de manhã e fatura nesse mesmo dia contabiliza com a de ontem, e um teste que cria uma taxa com data de hoje e espera vê-la aplicada falha sem que nada tenha mudado no seu módulo.

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

Correção. No código não existe nenhuma opção para voltar ao comportamento da 19. Nos testes, crie a taxa com data de ontem. Se a sua integração precisa da taxa exata do dia, aplique-a de forma explícita em vez de passar por _convert, ou guarde-a com a data que lhe corresponde segundo a regra da 20.

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

5. b64decode(registro.campo) devolve lixo

O que acontece. Na 19, ler um campo Binary devolvia o conteúdo em base64. Na 20 devolve um BinaryValue, que expõe os bytes em bruto com o protocolo de buffer do Python. base64.b64decode() aceita qualquer buffer: lê esses bytes em bruto como se fossem base64 e, sem validate=True, descarta o que não pertence ao alfabeto.

Como se nota. Na sonda, com um campo Binary normal: com um texto de 12 bytes, a 20 devolve 9 bytes diferentes e nenhum erro; com um PNG, binascii.Error: Incorrect padding. Na 19 os dois saem idênticos ao original. Consoante o conteúdo, uma imagem ou um PDF que envia a outro sistema chega corrompido ou a chamada rebenta.

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 (o que o campo devolve ao ser lido).

6. read() de um Binary devolve um dicionário

O que acontece. Na 20, read() de um campo Binary devolve {'content': <base64>, 'size': N}, mais filename se o tiver. Na 19 devolvia diretamente o base64.

Como se nota. Na sonda, read() devolve bytes na 19 e um dicionário com content e size na 20. Se esse valor acaba num str() ou num template de texto que vai para outro sistema, é enviado o texto do dicionário e no Odoo não falha nada.

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

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

7. Campos renomeados que se leem com getattr

O que acontece. Na 20, stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id e mrp.bom.line.product_uom_id passam a chamar-se uom_id. Usar o nome antigo diretamente dá erro (num create(), ValueError: Invalid field). O problema é o código pensado para várias versões que o lê com um valor por defeito: na 20, getattr(bom, 'product_uom_id', False) devolve sempre o valor por defeito.

Como se nota. Quantidades na unidade errada: um kit em dúzias, ou uma linha em quilos contra um componente em gramas. Aconteceu-nos num conector de marketplace ao desdobrar kits: na 20 caía sempre na unidade do produto.

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

Fonte. 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; o ValueError de create(), models.py:4151-4153.

8. Um módulo sem permissões instala sem avisar

O que acontece. O Odoo 20 substitui ir.model.access e ir.rule por um modelo novo, ir.access, com o seu ficheiro security/ir.access.csv. O guião do Odoo que converte as permissões (odoo/upgrade_code/19.4-00-ir-access.py) retira o CSV antigo do manifesto e, para acrescentar o novo, procura uma linha que comece por 'data': [. Se no seu manifesto 'data' estiver a meio de uma linha, escreve ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually e segue com o resto.

Como se nota. O módulo instala sem nenhuma permissão. Na 19, um modelo sem permissões dava um aviso ao instalar («The models [...] have no access rules»); na 20 esse aviso já não existe. Os testes, que correm como superutilizador, passam. O utilizador normal recebe «No group currently allows this operation». Aconteceu-nos com módulos já portados: apanhou-o um teste com um utilizador sem privilégios, e desde então a nossa cadeia de portagem pára se um ir.access.csv não for carregado.

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'],

Correção. Acrescente 'security/ir.access.csv' a data à mão e escreva um teste que crie e leia um registo de cada modelo com um utilizador normal (new_test_user e with_user). É o único teste que o vê.

Fonte. 20.0: 19.4-00-ir-access.py:415-434 (onde procura 'data' e o ERROR) e ir_access.py:342 (a mensagem ao utilizador). 19.0: loading.py:250-257 (o aviso que na 20 já não existe).

9. Sobrescrever um método que o Odoo 20 já não chama

O que acontece. O Python não avisa se sobrescreve um método que a classe base já não tem: o seu método existe, mas ninguém o chama. Na 20, a framework de pagamentos substitui payment.provider._get_compatible_providers por _find_available_providers, com outra assinatura (o que vem depois de amount passa a ser apenas por nome).

Como se nota. Se sobrescrevia _get_compatible_providers para ocultar um provedor de pagamento por país ou por montante, na 20 o provedor volta a aparecer ao pagar. Na sonda, o modelo da 19 tem _get_compatible_providers e não _find_available_providers; o da 20, ao contrário.

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, chamado a partir de portal.py:113. 20.0: payment_provider.py:671.

10. As migrações copiadas para 20.0.x repetem-se

O que acontece. Não é novidade da 20, mas surge precisamente ao portar. O Odoo executa uma pasta migrations/<versión completa> se a versão instalada for inferior à da pasta e a nova for igual ou superior. Se ao portar copia migrations/19.0.1.3.0 como migrations/20.0.1.3.0, ao subir para a 20 uma base com o módulo em 19.0.1.3.1, esse guião volta a correr sobre dados que já transformou. As pastas sem série (migrations/1.3.0) comparam apenas a parte do módulo e não se repetem.

Como se nota. O -u termina bem e os dados ficam transformados duas vezes: horas deslocadas outra vez, linhas copiadas duas vezes.

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

Correção. Pastas sem série, guiões idempotentes (que vejam o estado antes de tocar em nada) e um teste que execute a migração duas vezes.

Fonte. 20.0: migration.py:197-210 (linhas 205 a 208 para as pastas sem série, 210 para as de versão completa).

E uma que já não é silenciosa: datas nos anexos

Em setembro, ir.attachment.create({'datas': ...}) criava na 20 um anexo vazio sem qualquer erro (num conector de assinatura, o PDF assinado teria sido guardado com zero bytes). Desde o commit c06a072 de 31 de outubro («[FIX] base: remove fallback for datas») rebenta com AssertionError: _check_contents should handle that (ir_attachment.py:502). A correção é a mesma e vale na 17, 18, 19 e 20: 'raw' com os bytes sem codificar. O ramo 20.0 continua a receber alterações todas as semanas, pelo que o que testou em setembro convém voltar a testar com o código de hoje.

Como apanhá-las antes dos seus utilizadores

  1. Passe pelo seu código os grep de cada secção e reveja à mão o que aparecer.
  2. Execute os testes também com um utilizador sem privilégios, não só como superutilizador.
  3. Renderize os relatórios e as páginas do portal e compare o texto com o da 19.
  4. Crie dados de teste com data de hoje, não só de ontem: taxas de câmbio, pagamentos, prazos.
  5. Suba uma base da 19 para a 20 com dados e execute a migração duas vezes.

Perguntas frequentes

Os guiões de migração do Odoo (upgrade_code) corrigem estas mudanças?

Não. Nenhum dos guiões de odoo/upgrade_code do ramo 20.0 resolve estas dez: o de permissões (19.4-00-ir-access) é justamente o que pode deixar um módulo sem elas, e o de OWL 3 troca t-esc por t-out apenas nos ficheiros de /static/.

Porque é que os testes não as veem?

Porque correm como superutilizador, com dados que não passam por esses caminhos e sem olhar para o que é gerado. Um relatório com um campo em branco não lança nada e 'paid' continua a ser um valor válido. São precisos testes que olhem para o resultado: o texto do relatório, o montante convertido, o que vê um utilizador normal.

É possível deixar o código pronto na 19 antes de portar?

Em vários casos, sim: t-out em vez de t-esc e 'raw' nos anexos já funcionam na 17, 18 e 19, e ler os campos renomeados através de _fields deixa o mesmo código pronto para a 20.

Ligações úteis dentro da FlexigoTech

Odoo 20: o que muda para os seus conectoresO que desaparece e não instalaMigrar para o Odoo 20: checklistPara empresas com integrações ativasOs nossos conectores já no Odoo 20A lista, com ligação a cada ficha

Conectores para Odoo 17, 18, 19 e 20

A FlexigoTech faz conectores e módulos para Odoo 17, 18, 19 e 20, e testa-os assim em cada versão antes de os publicar.

Ver o catálogo