Saltar al contenido principal
Odoo 19 · Instalación · Desarrollo de módulos

Un módulo que compila en Odoo 19 y aun así no se instala

Compilar no es instalar, e instalar en una base limpia no es actualizar una base que ya tenía la versión anterior. Son tres pruebas distintas y casi todo el mundo hace solo la primera.

Si has llegado hasta aquí es porque el build te ha escupido algo como ValueError: Invalid field 'category_id' in 'res.groups' y en local no pasaba nada. No es tu entorno. El linter, el import y los tests unitarios no ejecutan la parte de Odoo que falla: el registro que se construye al instalar.

El terreno de «qué cambia en Odoo 19» ya está trillado. Esto va del escalón siguiente: el módulo pasa el linter, importa sin error, y aun así el -i falla. O peor, instala y se comporta mal en silencio.

Qué comprueba tu CI y qué no

python -m compileall comprueba sintaxis de Python. xmllint --noout comprueba que el XML está bien formado. Una suite de tests «puros», sin Odoo levantado, comprueba tu lógica. Ninguna de las tres abre el registro de Odoo, y es ahí donde vive el fallo.

Instalar es otra cosa: Odoo construye el registro y cada <record> acaba en un create() o un write() del ORM. Y write() valida los nombres de campo uno a uno: en odoo/orm/models.py de la 19.0, un KeyError sobre self._fields[field_name] sale como ValueError: Invalid field 'category_id' in 'res.groups'. No es un error de sintaxis; es un error de ejecución del XML.

Y la trampa mayor, la que convierte un gate en teatro: un -i sobre un módulo ya instalado no hace nada. En odoo/modules/loading.py de la 19.0, update_operation vale 'install' solo si el estado es to install; si es installed, vale None. Y con None, cuando en la línea de órdenes hay un -i o un -u, ni se cargan los datos ni se entra en el bloque de tests. Build verde, cero tests. Antes de creerte un gate, mira cuántos tests dice que ha corrido.

res.groups.privilege no es un campo renombrado: es un modelo nuevo

Las guías de migración traen una tabla de renombrados y con eso arreglas la mitad de los casos. category_id no está en ella, porque Odoo 19 no lo renombró: lo sacó de res.groups. Un buscar-y-reemplazar no tiene a qué apuntar.

En 17 y 18, res.groups.category_id apunta a ir.module.category. En 19 hay un modelo intermedio nuevo, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, catorce líneas): el grupo lleva privilege_id y es el privilegio el que lleva category_id. De paso, 19 añade UNIQUE (privilege_id, name): dos grupos llamados «Manager» bajo el mismo privilegio no instalan.

El arreglo no es cambiar un nombre: es crear un registro de un modelo que en tu versión de origen no existía. Así queda en nuestros módulos, con el comentario que dejamos en el fichero para que no vuelva a pasar:

<record id="module_category_mi_modulo" model="ir.module.category">
  <field name="name">Mi modulo</field>
</record>

<!-- Odoo 19 moved the grouping of access groups out of res.groups: a group
     now points at a res.groups.privilege, and the privilege is what carries
     the module category. Setting category_id on res.groups raises
     "Invalid field 'category_id' in 'res.groups'". -->
<record id="privilege_mi_modulo" model="res.groups.privilege">
  <field name="name">Mi modulo</field>
  <field name="category_id" ref="module_category_mi_modulo"/>
</record>

<record id="group_mi_modulo_user" model="res.groups">
  <field name="name">User</field>
  <field name="privilege_id" ref="privilege_mi_modulo"/>
</record>

Alrededor sí hay renombrados de verdad, y esos sí los arregla un reemplazo: res.groups.users pasa a user_ids, res.users.groups_id a group_ids, e igual en ir.actions.act_window y ir.actions.server. Lo que no cambió son las tablas: res_groups_users_rel(gid, uid) y res_groups_implied_rel(gid, hid) son idénticas en 17, 18 y 19. El SQL de una migración sobrevive a las tres series; el código ORM que lo rodea, no.

base.group_system con noupdate: instala bien y los permisos se quedan como estaban

El idioma habitual para que el administrador herede tu grupo es un <record> sobre un xmlid ajeno:

<record id="base.group_system" model="res.groups">
  <field name="implied_ids" eval="[(4, ref('group_mi_manager'))]"/>
</record>

Eso se aplica en una instalación nueva. En una actualización, Odoo lo ignora en silencio. La regla está en _load_records (odoo/orm/models.py) y cabe en una línea: if not (update and d_noupdate): to_update.append(data). Lo que decide no es tu fichero, sino la columna noupdate de la fila que ya existe en ir_model_data para ese xmlid. No lo des por supuesto en ningún sentido: pregúntaselo a tu base de datos.

SELECT module, name, noupdate FROM ir_model_data
 WHERE module = 'base' AND name IN ('group_system', 'group_user');

En nuestra producción de Odoo.sh, comprobado el 3 de septiembre de 2026, las dos filas vuelven con noupdate = t. Con ese valor, el XML de arriba no se reaplica nunca en un upgrade.

Por qué importa: si además pones groups="mi_modulo.group_manager" en un campo de credenciales, en una base ya instalada ese grupo se queda sin ningún miembro y nadie —tampoco el administrador— puede leer ni escribir ese campo. El código interno con sudo() sigue funcionando, así que ningún test lo ve: los tests corren sobre una instalación limpia, donde el <record> sí se aplicó. Nos pasó exactamente así.

La receta: dejar el XML, que cubre instalaciones nuevas, y añadir una migración migrations/<serie>.<version>/post-migrate.py que lo escriba en SQL idempotente sobre res_groups_implied_rel y res_groups_users_relimplied_ids solo propaga a los usuarios por el ORM, así que hay que insertarlos también. Y un test que falle si el grupo se queda vacío. La clausura transitiva es all_implied_ids en 19 y trans_implied_ids en 17 y 18.

ir.cron sin numbercall: el cron corre una vez y se apaga solo

Este no se ve hasta la semana siguiente. En Odoo 17, odoo/addons/base/models/ir_cron.py declara numbercall = fields.Integer(..., default=1, ...). Un <record model="ir.cron"> que no lo ponga hereda ese 1. Al acabar la primera ejecución, call_count_left da 0 y el UPDATE que cierra la ejecución escribe active = job['active'] and bool(call_count_left): falso. Por si quedaba duda, la consulta que selecciona trabajos filtra AND numbercall != 0.

Resultado: el cron se ejecuta una vez, se desactiva a sí mismo y no deja rastro. Ni excepción, ni línea de log, ni nada en la interfaz. Parece exactamente «el cron no salta». El arreglo es <field name="numbercall">-1</field>, negativo por «sin límite».

Y aquí la simetría desagradable: Odoo 18 eliminó el campo y 19 nunca lo tuvo. Poner numbercall en 19 impide instalar (Invalid field 'numbercall' in 'ir.cron'); no ponerlo en 17 rompe el funcionamiento sin decir nada. El mismo fichero falla en dos direcciones opuestas. Con doall, igual.

Este fallo se comete también en casa. Auditando nuestro catálogo encontramos catorce registros ir.cron en cuatro conectores portados a 17 sin un solo numbercall; ampliando el barrido a la rama 17.0 del despliegue que realmente se ejecuta, 130 registros en quince módulos, ninguno con el campo. Contado como lo que es: lo encontramos porque fuimos a buscarlo con un grep, no porque saltara.

El inventario, y en qué versión muerde cada trampa

Esto es lo que hemos recogido instalando de verdad sobre Odoo.sh en las tres series, con nuestro propio catálogo. Ninguna de estas trampas la caza un compilador ni un linter. Las filas marcadas «silencioso» son peores: el módulo instala y el build sale verde.

Qué escribesQué pasaDónde muerde
category_id en res.groupsInvalid field 'category_id' in 'res.groups'No instala · 19
users o groups_idRenombrados a user_ids y group_idsNo instala · 19
numbercall o doall en ir.cronInvalid field 'numbercall' in 'ir.cron'No instala · 18 y 19
falta numbercall en ir.cronInstala; el cron corre una vez y se apagaSilencioso · solo 17
_sql_constraints en un modelo de 19Instala; un warning en el log y la restricción no se creaSilencioso · 19
models.Constraint en 18 o 17module 'odoo.models' has no attribute 'Constraint'No instala · 18 y 17
<record id="base.group_system">Instala; en un upgrade no se reaplicaSilencioso · las tres
<tree> en una herenciaEl xpath no casa y la vista revientaNo instala · 18 y 19
Campo citado en un modificador y no renderizadomust be present in view but is missingNo instala · solo 17

Fíjate en la última fila: esa comprobación solo la hace 17. El mensaje sigue en el código de 18 y 19, pero allí solo cubre nombres y acciones, no un campo citado en un modificador. Si desarrollas contra 19 y portas hacia atrás, la vista que a ti te instala perfectamente rompe en 17. Las trampas no van todas en la misma dirección.

Cómo se detecta esto de verdad: instalar dos veces, en dos bases distintas

Prueba A — base limpia, -i. Coge todo lo que impide instalar: campos que ya no existen, xpath que no casa, vistas inválidas, constraints que la base rechaza. No coge nada de lo que solo pasa al actualizar: aquí update es falso, los noupdate se ignoran y los scripts de migrations/ ni se ejecutan, porque migrate_module (odoo/modules/migration.py) sale por la puerta si el módulo no está en to upgrade.

Prueba B — base con la versión anterior, -u. Coge lo que solo aparece al actualizar: registros que no se reaplican, columnas nuevas sin rellenar, migraciones que fallan a mitad, datos viejos que ya no encajan. Aquí sí corren tus pre-migrate y post-migrate, y es la única forma de saber si funcionan.

Ninguna sustituye a la otra, y si publicas para 17, 18 y 19 son seis instalaciones, no dos. Un detalle práctico: antes del -i, resetea con una desinstalación real por ORM. Cambiar el estado a mano con psql deja el grafo inconsistente y el cargador se salta el módulo en silencio: otra vez un build verde que no ha probado nada.

La lista de comprobación antes de subir

  • grep de category_id en cualquier <record model="res.groups">. Si sale alguno, hay que crear un res.groups.privilege.
  • grep de groups_id y de users en el XML: en 19 son group_ids y user_ids.
  • grep de numbercall y doall: fuera en 18 y 19, obligatorio a -1 en 17.
  • grep de _sql_constraints en 19: se queda en un warning del log y la restricción no se crea. Y de models.Constraint en 18 y 17: ese nombre no existe allí y el módulo ni se importa.
  • grep de <tree en 18/19 y de <list en 17, sin olvidar el view_mode de las acciones.
  • Por cada <record id="base....">: ¿hay migración para el upgrade y un test que falle si el grupo se queda vacío?
  • Instalar en base limpia. Contar los tests que se han ejecutado. Si dice cero, el gate está vacío.
  • Instalar sobre una base con la versión anterior. Contar los tests otra vez.
  • Repetir en cada serie que vayas a publicar.

Los cinco primeros puntos son un script estático que corre en un segundo; el build real tarda minutos. Haz primero el barato, pero no dejes que sustituya al caro: el grep encuentra lo que ya sabes buscar, y el build encuentra lo que no.

Si vienes de una migración, el nivel anterior está en los errores comunes al migrar a Odoo 19.

La regla de fondo, la única que hay que llevarse: compilar, parsear y tener tests verdes no es lo mismo que instalar. Los dos módulos que más caros nos han salido este año tenían todo verde en local y no se instalaban.

Ingeniería para partners de OdooProducto propio que instala en 17, 18 y 19, con gate real en cada serie.Migración a Odoo 19Qué revisar, en qué orden y qué encarece una migración.Errores de migración a Odoo 19El nivel anterior: lo que se rompe antes de llegar al build.Qué trae Odoo 19Los cambios funcionales, para decidir si merece la pena moverse.

Preguntas frecuentes

¿Por qué pasan mis tests si el módulo no se instala?

Porque una suite de Python puro no abre el registro de Odoo: valida tu lógica, no tus ficheros de datos. Y un -i sobre un módulo ya instalado no procesa nada: el bloque de tests ni se ejecuta y el build sale verde con cero tests.

¿Tengo que crear un res.groups.privilege también en 17 y 18?

No: ese modelo no existe antes de 19. En 17 y 18 la categoría va en el propio grupo, con category_id. Es una de las razones por las que el XML de seguridad no puede ser el mismo fichero en las tres series.

¿Puedo dejar numbercall puesto para que valga en las tres versiones?

No. En 18 y 19 el campo no existe y su presencia impide instalar el módulo. Y quitarlo en 17 hace que el cron se apague solo después de la primera ejecución. Hay que generar el fichero por serie.

Si instalo en una base limpia y va bien, ¿no basta?

No, porque en una instalación limpia los scripts de migrations/ no llegan a ejecutarse y los registros marcados noupdate sí se aplican. Justo los dos comportamientos que fallan al actualizar quedan fuera de esa prueba.

¿Tu módulo no instala y no sabes por qué?

Miramos tu XML de seguridad, tus crons, tus vistas heredadas y tus scripts de migración, y te decimos qué rompe en cada serie antes de que lo descubra un cliente. Reserva una llamada en Calendly o llámanos al +34 616 809 504.

Hablar con un ingeniero