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 escreve | O que acontece | Onde morde |
|---|---|---|
category_id em res.groups | Invalid field 'category_id' in 'res.groups' | Não instala · 19 |
users ou groups_id | Renomeados para user_ids e group_ids | Não instala · 19 |
numbercall ou doall em ir.cron | Invalid field 'numbercall' in 'ir.cron' | Não instala · 18 e 19 |
falta numbercall em ir.cron | Instala; o cron corre uma vez e desliga-se | Silencioso · só 17 |
_sql_constraints num modelo do 19 | Instala; um warning no log e a restrição não é criada | Silencioso · 19 |
models.Constraint no 18 ou no 17 | module 'odoo.models' has no attribute 'Constraint' | Não instala · 18 e 17 |
<record id="base.group_system"> | Instala; num upgrade não é reaplicado | Silencioso · as três |
<tree> numa herança | O xpath não encaixa e a vista rebenta | Não instala · 18 e 19 |
| Campo citado num modificador e não renderizado | must be present in view but is missing | Nã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
grepdecategory_idem qualquer<record model="res.groups">. Se aparecer algum, é preciso criar umres.groups.privilege.grepdegroups_ide deusersno XML: no 19 sãogroup_idseuser_ids.grepdenumbercalledoall: fora no 18 e no 19, obrigatório a-1no 17.grepde_sql_constraintsno 19: fica num warning do log e a restrição não é criada. E demodels.Constraintno 18 e no 17: esse nome não existe aí e o módulo nem sequer é importado.grepde<treeno 18/19 e de<listno 17, sem esquecer oview_modedas 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.
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.
