Vai al contenuto principale
Odoo 19 · Installazione · Sviluppo di moduli

Un modulo che compila in Odoo 19 ma non si installa

Compilare non è installare, e installare su un database pulito non è aggiornare un database che aveva già la versione precedente. Sono tre prove diverse e quasi tutti fanno solo la prima.

Se sei arrivato fin qui è perché la build ti ha restituito qualcosa come ValueError: Invalid field 'category_id' in 'res.groups' e in locale andava tutto bene. Non è il tuo ambiente. Il linter, il import e i test unitari non eseguono la parte di Odoo che fallisce: il registro che si costruisce durante l'installazione.

Il tema di «cosa cambia in Odoo 19» è già stato battuto a fondo. Qui si parla del gradino successivo: il modulo passa il linter, si importa senza errori, eppure il -i fallisce. O peggio, si installa e si comporta male in silenzio.

Cosa controlla la tua CI e cosa no

python -m compileall controlla la sintassi di Python. xmllint --noout controlla che l'XML sia ben formato. Una suite di test «puri», senza Odoo avviato, controlla la tua logica. Nessuno dei tre apre il registro di Odoo, ed è lì che vive il guasto.

Installare è un'altra cosa: Odoo costruisce il registro e ogni <record> finisce in un create() o in un write() dell'ORM. E write() convalida i nomi dei campi uno per uno: in odoo/orm/models.py della 19.0, un KeyError su self._fields[field_name] risulta come ValueError: Invalid field 'category_id' in 'res.groups'. Non è un errore di sintassi; è un errore di esecuzione dell'XML.

E la trappola maggiore, quella che trasforma un gate in teatro: un -i su un modulo già installato non fa nulla. In odoo/modules/loading.py della 19.0, update_operation vale 'install' solo se lo stato è to install; se è installed, vale None. E con None, quando nella riga degli ordini c'è un -i o un -u, non vengono caricati i dati né si entra nel blocco dei test. Build verde, zero test. Prima di fidarti di un gate, guarda quanti test dice di aver eseguito.

res.groups.privilege non è un campo rinominato: è un modello nuovo

Le guide alla migrazione riportano una tabella dei rinominati e con quella risolvi metà dei casi. category_id non c'è, perché Odoo 19 non l'ha rinominato: l'ha tolto da res.groups. Un cerca-e-sostituisci non ha nulla a cui puntare.

In 17 e 18, res.groups.category_id punta a ir.module.category. In 19 c'è un nuovo modello intermedio, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, quattordici righe): il gruppo porta privilege_id ed è il privilegio a portare category_id. Già che c'è, la 19 aggiunge UNIQUE (privilege_id, name): due gruppi chiamati «Manager» sotto lo stesso privilegio non si installano.

La correzione non consiste nel cambiare un nome: è creare un record di un modello che nella tua versione di origine non esisteva. Ecco come resta nei nostri moduli, con il commento che abbiamo lasciato nel file perché non succeda di nuovo:

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

Intorno ci sono anche rinominati veri, e quelli li risolve una sostituzione: res.groups.users diventa user_ids, res.users.groups_id diventa group_ids, e lo stesso in ir.actions.act_window e ir.actions.server. Ciò che non è cambiato sono le tabelle: res_groups_users_rel(gid, uid) e res_groups_implied_rel(gid, hid) sono identiche in 17, 18 e 19. L'SQL di una migrazione sopravvive a tutte e tre le serie; il codice ORM che lo circonda, no.

base.group_system con noupdate: si installa bene e i permessi restano com'erano

L'idioma abituale per far ereditare il tuo gruppo all'amministratore è un <record> su un xmlid altrui:

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

Questo si applica in un'installazione nuova. In un aggiornamento, Odoo lo ignora in silenzio. La regola è in _load_records (odoo/orm/models.py) e sta in una riga: if not (update and d_noupdate): to_update.append(data). Ciò che decide non è il tuo file, ma la colonna noupdate della riga che esiste già in ir_model_data per quell'xmlid. Non darlo per scontato in nessun senso: chiedilo al tuo database.

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

Sulla nostra produzione di Odoo.sh, verificato il 3 settembre 2026, entrambe le righe tornano con noupdate = t. Con quel valore, l'XML qui sopra non viene mai riapplicato in un upgrade.

Perché è importante: se in più metti groups="mi_modulo.group_manager" su un campo di credenziali, in un database già installato quel gruppo resta senza alcun membro e nessuno (nemmeno l'amministratore) può leggere o scrivere quel campo. Il codice interno con sudo() continua a funzionare, quindi nessun test lo vede: i test girano su un'installazione pulita, dove il <record> è stato applicato. Ci è successo esattamente così.

La ricetta: lasciare l'XML, che copre le installazioni nuove, e aggiungere una migrazione migrations/<serie>.<version>/post-migrate.py che lo scriva in SQL idempotente su res_groups_implied_rel e res_groups_users_rel: implied_ids propaga solo agli utenti tramite l'ORM, quindi vanno inseriti anche loro. E un test che fallisca se il gruppo resta vuoto. La chiusura transitiva è all_implied_ids in 19 e trans_implied_ids in 17 e 18.

ir.cron senza numbercall: il cron gira una volta e si spegne da solo

Questo non si nota fino alla settimana successiva. In Odoo 17, odoo/addons/base/models/ir_cron.py dichiara numbercall = fields.Integer(..., default=1, ...). Un <record model="ir.cron"> che non lo specifichi eredita quell'1. Al termine della prima esecuzione, call_count_left dà 0 e il UPDATE che chiude l'esecuzione scrive active = job['active'] and bool(call_count_left): falso. Se restassero dubbi, la query che seleziona i job filtra AND numbercall != 0.

Risultato: il cron viene eseguito una volta, si disattiva da solo e non lascia traccia. Né eccezioni, né righe di log, né nulla nell'interfaccia. Sembra esattamente «il cron non parte». La correzione è <field name="numbercall">-1</field>, negativo per «senza limite».

Ed ecco la spiacevole simmetria: Odoo 18 ha eliminato il campo e la 19 non l'ha mai avuto. Mettere numbercall in 19 impedisce l'installazione (Invalid field 'numbercall' in 'ir.cron'); non metterlo in 17 rompe il funzionamento senza dire nulla. Lo stesso file fallisce in due direzioni opposte. Con doall, lo stesso.

Questo errore lo commettiamo anche noi. Controllando il nostro catalogo abbiamo trovato quattordici record ir.cron in quattro connettori portati a 17 senza un solo numbercall; estendendo la ricerca al ramo 17.0 del deployment che gira davvero, 130 record in quindici moduli, nessuno con il campo. Contata per quello che è: l'abbiamo trovato perché siamo andati a cercarlo con un grep, non perché sia saltato fuori da solo.

L'inventario, e in quale versione morde ciascuna trappola

Questo è ciò che abbiamo raccolto installando davvero su Odoo.sh nelle tre serie, con il nostro catalogo. Nessuna di queste trappole la prende un compilatore né un linter. Le righe segnate «silenzioso» sono peggiori: il modulo si installa e la build esce verde.

Cosa scriviCosa succedeDove morde
category_id in res.groupsInvalid field 'category_id' in 'res.groups'Non si installa · 19
users o groups_idRinominati in user_ids e group_idsNon si installa · 19
numbercall o doall in ir.cronInvalid field 'numbercall' in 'ir.cron'Non si installa · 18 e 19
manca numbercall in ir.cronSi installa; il cron gira una volta e si spegneSilenzioso · solo 17
_sql_constraints in un modello della 19Si installa; un warning nel log e il vincolo non viene creatoSilenzioso · 19
models.Constraint in 18 o 17module 'odoo.models' has no attribute 'Constraint'Non si installa · 18 e 17
<record id="base.group_system">Si installa; in un upgrade non viene riapplicatoSilenzioso · tutte e tre
<tree> in un'ereditarietàL'xpath non corrisponde e la vista esplodeNon si installa · 18 e 19
Campo citato in un modificatore e non renderizzatomust be present in view but is missingNon si installa · solo 17

Guarda l'ultima riga: quel controllo lo fa solo la 17. Il messaggio resta nel codice di 18 e 19, ma lì copre solo nomi e azioni, non un campo citato in un modificatore. Se sviluppi su 19 e porti all'indietro, la vista che da te si installa perfettamente si rompe in 17. Le trappole non vanno tutte nella stessa direzione.

Come si individua davvero: installare due volte, su due database diversi

Prova A: database pulito, -i. Prende tutto ciò che impedisce l'installazione: campi che non esistono più, xpath che non corrispondono, viste non valide, vincoli che il database rifiuta. Non prende nulla di ciò che accade solo all'aggiornamento: qui update è falso, i noupdate vengono ignorati e gli script di migrations/ non vengono nemmeno eseguiti, perché migrate_module (odoo/modules/migration.py) esce dalla porta se il modulo non è in to upgrade.

Prova B: database con la versione precedente, -u. Prende ciò che compare solo all'aggiornamento: record che non vengono riapplicati, colonne nuove non popolate, migrazioni che falliscono a metà, dati vecchi che non combaciano più. Qui sì, i tuoi pre-migrate e post-migrate girano, ed è l'unico modo per sapere se funzionano.

Nessuna sostituisce l'altra, e se pubblichi per 17, 18 e 19 sono sei installazioni, non due. Un dettaglio pratico: prima del -i, fai il reset con una vera disinstallazione tramite ORM. Cambiare lo stato a mano con psql lascia il grafo incoerente e il loader salta il modulo in silenzio: ancora una build verde che non ha provato nulla.

La lista di controllo prima di pubblicare

  • grep di category_id in qualsiasi <record model="res.groups">. Se ne esce qualcuno, bisogna creare un res.groups.privilege.
  • grep di groups_id e di users nell'XML: in 19 sono group_ids e user_ids.
  • grep di numbercall e doall: eliminato in 18 e 19, obbligatorio a -1 in 17.
  • grep di _sql_constraints in 19: si ferma a un warning nel log e il vincolo non viene creato. E di models.Constraint in 18 e 17: quel nome lì non esiste e il modulo non viene nemmeno importato.
  • grep di <tree in 18/19 e di <list in 17, senza dimenticare il view_mode delle azioni.
  • Per ogni <record id="base....">: c'è una migrazione per l'upgrade e un test che fallisce se il gruppo resta vuoto?
  • Installare su database pulito. Contare i test eseguiti. Se dice zero, il gate è vuoto.
  • Installare su un database con la versione precedente. Contare di nuovo i test.
  • Ripetere in ogni serie che hai intenzione di pubblicare.

I primi cinque punti sono uno script statico che gira in un secondo; la build vera richiede minuti. Fai prima quello economico, ma non lasciare che sostituisca quello costoso: il grep trova ciò che sai già cercare, e la build trova ciò che non sai.

Se arrivi da una migrazione, il livello precedente è negli errori comuni nella migrazione a Odoo 19.

La regola di fondo, l'unica da portarsi via: compilare, fare il parsing e avere test verdi non è la stessa cosa che installare. I due moduli che ci sono costati di più quest'anno avevano tutto verde in locale e non si installavano.

Ingegneria per partner di OdooProdotto proprio che si installa in 17, 18 e 19, con un gate reale in ogni serie.Migrazione a Odoo 19Cosa rivedere, in quale ordine e cosa rende più cara una migrazione.Errori di migrazione a Odoo 19Il livello precedente: ciò che si rompe prima di arrivare alla build.Cosa porta Odoo 19I cambiamenti funzionali, per decidere se vale la pena passare.

Domande frequenti

Perché i miei test passano se il modulo non si installa?

Perché una suite di Python puro non apre il registro di Odoo: convalida la tua logica, non i tuoi file di dati. E un -i su un modulo già installato non elabora nulla: il blocco dei test non viene nemmeno eseguito e la build esce verde con zero test.

Devo creare un res.groups.privilege anche in 17 e 18?

No: quel modello non esiste prima della 19. In 17 e 18 la categoria sta nel gruppo stesso, con category_id. È uno dei motivi per cui l'XML di sicurezza non può essere lo stesso file nelle tre serie.

Posso lasciare numbercall impostato perché valga in tutte e tre le versioni?

No. In 18 e 19 il campo non esiste e la sua presenza impedisce di installare il modulo. E toglierlo in 17 fa sì che il cron si spenga da solo dopo la prima esecuzione. Bisogna generare il file per ogni serie.

Se installo su un database pulito e va bene, non basta?

No, perché in un'installazione pulita gli script di migrations/ non arrivano a essere eseguiti e i record segnati noupdate vengono applicati. Proprio i due comportamenti che falliscono all'aggiornamento restano fuori da quella prova.

Il tuo modulo non si installa e non sai perché?

Guardiamo il tuo XML di sicurezza, i tuoi cron, le tue viste ereditate e i tuoi script di migrazione, e ti diciamo cosa si rompe in ogni serie prima che lo scopra un cliente. Prenota una chiamata su Calendly o chiamaci al +34 616 809 504.

Parla con un ingegnere