Se rendre au contenu

Wizards et assistants en Odoo 19 : TransientModel, target='new' et binding_model_id

Bloc 4 · Interface utilisateur — Article 4/4 · Fin du Bloc 4 Wizards et assistants en Odoo 19 Des formulaires modaux qui guident l'utilisateur et orchestrent…
26 avril 2026 par
Wizards et assistants en Odoo 19 : TransientModel, target='new' et binding_model_id
B.Mustapha

Bloc 4 · Interface utilisateur — Article 4/4 · Fin du Bloc 4

Wizards et assistants en Odoo 19

Des formulaires modaux qui guident l'utilisateur et orchestrent plusieurs écritures métier — avec TransientModel, target='new', default_get(active_id) et binding_model_id pour les actions contextuelles.

~15 minutes de lecture

Wizard de clôture de ticket en modal avec motif, note et notification client
Objectif de l'article — ce wizard modal : motif de clôture en radio, champ "ticket doublon" conditionnel, heures passées, notification automatique au client. Le tout avec ~60 lignes de Python.

Ce que tu vas apprendre

TransientModel

La classe qui stocke temporairement la saisie du wizard.

Ouvrir en modal

target='new' dans l'action = dialog bloquant.

Pré-remplissage

default_get(active_id) et les default_* du contexte.

Actions contextuelles

binding_model_id = une entrée dans le menu Actions de la liste.

Prérequis

  • Module odooskills_helpdesk v19.0.1.10.0 (T18).
  • Maîtrise des vues form (T16) et de l'héritage de vues (T18).
  • Compréhension des actions ir.actions.act_window.

1. TransientModel — un modèle qui s'autodétruit

Un wizard = un formulaire éphémère. L'utilisateur saisit, confirme, le code serveur agit sur les vrais modèles, puis la donnée du wizard est jetée.

Côté ORM, deux classes à distinguer :

models.Modelmodels.TransientModel
Persistance Records conservés indéfiniment. Records purgés par un cron (~1h par défaut).
Usage Données métier. Formulaires d'assistant, saisies éphémères.
Table SQL Créée avec contraintes complètes. Créée, mais ON DELETE CASCADE partout.
Sécurité ACL / record rules obligatoires. ACL simples, chaque user voit uniquement ses records.

Le squelette d'un wizard :

from odoo import api, models, fields


class HelpdeskTicketCloseWizard(models.TransientModel):
    _name = 'helpdesk.ticket.close.wizard'
    _description = 'Clôture de ticket helpdesk (wizard)'

    ticket_id = fields.Many2one('helpdesk.ticket', required=True)
    resolution_reason = fields.Selection([
        ('resolved', 'Résolu'),
        ('not_a_bug', 'Pas un bug / comportement attendu'),
        ('duplicate', "Duplicata d'un autre ticket"),
        ('wont_fix', 'Ne sera pas corrigé'),
    ], required=True, default='resolved')
    duplicate_ticket_id = fields.Many2one('helpdesk.ticket')
    resolution_note = fields.Text()
    hours_spent = fields.Float(digits=(6, 2))
    notify_partner = fields.Boolean(default=True)

    def action_close(self):
        # ... on y revient plus bas
        pass

Une table helpdesk_ticket_close_wizard est créée en base — mais tu n'as jamais à la manipuler : Odoo nettoie tout seul.

2. Ouvrir le wizard en modal depuis un bouton

Le flux complet en une image :

Bouton Form name=action_open_close type="object" Méthode Python return {'type': 'ir.actions.act_window'} Web Client ouvre un Dialog target="new" Wizard default_get() → action_close() Le flux d'ouverture d'un wizard en Odoo 19 Le contexte (active_id, default_ticket_id…) est transmis d'étape en étape.

Deux fichiers à toucher : la méthode qui renvoie l'action, et le bouton qui appelle la méthode.

# models/helpdesk_ticket.py — méthode retournant l'action

def action_open_close_wizard(self):
    """Retourne une action qui ouvre le wizard de clôture."""
    self.ensure_one()
    return {
        'name': 'Clôturer le ticket',
        'type': 'ir.actions.act_window',
        'res_model': 'helpdesk.ticket.close.wizard',
        'view_mode': 'form',
        'target': 'new',   # <-- la clé : modal dialog
        'context': {
            'default_ticket_id': self.id,
            'default_hours_spent': self.hours_spent,
        },
    }

Côté vue form du ticket, on ajoute le bouton dans le <header> :

<header>
    <button name="action_resolve" type="object" string="Résoudre"
            invisible="state != 'in_progress'"/>
    <button name="action_open_close_wizard" type="object"
            string="Clôturer (avec motif)" class="oe_highlight"
            invisible="state == 'done'"/>
    <field name="state" widget="statusbar"/>
</header>
target='new' vs target='current'new ouvre en dialog modal par-dessus la vue actuelle, l'utilisateur reste dans son contexte ; current remplace la vue. Pour un wizard d'assistant, c'est toujours new.

3. Pré-remplissage : default_* et default_get()

Deux mécanismes complémentaires pour alimenter le wizard à l'ouverture :

3.1 Les default_* dans le contexte

Tu as vu 'default_ticket_id': self.id ci-dessus. Odoo lit toutes les clés default_<nom_champ> et les applique automatiquement. Zéro code supplémentaire côté wizard. Simple, mais limité aux valeurs calculables à l'ouverture.

3.2 default_get — la méthode universelle

Quand la logique de pré-remplissage est plus riche (lire active_ids, calculer une valeur, lire un autre modèle), on override default_get() :

@api.model
def default_get(self, fields_list):
    """Pré-remplit le wizard à partir du contexte."""
    vals = super().default_get(fields_list)
    ticket_id = self.env.context.get('active_id')
    if ticket_id and 'ticket_id' in fields_list and not vals.get('ticket_id'):
        vals['ticket_id'] = ticket_id
    return vals

Les trois règles à retenir :

  1. Toujours appeler super() d'abord — sinon les default_* du contexte sont ignorés.
  2. Respecter fields_list — Odoo ne demande pas tous les champs à chaque fois (tabs pliés, etc.), alimenter ce qui n'est pas demandé gaspille.
  3. Lire active_id / active_ids quand le wizard est ouvert depuis une liste ou une fiche — c'est Odoo qui les injecte automatiquement.

4. La vue du wizard — <footer> et special="cancel"

Une vue form classique, avec deux spécificités : les boutons vont dans un <footer> (pas dans <header>), et le bouton Annuler utilise l'attribut magique special="cancel".

<record id="view_helpdesk_ticket_close_wizard_form" model="ir.ui.view">
    <field name="name">helpdesk.ticket.close.wizard.form</field>
    <field name="model">helpdesk.ticket.close.wizard</field>
    <field name="arch" type="xml">
        <form string="Clôture du ticket">
            <sheet>
                <group>
                    <field name="ticket_id" readonly="1"/>
                    <field name="partner_id"/>
                    <field name="resolution_reason" widget="radio"/>
                    <field name="duplicate_ticket_id"
                           invisible="resolution_reason != 'duplicate'"
                           required="resolution_reason == 'duplicate'"/>
                    <field name="hours_spent"/>
                    <field name="notify_partner"/>
                </group>
                <group string="Note de résolution">
                    <field name="resolution_note" nolabel="1"/>
                </group>
            </sheet>
            <footer>
                <button name="action_close" type="object"
                        string="Clôturer le ticket" class="btn-primary"
                        data-hotkey="q"/>
                <button string="Annuler" class="btn-secondary"
                        special="cancel" data-hotkey="x"/>
            </footer>
        </form>
    </field>
</record>

Deux détails importants :

  • special="cancel" — ferme le dialog sans valider. Pas besoin d'une méthode Python. Odoo gère nativement.
  • invisible sur duplicate_ticket_id — le champ apparaît dynamiquement quand le motif devient "Duplicata". Expression v19 directe, plus d'attrs.
Radio Duplicata sélectionné — le champ Ticket doublon devient visible et requis

5. La méthode d'action et le retour

Quand l'utilisateur clique "Clôturer", le code serveur :

  1. Valide la saisie (raise ValidationError si incohérent).
  2. Écrit sur le vrai modèle.
  3. Poste éventuellement un message au client.
  4. Retourne une action : fermer le dialog, ouvrir une autre vue, afficher une notif…
from odoo.exceptions import ValidationError

def action_close(self):
    self.ensure_one()
    if self.resolution_reason == 'duplicate' and not self.duplicate_ticket_id:
        raise ValidationError(
            "Merci d'indiquer le ticket maître quand le motif est 'Duplicata'."
        )

    note_lines = [
        f"Motif : {dict(self._fields['resolution_reason'].selection).get(self.resolution_reason)}",
    ]
    if self.duplicate_ticket_id:
        note_lines.append(f"Doublon de : {self.duplicate_ticket_id.reference}")
    if self.resolution_note:
        note_lines.append(self.resolution_note)

    self.ticket_id.write({
        'state': 'done',
        'resolution_note': '\n'.join(note_lines),
        'hours_spent': self.hours_spent or self.ticket_id.hours_spent,
    })

    if self.notify_partner and self.ticket_id.partner_id:
        self.ticket_id.message_post(
            body=f"<p>Ticket clôturé — <strong>{self.resolution_reason}</strong></p>",
            partner_ids=self.ticket_id.partner_id.ids,
            subtype_xmlid='mail.mt_comment',
        )

    return {'type': 'ir.actions.act_window_close'}

Les trois retours d'action les plus utiles :

RetourEffet
{'type': 'ir.actions.act_window_close'} Ferme le dialog, reste sur la vue parente, recharge si besoin.
{'type': 'ir.actions.act_window', 'res_model': ..., 'res_id': ..., 'view_mode': 'form'} Enchaîne sur une autre vue (utile pour chaîner deux wizards).
{'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {...}} Affiche un toast ("3 tickets réassignés"), optionnellement avec une next action.
ensure_one() au début de la méthode — un wizard doit toujours opérer sur un seul record de wizard. Sans ensure_one(), un appel API sur un recordset de wizards passerait silencieusement, avec des résultats imprévisibles.

6. Action contextuelle de liste avec binding_model_id

Deuxième cas classique : déclencher un wizard qui opère sur une multi-sélection depuis une vue liste. Exemple : réassigner 10 tickets à un autre agent en un clic.

Menu Actions d'une liste de tickets, avec l'entrée Réassigner les tickets sélectionnés
Coche 3 tickets → le menu Actions expose "Réassigner les tickets sélectionnés". Aucune ligne de JavaScript : tout est en XML.

La recette : binding_model_id sur l'action. Odoo crée automatiquement une entrée dans le menu Actions des vues liste/kanban du modèle ciblé.

<record id="action_helpdesk_ticket_bulk_assign" model="ir.actions.act_window">
    <field name="name">Réassigner les tickets sélectionnés</field>
    <field name="res_model">helpdesk.ticket.bulk.assign.wizard</field>
    <field name="view_mode">form</field>
    <field name="target">new</field>
    <field name="binding_model_id" ref="model_helpdesk_ticket"/>
    <field name="binding_view_types">list</field>
</record>

Côté wizard, on lit active_ids dans default_get :

class HelpdeskTicketBulkAssignWizard(models.TransientModel):
    _name = 'helpdesk.ticket.bulk.assign.wizard'
    _description = 'Assignation groupée de tickets (wizard)'

    user_id = fields.Many2one('res.users', required=True,
                              domain="[('share', '=', False)]")
    ticket_ids = fields.Many2many('helpdesk.ticket', required=True)
    ticket_count = fields.Integer(compute='_compute_ticket_count')
    notify_assignee = fields.Boolean(default=True)

    @api.depends('ticket_ids')
    def _compute_ticket_count(self):
        for w in self:
            w.ticket_count = len(w.ticket_ids)

    @api.model
    def default_get(self, fields_list):
        vals = super().default_get(fields_list)
        if 'ticket_ids' in fields_list:
            active_ids = self.env.context.get('active_ids', [])
            vals['ticket_ids'] = [(6, 0, active_ids)]
        return vals

    def action_assign(self):
        self.ensure_one()
        self.ticket_ids.write({'user_id': self.user_id.id})
        if self.notify_assignee:
            for ticket in self.ticket_ids:
                ticket.message_post(
                    body=f"Réassigné à <strong>{self.user_id.name}</strong>",
                    partner_ids=self.user_id.partner_id.ids,
                    subtype_xmlid='mail.mt_comment',
                )
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'Réassignation effectuée',
                'message': f"{len(self.ticket_ids)} ticket(s) assigné(s).",
                'type': 'success',
                'next': {'type': 'ir.actions.act_window_close'},
            },
        }
Wizard d'assignation groupée avec la liste des 3 tickets sélectionnés visibles
Le wizard reçoit les 3 tickets cochés via active_ids, les affiche en readonly, demande un nouvel assigné, applique en masse et renvoie une notification.

La commande [(6, 0, active_ids)] est un Command triplet : 6 = "remplace tout par cette liste d'IDs". Équivalent moderne : [Command.set(active_ids)] en important from odoo.fields import Command.

7. Sécurité — un ACL par wizard, toujours

Même éphémère, un wizard a une table SQL. Sans ACL, personne ne peut l'ouvrir et Odoo lève une AccessError. Ajoute dans security/ir.model.access.csv :

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_helpdesk_ticket_close_wizard_user,helpdesk.ticket.close.wizard.user,model_helpdesk_ticket_close_wizard,base.group_user,1,1,1,1
access_helpdesk_ticket_bulk_assign_wizard_user,helpdesk.ticket.bulk.assign.wizard.user,model_helpdesk_ticket_bulk_assign_wizard,base.group_user,1,1,1,1

Règle : ACL write/create/unlink à 1 pour tous les wizards — c'est l'équivalent du "droit de saisir dans ce formulaire éphémère". Les droits métiers s'appliquent sur la cible (ici helpdesk.ticket), pas sur le wizard.

Les pièges de wizard à connaître

  1. Oublier target='new' — le wizard s'ouvre en remplaçant la vue courante au lieu d'un dialog. Mauvaise UX, le retour arrière casse le flux.
  2. Oublier l'ACLAccessError à l'ouverture. Message d'erreur générique qui ne dit pas que c'est un wizard sans ACL.
  3. Utiliser models.Model au lieu de TransientModel — la table se remplit indéfiniment de saisies partielles. Audit DB douloureux.
  4. Faire une query SQL dans default_get pour pré-remplir — passe par l'ORM (self.env['model'].browse(id).field), sinon tu bypasses les ACL.
  5. Renvoyer None ou True depuis la méthode d'action — le dialog reste ouvert, l'utilisateur ne sait pas si ça a marché. Toujours renvoyer une act_window_close ou une notification.
  6. Chaîner deux wizards sans passer le contexte — le second wizard n'a plus d'active_id. Pense à context dans l'action retournée.

🎯 Bloc 4 terminé — et après ?

Avec ce T19, la série UI (Bloc 4) est complète. Tu as maintenant tous les outils pour construire un backend Odoo moderne :

Le Bloc 5 attaque la couche business côté serveur : rapports QWeb, impressions PDF, actions serveur automatisées, cron jobs, mail templates. Ça démarre au T20.

Voir aussi dans cette série

T15 — Méthodes de modèle

create, write, actions

T18 — Héritage de vues

xpath, inherit_id

Prochain bloc — Rapports et automatisations

On attaque le Bloc 5 : rapports QWeb, PDF, actions serveur, cron jobs, mail templates. La couche business côté serveur.

Télécharger le guide technique Odoo 19 (PDF gratuit)
Héritage de vues en Odoo 19 : xpath, inherit_id et les 5 positions
Bloc 4 · Interface utilisateur — Article 3/4 Héritage de vues en Odoo 19 Étendre une vue existante sans la réécrire — avec xpath , inherit_id et les cinq…