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
| Aucun commentaire pour l'instant

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)
Se connecter pour laisser un commentaire.
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…