Aller plus loin : assistants modaux, rapports QWeb, modèle de sécurité Odoo, controllers HTTP.
TransientModel avec actionspre_init / post_init / uninstallUn wizard est un formulaire modal temporaire, idéal pour des opérations en masse, des assistants d'import, des paramètres ponctuels.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class LibraryBookLendWizard(models.TransientModel):
_name = 'library.book.lend.wizard'
_description = 'Assistant : prêter un livre'
book_id = fields.Many2one('library.book', required=True)
member_id = fields.Many2one('library.member', required=True)
date_return = fields.Date(default=lambda s: fields.Date.add(fields.Date.today(), days=14))
send_email = fields.Boolean(default=True, string="Envoyer email de confirmation")
@api.model
def default_get(self, fields_list):
# Pré-remplir avec le record actif (depuis le bouton dans la vue form)
res = super().default_get(fields_list)
if self.env.context.get('active_model') == 'library.book':
res['book_id'] = self.env.context.get('active_id')
return res
def action_confirm(self):
self.ensure_one()
if self.book_id.state != 'available':
raise UserError(_("Ce livre n'est pas disponible."))
self.env['library.reservation'].create({
'book_id': self.book_id.id,
'member_id': self.member_id.id,
'date_return': self.date_return,
})
self.book_id.state = 'borrowed'
if self.send_email:
self._send_confirmation_email()
return {'type': 'ir.actions.act_window_close'}
Vue du wizard :
<record id="view_lend_wizard_form" model="ir.ui.view">
<field name="name">library.book.lend.wizard.form</field>
<field name="model">library.book.lend.wizard</field>
<field name="arch" type="xml">
<form string="Prêter un livre">
<group>
<field name="book_id"/>
<field name="member_id"/>
<field name="date_return"/>
<field name="send_email"/>
</group>
<footer>
<button name="action_confirm" string="Confirmer"
type="object" class="btn-primary"/>
<button string="Annuler" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_lend_wizard" model="ir.actions.act_window">
<field name="name">Prêter un livre</field>
<field name="res_model">library.book.lend.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field> <!-- ⚠️ TRÈS IMPORTANT pour ouvrir en modal -->
</record>
target=new est OBLIGATOIRE pour un wizardSans cet attribut, votre wizard s'ouvre comme une vue normale plein écran, et les TransientModel records ne sont jamais purgés. Catastrophe garantie.
QWeb est le moteur de template d'Odoo. Pour générer un PDF, on déclare 3 choses :
ir.actions.report (déclaration du rapport)<odoo>
<record id="action_library_book_report" model="ir.actions.report">
<field name="name">Fiche livre</field>
<field name="model">library.book</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">library.report_book_template</field>
<field name="report_file">library.report_book_template</field>
<field name="binding_model_id" ref="model_library_book"/>
<field name="binding_type">report</field>
</record>
<template id="report_book_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="book">
<t t-call="web.external_layout">
<div class="page">
<h1>Fiche livre</h1>
<h2 t-field="book.title"/>
<div class="row mt-4">
<div class="col-6">
<strong>Auteur :</strong> <span t-field="book.author_id"/><br/>
<strong>ISBN :</strong> <span t-field="book.isbn"/><br/>
<strong>Publié le :</strong>
<span t-field="book.publish_date"
t-options-format="'long'"/>
</div>
<div class="col-6 text-end">
<img t-att-src="image_data_uri(book.cover_image)"
style="max-height: 200px;"/>
</div>
</div>
<h3 class="mt-4">Historique de prêts</h3>
<table class="table table-sm">
<thead>
<tr><th>Date</th><th>Emprunteur</th><th>Retour</th></tr>
</thead>
<tbody>
<tr t-foreach="book.reservation_ids" t-as="r">
<td t-field="r.date_start"/>
<td t-field="r.member_id"/>
<td t-field="r.date_return"/>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>
docs = recordset des records (par défaut). doc_ids, doc_model, company, user, res_company sont aussi disponibles. Vous pouvez passer des variables custom via une méthode _get_report_values sur un AbstractModel dédié.
La sécurité Odoo se joue à 3 niveaux. Comprenez les bien, c'est ce que les juniors ratent le plus.
| Niveau | Fichier | Réponse à la question |
|---|---|---|
| 1. Access Control List | security/ir.model.access.csv | "Qui peut lire/écrire/créer/supprimer sur QUEL modèle ?" |
| 2. Record Rules | security/record_rules.xml | "Sur QUELS records ce groupe peut-il agir ?" |
| 3. Groupes & Champs | security/groups.xml | "Qui voit ce bouton, ce champ, ce menu ?" |
<odoo>
<record id="module_category_library" model="ir.module.category">
<field name="name">Bibliothèque</field>
<field name="sequence">10</field>
</record>
<record id="group_library_user" model="res.groups">
<field name="name">Utilisateur Bibliothèque</field>
<field name="category_id" ref="module_category_library"/>
</record>
<record id="group_library_manager" model="res.groups">
<field name="name">Manager Bibliothèque</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
</record>
</odoo>
implied_ids avec (4, ref(...)) = un Manager hérite automatiquement des droits du User.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_book_user,library.book.user,model_library_book,group_library_user,1,0,0,0
access_book_manager,library.book.manager,model_library_book,group_library_manager,1,1,1,1
access_reservation_user,library.reservation.user,model_library_reservation,group_library_user,1,1,1,0
access_reservation_manager,library.reservation.manager,model_library_reservation,group_library_manager,1,1,1,1
Aucun utilisateur ne pourra accéder au modèle (sauf l'admin via sudo). Erreur 403 garantie pour les utilisateurs standards. Toujours ajouter au minimum un accès lecture pour le groupe pertinent.
Limitent le périmètre à l'intérieur du modèle. Exemple : "un utilisateur ne voit que ses propres réservations".
<record id="reservation_user_rule" model="ir.rule">
<field name="name">Reservation : own only</field>
<field name="model_id" ref="model_library_reservation"/>
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
<field name="domain_force">[('member_id.user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Champ visible uniquement pour les managers -->
<field name="cost_price" groups="library.group_library_manager"/>
<!-- Bouton visible uniquement pour les users -->
<button name="action_borrow" groups="library.group_library_user"/>
Pour exposer une route web (page publique, API, webhook), on utilise un http.Controller.
from odoo import http
from odoo.http import request
import json
class LibraryController(http.Controller):
@http.route('/library/books', type='http', auth='public', website=True)
def books_list(self, **kw):
books = request.env['library.book'].sudo().search([('state', '=', 'available')])
return request.render('library.books_list_template', {'books': books})
@http.route('/api/library/books', type='json', auth='user', methods=['POST'])
def api_books(self, **kw):
domain = kw.get('domain', [])
books = request.env['library.book'].search(domain)
return [{
'id': b.id,
'title': b.title,
'author': b.author_id.name,
'state': b.state,
} for b in books]
auth='public'Avec auth='public', n'importe qui (utilisateur non connecté) accède à la route. Si vous ne sudo() pas explicitement votre search, l'utilisateur public ne voit rien (et ce n'est pas un bug, c'est la sécurité). Si vous sudo, vous bypassez TOUTES les règles — soyez précautionneux.
Types de routes :
type='http' — retourne du HTML (rendu de template)type='json' — endpoint JSON-RPC (corps JSON, réponse JSON)auth='public' — accessible sans loginauth='user' — utilisateur Odoo connecté requisauth='none' — pas d'env, attention manuelleQuatre hooks peuvent être déclarés dans le manifest :
# __manifest__.py
{
'pre_init_hook': '_pre_init', # avant l'install
'post_init_hook': '_post_init', # après l'install initiale
'post_load': '_post_load', # à chaque démarrage Odoo
'uninstall_hook': '_uninstall', # avant désinstallation
}
# __init__.py
def _post_init(env):
# env est passé en argument depuis Odoo 17+
env['ir.sequence'].create({
'name': 'Livre référence',
'code': 'library.book',
'prefix': 'BOOK/',
'padding': 5,
})
Faire un browse() dans une boucle = N requêtes SQL. Toujours search() en un coup et utiliser mapped() / filtered().
Mauvais :
for order_id in [1, 2, 3, 4, 5]:
order = env['sale.order'].browse(order_id)
print(order.partner_id.name) # 1 query par order → 5 queries
Bon :
orders = env['sale.order'].browse([1, 2, 3, 4, 5])
for order in orders:
print(order.partner_id.name) # 1 query préfetché (auto)
# search() + count + read en une seule requête
data = env['library.book'].search_read(
domain=[('state', '=', 'available')],
fields=['title', 'author_id'],
limit=50,
)
# Recordset read_group pour des aggregations
env['library.book'].read_group(
domain=[],
fields=['state', 'price:sum'],
groupby=['state'],
)
# Forcer le flush avant un raw SQL
self.flush_model()
env.cr.execute("SELECT id FROM library_book WHERE ...")
🏋️ Exercice de validation
library.book.bulk.archive qui prend une liste de book_ids (depuis l'action multi-sélection en liste) et les archive en masse, avec un check préalable qu'aucun livre n'est en cours de prêt.library_user (lecture seule sur livres) et library_manager (CRUD complet).library.member à leurs propres réservations./api/library/borrow qui permet d'emprunter un livre via POST avec auth utilisateur.