De 17 a 19 no es un salto, son dos
He llevado módulos custom de Odoo 17 a 19, y lo primero que hay que entender es que no existe un camino directo. El motor de migración de MigrateFelix trabaja con pares de versiones adyacentes — 16→17, 17→18, 18→19 y las combinaciones que se componen a partir de ahí — porque cada versión introduce sus propios cambios y aplicarlos todos de golpe hace imposible saber cuál rompió qué.
Eso cambia cómo hay que planificar el trabajo. No estás resolviendo "el problema de 17 a 19". Estás resolviendo el de 17 a 18 y después, sobre un módulo que ya funciona en 18, el de 18 a 19. Si intentas hacerlo de una pasada y algo falla, no tienes forma de saber en qué versión se rompió, y acabas depurando dos migraciones a la vez.
La trampa: casi toda la documentación resuelve el salto anterior
Si buscas "migrar módulos Odoo" vas a encontrar, sobre todo, material sobre el salto a 17. Y ese material es bueno — solo que si tu módulo ya corre en 17, describe trabajo que alguien ya hizo.
Los dos cambios que más aparecen en esas guías son precisamente los que ya no te afectan:
El atributo attrs, que agrupaba condiciones dinámicas de visibilidad y obligatoriedad en un diccionario, desapareció a favor de atributos directos sobre el campo. Si tu módulo instala en 17, esto ya está hecho:
<field name="partner_id"
invisible="state == 'draft'"
required="state == 'confirmed'"
readonly="state in ('done', 'cancel')"/>
Y name_get(), que devolvía una lista de tuplas (id, nombre), dio paso a display_name como campo calculado:
from odoo import api, fields, models
class ServiceRequest(models.Model):
_name = "x_service.request"
_description = "Solicitud de servicio"
_order = "scheduled_date desc, id desc"
name = fields.Char(required=True)
partner_id = fields.Many2one("res.partner", required=True, ondelete="restrict")
scheduled_date = fields.Datetime()
@api.depends("name", "partner_id.name")
def _compute_display_name(self):
for record in self:
record.display_name = f"[{record.partner_id.name}] {record.name}"
Fíjate en el @api.depends. Es lo que hace que el nombre se recalcule cuando cambia el partner, y es la parte que más se olvida al portar un name_get antiguo: sin las dependencias declaradas el campo se calcula una vez y se queda obsoleto.
La conclusión práctica es incómoda pero útil: para un salto 17→19, gran parte de lo que vas a leer por ahí es ruido. Lo que te rompe es lo que cambió después de 17.
Las cuatro categorías que rompen, en orden de dolor
Independientemente de la versión concreta, los fallos de migración de módulos custom en Odoo caen casi siempre en las mismas cuatro familias. Reconocer la familia es más útil que memorizar una lista de renombrados, porque la lista cambia cada año y las familias no.
1. Vistas y herencia de vistas
Es la categoría que más rompe y la que peor avisa. Un xpath que localiza un nodo por su posición o por una clase CSS del tema depende de que la vista base no cambie — y las vistas base cambian en cada versión. El xpath no falla al parsear: falla al aplicar, cuando el nodo que buscaba ya no está donde estaba.
La herencia frágil se ve así:
<xpath expr="//notebook/page[3]/group/field[@name='note']" position="after">
Y la que sobrevive, así:
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_priority_level"/>
</xpath>
Anclar en un nombre de campo es anclar en algo que el módulo base tiene un motivo funcional para conservar. Anclar en "la tercera página del notebook" es anclar en una decisión de maquetación que nadie prometió mantener.
El otro cambio de esta familia es el nombre de la etiqueta de las vistas de lista, que pasó de <tree> a <list>. [CONFIRMAR: en qué versión exacta se elimina definitivamente el alias <tree> — verificar contra las notas de versión de la release destino antes de publicar]. La forma correcta en el destino es la misma en cualquier caso:
<record id="view_x_service_request_list" model="ir.ui.view">
<field name="name">x.service.request.list</field>
<field name="model">x_service.request</field>
<field name="arch" type="xml">
<list string="Solicitudes">
<field name="name"/>
<field name="partner_id"/>
<field name="scheduled_date"/>
</list>
</field>
</record>
2. Métodos del ORM que se renombran o desaparecen
Esta familia es más fácil de detectar y más cara de arreglar. Fácil porque revienta con un AttributeError claro; cara porque el método viejo y el nuevo rara vez tienen la misma firma, así que no es un buscar-y-reemplazar sino una reescritura de la lógica que lo llamaba.
El patrón de trabajo que uso: no busques el método por su nombre, busca por qué lo llamabas. Casi siempre hay una forma de expresar la misma intención con la API estable, y esa versión sobrevive a la siguiente migración también.
[CONFIRMAR: lista concreta de métodos del ORM retirados en 18 y en 19 — extraer de las notas de versión oficiales de cada release, no de memoria]
3. Campos y atributos de campo obsoletos
Los atributos de fields.* se van deprecando de forma silenciosa: durante una versión emiten un warning en el log y siguen funcionando, y a la siguiente dejan de existir. El resultado práctico es que un módulo puede haber estado avisando durante todo un ciclo sin que nadie leyera el log, y romper en la migración siguiente sin previo aviso aparente.
Antes de migrar, instala el módulo en la versión de origen y lee los warnings. Es diez minutos de trabajo y te da la lista exacta de lo que va a romper en el siguiente salto, específica de tu código en lugar de genérica.
[CONFIRMAR: atributos de campo concretos retirados entre 17 y 19 — mismo criterio, notas de versión oficiales]
4. Assets y componentes de frontend
Si tu módulo trae JavaScript propio, esta es la parte que más tiempo consume y la que menos se puede automatizar. Los bundles de assets y el framework de componentes del backend evolucionan de forma más agresiva que el ORM, y no hay una equivalencia mecánica entre versiones.
Mi recomendación, aprendida a base de sufrirlo: separa el módulo. La lógica de negocio en Python y las vistas declarativas se portan razonablemente bien; el JavaScript custom no. Si van en el mismo módulo, el JavaScript retrasa todo lo demás. En módulos separados, entregas la parte funcional y trabajas la interfaz aparte.
Lo que sobrevive sin tocarlo
Vale la pena decirlo porque cambia la estimación: la mayor parte de un módulo bien escrito no se toca.
- Los campos calculados con
@api.dependsdeclarado. _inheritsobre modelos, que es mucho más estable que la herencia de vistas.- Las reglas de acceso en
ir.model.access.csv, siempre que las referencias a grupos estén cualificadas con su módulo (base.group_user, nogroup_user). - Las restricciones SQL declaradas en
_sql_constraints. - Los métodos con
@api.modely@api.constrains.
Dicho de otra forma: cuanto más declarativo y menos acoplado a la maquetación del núcleo esté tu módulo, más barata es cada migración. Eso no es un consejo de migración, es un consejo de diseño que se cobra años después.
El método: instalar en destino después de cada salto
Todo lo anterior son heurísticas. La única forma de saber que una migración funcionó es instalar el módulo en la versión de destino y ver si arranca, y hacerlo después de cada salto, no solo al final.
Cuando falla, bisecta: divide los registros de datos, reinstala, y repite hasta aislar el que rompe. El traceback de Odoo suele apuntar al cargador de datos y no al registro culpable, así que la bisección convierte "el módulo no instala" en "esta línea no instala", que es la única versión del problema sobre la que se puede trabajar. Es la misma técnica que usa el validador de MigrateFelix, y la usa porque es lo que funciona a mano.
Lo que me llevo
Migrar entre versiones de Odoo no es traducir código: es descubrir de qué dependías sin saberlo. El módulo que se porta en una tarde y el que se atasca dos semanas suelen tener el mismo tamaño; la diferencia está en cuánto se apoyaban en detalles internos del núcleo que nadie prometió mantener.
La versión útil de esa lección es preventiva. Cada xpath posicional que escribes hoy es una factura con fecha de vencimiento en la próxima release.