Saltar al contenido principal
Odoo 20 · Guía técnica

Qué rompe Odoo 20 sin avisar: diez cambios que no dan ningún error

El módulo instala, las pruebas pasan y el resultado ya no es el mismo. Diez cambios de Odoo 20 que no lanzan ninguna excepción, con el grep que los encuentra, el arreglo y la línea del código de Odoo donde está cada uno.

Código fuente de Odoo 20 comparado con el de Odoo 19 para encontrar los cambios que no dan error

Odoo 20 rompe muchas cosas con un error claro: un import que ya no existe, un campo renombrado, un módulo que no instala. Esas salen en el primer arranque y se arreglan. Este artículo va de las otras: diez cambios que no lanzan ninguna excepción. El módulo instala, sus pruebas pasan y el resultado ya no es el mismo.

Método. Cada punto está leído en el código de odoo/odoo, rama 20.0 (commit 2fec203, 2 de noviembre de 2026), y comparado con la rama 19.0 (commit 4d2d4b5, del mismo día). Los enlaces de cada apartado llevan a esas líneas exactas. Siete de los diez los ejecutamos el 6 de noviembre con el mismo guion en dos bases de usar y tirar, una de Odoo 19 y otra de Odoo 20 (Community, módulos base y account); su salida va en cada apartado.

Lo que vimos al portar nuestro catálogo

Mantenemos un catálogo de conectores y módulos para Odoo 17, 18, 19 y 20. Para medir qué se rompe al subir a 20, lo portamos entero de forma automática sobre copias (los guiones de migración que trae Odoo en odoo/upgrade_code más los nuestros) y probamos cada módulo instalándolo en un Odoo 20 real y ejecutando todas sus pruebas. El último barrido completo terminó el 1 de noviembre de 2026, con nuestro código del 31 de octubre:

Resultado del porte automático a 20Módulos
Instalan y pasan todas sus pruebas151
Instalan sin ningún error, con pruebas en rojo55
No instalan12
Total, de 147 repositorios218

Los 12 que no instalan son la parte fácil: el log dice qué falta. Los 55 del medio instalaron limpios, y sin pruebas que recorran el camino de verdad habrían parecido listos. En el log, esas pruebas caen por excepciones (KeyError, AttributeError, TypeError, ValueError) o por aserciones que no se cumplen. Los diez cambios de este artículo no lanzan ninguna excepción: solo los ve una prueba que mire el resultado, como las que escribimos a propósito (un usuario sin privilegios, un tipo de cambio con fecha de hoy, un informe comparado con el de 19), o una sonda en un Odoo 20 real.

Son datos del porte automático sobre copias, no de lo publicado: un módulo nuestro sale en 20.0 después de pasar todas sus pruebas en un Odoo 20 real.

Los diez, de un vistazo

Cambio en Odoo 20Cómo se nota
1. t-esc y t-rawEl dato sale en blanco en informes y en el portal
2. account.payment.statepaid pasa a ser «validado, sin conciliar»
3. is_companySin NIF, el contacto se crea como persona
4. Tipos de cambioEl tipo de hoy se aplica a partir de mañana
5. b64decode de un BinaryBytes corruptos o Incorrect padding
6. read() de un BinaryUn diccionario en lugar del base64
7. Campos renombrados a uom_idgetattr cae siempre al valor por defecto
8. Permisos en ir.accessEl módulo instala sin ningún permiso
9. Métodos renombradosTu sobrescritura deja de ejecutarse
10. migrations/20.0.xEl guion de migración corre dos veces

1. t-esc y t-raw salen en blanco

Qué pasa. El motor QWeb de 20 ya no compila t-esc ni t-raw. En 19 todavía se compilaban como t-out; en 20 el elemento se pinta vacío y el log solo deja un WARNING: Unknown directives or unused attributes: {'t-esc'}.

Cómo se nota. El PDF de la factura o la página del portal se generan bien, pero el dato no está. En la sonda, la misma plantilla da <p>HOLA</p> en 19 y <p></p> en 20. El guion de Odoo para pasar a OWL 3 (owl3-migration.py) sí cambia t-esc por t-out, pero solo en los ficheros que cuelgan de /static/: las plantillas de informes y del portal se quedan como estaban.

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

Arreglo. t-out, que existe en 17, 18, 19 y 20. Lo que antes iba con t-raw tiene que llegar como Markup; si no, t-out lo escapa.

Fuente. 20.0: ir_qweb.py:1927 (el aviso) y owl3-migration.py:547-554 (el guion de OWL 3 solo mira /static/). 19.0: ir_qweb.py:2370-2374 (t-esc y t-raw todavía se compilan).

2. Un pago paid ya no está conciliado

Qué pasa. El campo state de account.payment cambia de valores. En 19: draft, in_process, paid, canceled, rejected. En 20: draft, paid, reconciled, canceled, rejected. El paid de 20 es el antiguo in_process (validado, sin conciliar) y el antiguo paid pasa a llamarse reconciled. En las facturas, payment_state conserva sus valores de 19 y añade uno, todo.

Cómo se nota. Un filtro por 'paid' sigue funcionando y devuelve otra cosa: pagos validados que todavía no están conciliados. Uno por 'in_process' tampoco da error en 20: en la sonda devuelve 0 registros sin ninguna excepción.

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

Arreglo. Revisa a mano los resultados del grep que sean de pagos: el mismo 'paid' en payment_state de una factura sigue significando lo mismo.

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

3. is_company lo decide el NIF

Qué pasa. En 20, res.partner.is_company es un campo calculado y guardado que depende de has_vat y de commercial_partner_id: un contacto es empresa si es su propia entidad comercial y tiene NIF (no vacío, ni «/», «na» o «NA»). En 19 era un booleano normal.

Cómo se nota. Un create() con is_company: True y sin NIF crea una persona, sin error. En la sonda, sin NIF, 19 da True y 20 da False; con NIF, True en las dos. Un write() posterior sí lo deja en True, hasta que Odoo lo recalcule porque cambie el NIF o la entidad comercial. A un conector de marketplace que crea clientes empresa sin NIF le salen personas, y si después busca el contacto por is_company para reutilizarlo, no lo encuentra y crea otro en cada pedido. Nos pasó al portar uno de los nuestros.

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

Arreglo. Pasa el NIF cuando lo tengas, no busques contactos por is_company y, si lo escribes, comprueba antes si el campo es calculado: Partner._fields['is_company'].compute.

Fuente. 20.0: res_partner.py:371 (el campo), res_partner.py:942-945 (has_vat) y res_partner.py:950-951 (de qué depende). 19.0: res_partner.py:277 (booleano normal).

4. El tipo de cambio de hoy se aplica mañana

Qué pasa. res.currency._get_rates busca el último tipo con fecha anterior o igual a la de la operación en 19 ('<=') y estrictamente anterior en 20 ('<'). En 20, un tipo con fecha de hoy no cuenta para las operaciones de hoy.

Cómo se nota. En la sonda, la libra tiene 1,10 ayer y 1,20 hoy; convertir 100 dólares a libras con fecha de hoy da 120 en 19 y 110 en 20. Sin error. Un proceso que carga el tipo del día por la mañana y factura ese mismo día contabiliza con el de ayer, y una prueba que crea un tipo con fecha de hoy y espera verlo aplicado falla sin que nada haya cambiado en tu 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

Arreglo. En el código no hay ninguna opción para volver al comportamiento de 19. En las pruebas, crea el tipo con fecha de ayer. Si tu integración necesita el tipo exacto del día, aplícalo de forma explícita en vez de pasar por _convert, o guárdalo con la fecha que le corresponde con la regla de 20.

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

5. b64decode(registro.campo) devuelve basura

Qué pasa. En 19, leer un campo Binary devolvía el contenido en base64. En 20 devuelve un BinaryValue, que expone los bytes en crudo con el protocolo de buffer de Python. base64.b64decode() acepta cualquier buffer: lee esos bytes crudos como si fueran base64 y, sin validate=True, tira lo que no es del alfabeto.

Cómo se nota. En la sonda, con un campo Binary normal: con un texto de 12 bytes, 20 devuelve 9 bytes distintos y ningún error; con un PNG, binascii.Error: Incorrect padding. En 19 los dos salen idénticos al original. Según el contenido, una imagen o un PDF que mandas a otro sistema llega corrupto o la llamada revienta.

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)

Fuente. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) y fields_binary.py:128-129 (lo que devuelve el campo al leerlo).

6. read() de un Binary devuelve un diccionario

Qué pasa. En 20, read() de un campo Binary devuelve {'content': <base64>, 'size': N}, más filename si lo tiene. En 19 devolvía el base64 directamente.

Cómo se nota. En la sonda, read() devuelve bytes en 19 y un diccionario con content y size en 20. Si ese valor acaba en un str() o en una plantilla de texto que va a otro sistema, se envía el texto del diccionario y en Odoo no falla 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

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

7. Campos renombrados que se leen con getattr

Qué pasa. En 20, stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id y mrp.bom.line.product_uom_id pasan a llamarse uom_id. Usar el nombre viejo de forma directa da error (en un create(), ValueError: Invalid field). El problema es el código pensado para varias versiones que lo lee con un valor por defecto: en 20, getattr(bom, 'product_uom_id', False) devuelve siempre el valor por defecto.

Cómo se nota. Cantidades en la unidad equivocada: un kit en docenas, o una línea en kilos contra un componente en gramos. Nos pasó en un conector de marketplace al desglosar kits: en 20 caía siempre a la unidad del producto.

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

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

8. Un módulo sin permisos instala sin avisar

Qué pasa. Odoo 20 sustituye ir.model.access e ir.rule por un modelo nuevo, ir.access, con su fichero security/ir.access.csv. El guion de Odoo que convierte los permisos (odoo/upgrade_code/19.4-00-ir-access.py) quita el CSV viejo del manifiesto y, para añadir el nuevo, busca una línea que empiece por 'data': [. Si en tu manifiesto 'data' está a media línea, escribe ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually y sigue con lo demás.

Cómo se nota. El módulo instala sin ningún permiso. En 19, un modelo sin permisos daba un aviso al instalar («The models [...] have no access rules»); en 20 ese aviso ya no existe. Las pruebas, que corren como superusuario, pasan. El usuario normal recibe «No group currently allows this operation». Nos pasó con módulos ya portados: lo cazó una prueba con un usuario sin privilegios, y desde entonces nuestra cadena de porte se para si un ir.access.csv no se carga.

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

Arreglo. Añade 'security/ir.access.csv' a data a mano y escribe una prueba que cree y lea un registro de cada modelo con un usuario normal (new_test_user y with_user). Es la única prueba que lo ve.

Fuente. 20.0: 19.4-00-ir-access.py:415-434 (dónde busca 'data' y el ERROR) y ir_access.py:342 (el mensaje al usuario). 19.0: loading.py:250-257 (el aviso que en 20 ya no está).

9. Sobrescribir un método que Odoo 20 ya no llama

Qué pasa. Python no avisa si sobrescribes un método que la clase base ya no tiene: tu método existe, pero nadie lo llama. En 20, el framework de pagos sustituye payment.provider._get_compatible_providers por _find_available_providers, con otra firma (lo que va después de amount pasa a ser solo por nombre).

Cómo se nota. Si sobrescribías _get_compatible_providers para ocultar un proveedor de pago por país o por importe, en 20 el proveedor vuelve a salir al pagar. En la sonda, el modelo de 19 tiene _get_compatible_providers y no _find_available_providers; el de 20, al revés.

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

Fuente. 19.0: payment_provider.py:555, llamado desde portal.py:113. 20.0: payment_provider.py:671.

10. Las migraciones copiadas a 20.0.x se repiten

Qué pasa. No es nuevo de 20, pero sale justo al portar. Odoo ejecuta una carpeta migrations/<versión completa> si la versión instalada es menor que la de la carpeta y la nueva es igual o mayor. Si al portar copias migrations/19.0.1.3.0 como migrations/20.0.1.3.0, al subir a 20 una base con el módulo en 19.0.1.3.1, ese guion vuelve a correr sobre datos que ya transformó. Las carpetas sin serie (migrations/1.3.0) comparan solo la parte del módulo y no se repiten.

Cómo se nota. El -u termina bien y los datos quedan transformados dos veces: horas desplazadas otra vez, líneas copiadas dos veces.

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

Arreglo. Carpetas sin serie, guiones idempotentes (que miren el estado antes de tocar nada) y una prueba que ejecute la migración dos veces.

Fuente. 20.0: migration.py:197-210 (líneas 205 a 208 para las carpetas sin serie, 210 para las de versión completa).

Y uno que ya no es silencioso: datas en los adjuntos

En septiembre, ir.attachment.create({'datas': ...}) creaba en 20 un adjunto vacío sin ningún error (en un conector de firma, el PDF firmado se habría guardado con cero bytes). Desde el commit c06a072 del 31 de octubre («[FIX] base: remove fallback for datas») revienta con AssertionError: _check_contents should handle that (ir_attachment.py:502). El arreglo es el mismo y vale en 17, 18, 19 y 20: 'raw' con los bytes sin codificar. La rama 20.0 sigue recibiendo cambios cada semana, así que lo que probaste en septiembre conviene volver a probarlo con el código de hoy.

Cómo cazarlos antes que tus usuarios

  1. Pasa por tu código los grep de cada apartado y revisa a mano lo que salga.
  2. Ejecuta las pruebas también con un usuario sin privilegios, no solo como superusuario.
  3. Renderiza los informes y las páginas del portal y compara el texto con el de 19.
  4. Crea datos de prueba con fecha de hoy, no solo de ayer: tipos de cambio, pagos, plazos.
  5. Sube una base de 19 a 20 con datos y ejecuta la migración dos veces.

Preguntas frecuentes

¿Los guiones de migración de Odoo (upgrade_code) arreglan estos cambios?

No. Ninguno de los guiones de odoo/upgrade_code de la rama 20.0 resuelve estos diez: el de permisos (19.4-00-ir-access) es justo el que puede dejar un módulo sin ellos, y el de OWL 3 cambia t-esc por t-out solo en los ficheros de /static/.

¿Por qué las pruebas no los ven?

Porque corren como superusuario, con datos que no pasan por esos caminos y sin mirar lo que se genera. Un informe con un campo en blanco no lanza nada y 'paid' sigue siendo un valor válido. Hacen falta pruebas que miren el resultado: el texto del informe, el importe convertido, lo que ve un usuario normal.

¿Se puede dejar el código listo en 19 antes de portar?

En varios casos, sí: t-out en lugar de t-esc y 'raw' en los adjuntos ya funcionan en 17, 18 y 19, y leer los campos renombrados a través de _fields deja el mismo código listo para 20.

Enlaces útiles dentro de FlexigoTech

Odoo 20: qué cambia para tus conectoresLo que desaparece y no instalaMigrar a Odoo 20: checklistPara empresas con integraciones activasNuestros conectores ya en Odoo 20La lista, con enlace a cada ficha

Conectores para Odoo 17, 18, 19 y 20

FlexigoTech hace conectores y módulos para Odoo 17, 18, 19 y 20, y los prueba así en cada versión antes de publicarlos.

Ver el catálogo