Saltar para o conteúdo principal
Odoo 19 · Instalação · Desenvolvimento de módulos

Um módulo que compila no Odoo 19 e mesmo assim não instala

Compilar não é instalar, e instalar numa base limpa não é atualizar uma base que já tinha a versão anterior. São três testes distintos e quase toda a gente faz apenas o primeiro.

Se chegou até aqui, é porque o build lhe devolveu algo como ValueError: Invalid field 'category_id' in 'res.groups' e, em local, não acontecia nada. Não é o seu ambiente. O linter, o import e os testes unitários não executam a parte do Odoo que falha: o registo que se constrói ao instalar.

O terreno de «o que muda no Odoo 19» já está muito batido. Isto trata do degrau seguinte: o módulo passa o linter, importa sem erro e, ainda assim, o -i falha. Ou pior, instala e comporta-se mal em silêncio.

O que o seu CI verifica e o que não verifica

python -m compileall verifica a sintaxe de Python. xmllint --noout verifica se o XML está bem formado. Uma suite de testes «puros», sem o Odoo em execução, verifica a sua lógica. Nenhuma das três abre o registo do Odoo, e é aí que vive a falha.

Instalar é outra coisa: o Odoo constrói o registo e cada <record> acaba num create() ou num write() do ORM. E write() valida os nomes de campo um a um: em odoo/orm/models.py da 19.0, um KeyError sobre self._fields[field_name] sai como ValueError: Invalid field 'category_id' in 'res.groups'. Não é um erro de sintaxe; é um erro de execução do XML.

E a maior armadilha, a que transforma um gate em teatro: um -i sobre um módulo já instalado não faz nada. Em odoo/modules/loading.py da 19.0, update_operation vale 'install' apenas se o estado for to install; se for installed, vale None. E com None, quando na linha de ordens há um -i ou um -u, nem os dados são carregados nem se entra no bloco de testes. Build verde, zero testes. Antes de acreditar num gate, veja quantos testes diz ter executado.

res.groups.privilege não é um campo renomeado: é um modelo novo

Os guias de migração trazem uma tabela de renomeações e com isso resolve metade dos casos. category_id não consta dela, porque o Odoo 19 não o renomeou: retirou-o de res.groups. Um procurar-e-substituir não tem onde incidir.

No 17 e no 18, res.groups.category_id aponta para ir.module.category. No 19 há um modelo intermédio novo, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, catorze linhas): o grupo leva privilege_id e é o privilégio que leva category_id. Já agora, o 19 acrescenta UNIQUE (privilege_id, name): dois grupos chamados «Manager» sob o mesmo privilégio não instalam.

A correção não é mudar um nome: é criar um registo de um modelo que na sua versão de origem não existia. Assim fica nos nossos módulos, com o comentário que deixámos no ficheiro para que não volte a acontecer:

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

À volta há, de facto, renomeações verdadeiras, e essas resolve-as uma substituição: res.groups.users passa a user_ids, res.users.groups_id a group_ids, e o mesmo em ir.actions.act_window e ir.actions.server. O que não mudou foram as tabelas: res_groups_users_rel(gid, uid) e res_groups_implied_rel(gid, hid) são idênticas no 17, no 18 e no 19. O SQL de uma migração sobrevive às três séries; o código ORM que o rodeia, não.

base.group_system com noupdate: instala bem e as permissões ficam como estavam

O idioma habitual para que o administrador herde o seu grupo é um <record> sobre um xmlid alheio:

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

Isso aplica-se numa instalação nova. Numa atualização, o Odoo ignora-o em silêncio. A regra está em _load_records (odoo/orm/models.py) e cabe numa linha: if not (update and d_noupdate): to_update.append(data). O que decide não é o seu ficheiro, mas a coluna noupdate da linha que já existe em ir_model_data para esse xmlid. Não o dê por garantido em nenhum sentido: pergunte-o à sua base de dados.

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

Na nossa produção do Odoo.sh, verificado a 3 de setembro de 2026, as duas linhas voltam com noupdate = t. Com esse valor, o XML acima nunca é reaplicado num upgrade.

Porque importa: se além disso colocar groups="mi_modulo.group_manager" num campo de credenciais, numa base já instalada esse grupo fica sem nenhum membro e ninguém (nem o administrador) consegue ler nem escrever esse campo. O código interno com sudo() continua a funcionar, pelo que nenhum teste o vê: os testes correm sobre uma instalação limpa, onde o <record> foi de facto aplicado. Aconteceu-nos exatamente assim.

A receita: deixar o XML, que cobre instalações novas, e acrescentar uma migração migrations/<serie>.<version>/post-migrate.py que o escreva em SQL idempotente sobre res_groups_implied_rel e res_groups_users_rel: implied_ids só propaga aos utilizadores através do ORM, pelo que é preciso inseri-los também. E um teste que falhe se o grupo ficar vazio. O fecho transitivo é all_implied_ids no 19 e trans_implied_ids no 17 e no 18.

ir.cron sem numbercall: o cron corre uma vez e desliga-se sozinho

Este só se nota na semana seguinte. No Odoo 17, odoo/addons/base/models/ir_cron.py declara numbercall = fields.Integer(..., default=1, ...). Um <record model="ir.cron"> que não o indique herda esse 1. Ao terminar a primeira execução, call_count_left dá 0 e o UPDATE que fecha a execução escreve active = job['active'] and bool(call_count_left): falso. Para não restar dúvidas, a consulta que seleciona trabalhos filtra AND numbercall != 0.

Resultado: o cron executa-se uma vez, desativa-se a si próprio e não deixa rasto. Nem exceção, nem linha de log, nem nada na interface. Parece exatamente «o cron não dispara». A correção é <field name="numbercall">-1</field>, negativo por «sem limite».

E aqui a simetria desagradável: o Odoo 18 eliminou o campo e o 19 nunca o teve. Pôr numbercall no 19 impede a instalação (Invalid field 'numbercall' in 'ir.cron'); não o pôr no 17 estraga o funcionamento sem avisar. O mesmo ficheiro falha em duas direções opostas. Com doall, o mesmo.

Esta falha também a cometemos cá em casa. Ao auditar o nosso catálogo encontrámos catorze registos ir.cron em quatro conectores portados para o 17 sem um único numbercall; ao alargar a pesquisa ao ramo 17.0 do deployment que realmente se executa, 130 registos em quinze módulos, nenhum com o campo. Contado como o que é: encontrámo-lo porque o fomos procurar com um grep, não porque tenha disparado.

O inventário, e em que versão morde cada armadilha

Isto é o que recolhemos ao instalar a sério no Odoo.sh nas três séries, com o nosso próprio catálogo. Nenhuma destas armadilhas é apanhada por um compilador nem por um linter. As linhas marcadas «silencioso» são piores: o módulo instala e o build sai verde.

O que escreveO que aconteceOnde morde
category_id em res.groupsInvalid field 'category_id' in 'res.groups'Não instala · 19
users ou groups_idRenomeados para user_ids e group_idsNão instala · 19
numbercall ou doall em ir.cronInvalid field 'numbercall' in 'ir.cron'Não instala · 18 e 19
falta numbercall em ir.cronInstala; o cron corre uma vez e desliga-seSilencioso · só 17
_sql_constraints num modelo do 19Instala; um warning no log e a restrição não é criadaSilencioso · 19
models.Constraint no 18 ou no 17module 'odoo.models' has no attribute 'Constraint'Não instala · 18 e 17
<record id="base.group_system">Instala; num upgrade não é reaplicadoSilencioso · as três
<tree> numa herançaO xpath não encaixa e a vista rebentaNão instala · 18 e 19
Campo citado num modificador e não renderizadomust be present in view but is missingNão instala · só 17

Repare na última linha: essa verificação só a faz o 17. A mensagem continua no código do 18 e do 19, mas aí cobre apenas nomes e ações, não um campo citado num modificador. Se desenvolve contra o 19 e porta para trás, a vista que a si instala na perfeição rebenta no 17. As armadilhas não vão todas na mesma direção.

Como se deteta isto de verdade: instalar duas vezes, em duas bases distintas

Teste A: base limpa, -i. Apanha tudo o que impede a instalação: campos que já não existem, xpath que não encaixa, vistas inválidas, constraints que a base rejeita. Não apanha nada do que só acontece ao atualizar: aqui update é falso, os noupdate são ignorados e os scripts de migrations/ nem sequer são executados, porque migrate_module (odoo/modules/migration.py) sai pela porta se o módulo não estiver em to upgrade.

Teste B: base com a versão anterior, -u. Apanha o que só aparece ao atualizar: registos que não são reaplicados, colunas novas por preencher, migrações que falham a meio, dados antigos que já não encaixam. Aqui sim, correm os seus pre-migrate e post-migrate, e é a única forma de saber se funcionam.

Nenhum substitui o outro e, se publica para 17, 18 e 19, são seis instalações, não duas. Um detalhe prático: antes do -i, reponha o estado com uma desinstalação real através do ORM. Mudar o estado à mão com psql deixa o grafo inconsistente e o carregador salta o módulo em silêncio: mais uma vez, um build verde que não testou nada.

A lista de verificação antes de submeter

  • grep de category_id em qualquer <record model="res.groups">. Se aparecer algum, é preciso criar um res.groups.privilege.
  • grep de groups_id e de users no XML: no 19 são group_ids e user_ids.
  • grep de numbercall e doall: fora no 18 e no 19, obrigatório a -1 no 17.
  • grep de _sql_constraints no 19: fica num warning do log e a restrição não é criada. E de models.Constraint no 18 e no 17: esse nome não existe aí e o módulo nem sequer é importado.
  • grep de <tree no 18/19 e de <list no 17, sem esquecer o view_mode das ações.
  • Por cada <record id="base....">: existe migração para o upgrade e um teste que falhe se o grupo ficar vazio?
  • Instalar numa base limpa. Contar os testes que foram executados. Se disser zero, o gate está vazio.
  • Instalar sobre uma base com a versão anterior. Contar os testes outra vez.
  • Repetir em cada série que vá publicar.

Os primeiros cinco pontos são um script estático que corre num segundo; o build real demora minutos. Faça primeiro o barato, mas não deixe que substitua o caro: o grep encontra o que já sabe procurar, e o build encontra o que não sabe.

Se vem de uma migração, o nível anterior está nos erros comuns ao migrar para o Odoo 19.

A regra de fundo, a única que vale a pena levar: compilar, fazer parsing e ter testes verdes não é o mesmo que instalar. Os dois módulos que nos saíram mais caros este ano tinham tudo verde em local e não instalavam.

Engenharia para partners de OdooProduto próprio que instala no 17, 18 e 19, com gate real em cada série.Migração para o Odoo 19O que rever, por que ordem e o que encarece uma migração.Erros de migração para o Odoo 19O nível anterior: o que se parte antes de chegar ao build.O que traz o Odoo 19As alterações funcionais, para decidir se compensa mudar.

Perguntas frequentes

Porque é que os meus testes passam se o módulo não instala?

Porque uma suite de Python puro não abre o registo do Odoo: valida a sua lógica, não os seus ficheiros de dados. E um -i sobre um módulo já instalado não processa nada: o bloco de testes nem sequer é executado e o build sai verde com zero testes.

Tenho de criar um res.groups.privilege também no 17 e no 18?

Não: esse modelo não existe antes do 19. No 17 e no 18, a categoria vai no próprio grupo, com category_id. É uma das razões pelas quais o XML de segurança não pode ser o mesmo ficheiro nas três séries.

Posso deixar o numbercall definido para que valha nas três versões?

Não. No 18 e no 19 o campo não existe e a sua presença impede a instalação do módulo. E retirá-lo no 17 faz com que o cron se desligue sozinho depois da primeira execução. É preciso gerar o ficheiro por série.

Se instalar numa base limpa e correr bem, não basta?

Não, porque numa instalação limpa os scripts de migrations/ não chegam a ser executados e os registos marcados noupdate são aplicados. Precisamente os dois comportamentos que falham ao atualizar ficam fora desse teste.

O seu módulo não instala e não sabe porquê?

Analisamos o seu XML de segurança, os seus crons, as suas vistas herdadas e os seus scripts de migração, e dizemos-lhe o que parte em cada série antes que o descubra um cliente. Reserve uma chamada no Calendly ou ligue-nos para +34 616 809 504.

Falar com um engenheiro