Come personalizzare viste backend e frontend con moduli custom, ereditarietà XML e XPath robusti, senza compromettere la manutenibilità e la compatibilità con le versioni successive.

Ogni sviluppatore Odoo, prima o poi, si trova davanti alla stessa scena: una richiesta di modifica a una vista esistente, una deadline stretta e la tentazione di aprire direttamente il file XML nativo per sistemare la cosa in cinque minuti. È una scorciatoia che si paga cara al prossimo odoo-bin -u all.
Questo articolo descrive il meccanismo corretto, ovvero la view inheritance via XPath, con la profondità che merita: non solo il "come", ma anche le distinzioni che la documentazione informale spesso omette, i limiti reali dell'approccio e i pattern che in Unitiva usiamo tutti i giorni.
Per chi si approccia al tema per la prima volta, un'analogia aiuta a fissare il concetto prima di scendere nel codice.
Immagina che ogni vista standard di Odoo sia un libro stampato e distribuito dalla casa madre. Modificare direttamente il file XML nativo equivale a scrivere a penna sulle pagine di quel libro: alla prossima release, il libro viene sostituito da una copia aggiornata e immacolata. Tutto cio' che hai scritto scompare.
Creare un modulo custom con una view inherited è invece come incollare un Post-it su quella pagina. Ma il Post-it ben scritto non dice "vai a pagina 12, riga 4": dice "trova l'elemento chiamato phone e inserisci questo subito dopo". Il libro originale rimane intatto, e l'istruzione continua a funzionare anche se nella nuova edizione quell'elemento si è spostato di pagina.
È qui che la metafora rivela il punto centrale dell'intero articolo: la solidità della personalizzazione dipende da come scrivi l'istruzione sul Post-it. Un'istruzione legata al contenuto ("l'elemento chiamato X") sopravvive agli aggiornamenti; un'istruzione legata alla posizione ("pagina 12, riga 4") si rompe alla prima ristampa.
Il primo chiarimento necessario riguarda la sintassi. Esistono due pattern distinti, e confonderli è un errore comune, anche se sotto il cofano sono lo stesso meccanismo: entrambi creano un record ir.ui.view. Il template è semplicemente una scorciatoia che genera un ir.ui.view con type="qweb".
<odoo>
<template id="homepage_custom" inherit_id="website.homepage" priority="15">
<xpath expr="//div[@id='wrap']" position="inside">
<section class="oo-custom-banner">
<h2>Contenuto personalizzato</h2>
</section>
</xpath>
</template>
</odoo>
<odoo>
<record id="view_partner_form_custom" model="ir.ui.view">
<field name="name">res.partner.form.custom</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='phone']" position="after">
<field name="x_custom_field"/>
</xpath>
</field>
</record>
</odoo>
La distinzione pratica resta utile: il template è il modo idiomatico di estendere viste QWeb (website, portal, report), mentre il record model="ir.ui.view" è quello che userai per form, list, kanban e search del backend. Usare la sintassi sbagliata nel contesto sbagliato porta spesso a viste che semplicemente non si applicano come ti aspetti, e il debug diventa frustrante.
XPath è il linguaggio con cui dici a Odoo dove posizionare il bisturi. La qualità dell'espressione determina la robustezza della modifica nel tempo.
Quest'ultima, move, è proprio lo strumento da usare quando una nuova versione di Odoo ha spostato un elemento e tu hai bisogno di riportarlo dove serve alla tua personalizzazione, invece di duplicarlo.
Il punto critico che la documentazione informale non dice: la fragilità di un XPath è proporzionale alla sua specificità. Un'espressione come questa si rompe non appena Odoo riorganizza la struttura della vista, anche senza modificare il campo target:
expr="//div[@class='o_form_view']/div[2]/div[1]/field[@name='partner_id']"
La versione robusta:
expr="//field[@name='partner_id']"
Oppure, se il contesto è ambiguo:
expr="//field[@name='partner_id'][ancestor::group[@string='Informazioni generali']]"
Un’avvertenza specifica sull’aggancio per classe CSS: ‘@class=‘o_form_view’’ fa match solo sulla stringa esatta di classi, quindi si rompe non appena Odoo aggiunge o riordina una classe. Odoo mette a disposizione la funzione ‘hasclass()’ proprio per questo, e arriva persino a loggare un warning quando rileva un uso fragile di ‘@class’:
expr="//div[hasclass('o_form_view')]"
Privilegia sempre l’aggancio a identificatori semantici (‘name’, ‘id’, ‘string’) rispetto a posizioni strutturali. La struttura di una vista può cambiare tra minor release; gli attributi semantici sono molto più stabili.
Odoo non "riapplica" le viste ereditate dopo ogni aggiornamento. L'arch finale viene combinato a partire dalla vista base più tutte le viste figlie. Si parte dalla vista base (arch originale), si raccolgono tutte le viste con inherit_id che puntano ad essa, ordinate per priority, e si applica ogni XPath in sequenza sull'albero risultante.
La conseguenza pratica è duplice.
Prima: se due moduli custom modificano lo stesso elemento, l'ordine di applicazione dipende da priority (default 16; valori più bassi vengono applicati prima). Quando costruisci sopra una vista già modificata da un modulo terzo, devi ragionare esplicitamente sulla sequenza.
Seconda, e qui va corretto un mito diffuso: un XPath che non trova il suo target non fallisce silenziosamente. Odoo solleva un errore esplicito ("Element ... cannot be located in parent view") e questo controllo gira tramite un constraint sull'arch al momento della scrittura della vista, cioè durante l'installazione o l'aggiornamento del modulo. In pratica: un XPath rotto fa abortire l'odoo-bin -u.
Questo non è un difetto, è la tua rete di sicurezza. Significa che un aggiornamento che ha invalidato un tuo aggancio si fa notare subito, rumorosamente, nel posto giusto: in fase di upgrade. Ed è esattamente il motivo per cui non si aggiorna mai direttamente in produzione: l'upgrade va sempre provato prima su staging o in CI, dove l'errore emerge senza fare danni.
Se l'XPath rotto blocca l'upgrade, qual è allora il problema davvero insidioso? È il caso opposto, e più subdolo: l'XPath che continua a trovare un target, ma non più quello giusto.
Odoo, tra una versione e l'altra, raramente elimina del tutto un elemento. Molto più spesso lo sposta, lo racchiude in un nuovo container, o ne cambia il contesto. Se il tuo aggancio era ancorato alla posizione strutturale, puo' succedere che continui a risolversi, magari su un nodo simile in un altro punto della vista, e la tua personalizzazione finisce nel posto sbagliato. Nessun errore, nessun crash all'upgrade: la vista si carica, ma il campo è finito nella scheda sbagliata o nel gruppo sbagliato.
Questo è il fallimento che non vedi in CI, perché tecnicamente "funziona". È il motivo per cui, dopo ogni aggiornamento di versione, il workflow che adottiamo non si ferma al "l'upgrade è passato": include una verifica che le viste critiche siano non solo applicate, ma applicate dove ci aspettiamo. È un controllo che, su progetti in produzione, non si puo' rimandare.
Una view inherited completa non è solo XPath. Tre attributi che è necessario padroneggiare:
Controlla l'ordine di applicazione nella catena. Default 16. Se stai costruendo sopra una view di un modulo terzo che ha già modificato la vista base, devi ragionare esplicitamente sulla sequenza.
Limita la visibilità di una porzione di vista a gruppi specifici. Attenzione a un dettaglio che blocca chi sbaglia: su una vista ereditata non si usa il campo groups_id sul record (Odoo lo rifiuta con un errore esplicito). Il controllo per gruppo si fa con l'attributo groups sul nodo all'interno dell'arch:
<xpath expr="//field[@name='phone']" position="after"> <field name="x_custom_field" groups="base.group_system"/> </xpath>
Il groups_id sul record resta valido solo per le viste primarie, non per quelle ereditate.
Può essere impostato a False per disabilitare una view inherited senza rimuoverla. Utile durante lo sviluppo e per funzionalità che si attivano condizionalmente tramite configurazione.
<record id="view_partner_form_custom" model="ir.ui.view">
<field name="name">res.partner.form.custom</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority">20</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='phone']" position="after">
<field name="x_custom_field" groups="base.group_system"/>
</xpath>
</field>
</record>
Una view inherited non vive da sola. Il modulo che la contiene richiede due file obbligatori.
{
'name': 'Custom Partner Form',
'version': '19.0.1.0.0',
'depends': ['base', 'contacts'],
'data': ['views/res_partner_views.xml'],
'installable': True,
'auto_install': False,
}
La chiave depends non è solo dichiarativa: determina l'ordine di caricamento dei moduli. Se la tua view eredita da una vista definita in contacts, quel modulo deve essere in depends, altrimenti l'inherit_id potrebbe non essere ancora registrato quando il tuo modulo viene processato, con errori difficili da diagnosticare.
Puo' essere vuoto per moduli che contengono solo viste XML, ma deve esistere affinchè Python riconosca la cartella come package.
C'è un limite architetturale che è onesto nominare. Le view inherited gestiscono la struttura della vista. Non gestiscono:
La view inheritance è lo strumento giusto per modificare il layout e la struttura di una schermata esistente. Per tutto il resto, il modulo custom deve fare un passo in più, e capire quando quel passo è necessario è la differenza tra una customizzazione solida e una che si rompe al primo caso edge.
Ogni giorno in Unitiva mettiamo le mani su queste strutture: su upgrade che non devono interrompere la produzione, su personalizzazioni che devono coesistere con moduli di terze parti, su XPath che devono reggere al prossimo aggiornamento. La view inheritance non è un dettaglio: è la fondazione su cui si costruisce qualsiasi customizzazione mantenibile.
Prenota una call con il team di sviluppo Unitiva: analizziamo insieme la tua situazione e ti diciamo cosa ha senso fare, senza giri di parole.