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_helpdeskv19.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.Model | models.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 :
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 :
- Toujours appeler
super()d'abord — sinon lesdefault_*du contexte sont ignorés. - Respecter
fields_list— Odoo ne demande pas tous les champs à chaque fois (tabs pliés, etc.), alimenter ce qui n'est pas demandé gaspille. - Lire
active_id/active_idsquand 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.invisiblesurduplicate_ticket_id— le champ apparaît dynamiquement quand le motif devient "Duplicata". Expression v19 directe, plus d'attrs.
5. La méthode d'action et le retour
Quand l'utilisateur clique "Clôturer", le code serveur :
- Valide la saisie (raise
ValidationErrorsi incohérent). - Écrit sur le vrai modèle.
- Poste éventuellement un message au client.
- 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 :
| Retour | Effet |
|---|---|
{'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.
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'},
},
}
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
- 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. - Oublier l'ACL — AccessError à l'ouverture. Message d'erreur générique qui ne dit pas que c'est un wizard sans ACL.
- Utiliser
models.Modelau lieu deTransientModel— la table se remplit indéfiniment de saisies partielles. Audit DB douloureux. - Faire une query SQL dans
default_getpour pré-remplir — passe par l'ORM (self.env['model'].browse(id).field), sinon tu bypasses les ACL. - Renvoyer
NoneouTruedepuis la méthode d'action — le dialog reste ouvert, l'utilisateur ne sait pas si ça a marché. Toujours renvoyer uneact_window_closeou une notification. - Chaîner deux wizards sans passer le contexte — le second wizard n'a
plus d'
active_id. Pense àcontextdans 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 :
- T16 — Form, List, Search : les vues de base.
- T17 — Kanban, Graph, Pivot : les dashboards.
- T18 — Héritage de vues : étendre l'existant.
- T19 — Wizards (cet article) : les interactions guidées.
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
create, write, actions
vues backend
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)