Module 3 — Wizards, Rapports, Sécurité

Aller plus loin : assistants modaux, rapports QWeb, modèle de sécurité Odoo, controllers HTTP.

5h Avancé

🎯 Objectifs

1. Wizards (TransientModel)

Un wizard est un formulaire modal temporaire, idéal pour des opérations en masse, des assistants d'import, des paramètres ponctuels.

wizard/library_book_lend_wizard.py
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 wizard

Sans cet attribut, votre wizard s'ouvre comme une vue normale plein écran, et les TransientModel records ne sont jamais purgés. Catastrophe garantie.

2. Rapports QWeb (PDF)

QWeb est le moteur de template d'Odoo. Pour générer un PDF, on déclare 3 choses :

  1. Une ir.actions.report (déclaration du rapport)
  2. Un template QWeb (le HTML qui sera converti en PDF)
  3. Optionnel : layout custom (header/footer entreprise)
report/library_book_report.xml
<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>
📌
Variables disponibles dans QWeb

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é.

3. Sécurité — Le triptyque Odoo

La sécurité Odoo se joue à 3 niveaux. Comprenez les bien, c'est ce que les juniors ratent le plus.

NiveauFichierRéponse à la question
1. Access Control Listsecurity/ir.model.access.csv"Qui peut lire/écrire/créer/supprimer sur QUEL modèle ?"
2. Record Rulessecurity/record_rules.xml"Sur QUELS records ce groupe peut-il agir ?"
3. Groupes & Champssecurity/groups.xml"Qui voit ce bouton, ce champ, ce menu ?"

3.1 Définir les groupes

security/library_security.xml
<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.

3.2 ACL (CSV)

security/ir.model.access.csv
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
⚠️
Si vous oubliez la ligne CSV…

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.

3.3 Record Rules

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>

3.4 Sécurité au niveau vue/champ

<!-- 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"/>

4. Controllers HTTP

Pour exposer une route web (page publique, API, webhook), on utilise un http.Controller.

controllers/main.py
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]
Le piège 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 :

5. Hooks d'installation

Quatre 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,
    })

6. Performance ORM — les pièges classiques

🐌
N+1 queries — l'erreur n°1

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)

Autres patterns à connaître

# 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

Mission

  1. Créer un wizard 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.
  2. Créer un rapport PDF "Fiche emprunt" qui prend une réservation et génère une fiche imprimable.
  3. Créer 2 groupes : library_user (lecture seule sur livres) et library_manager (CRUD complet).
  4. Ajouter une record rule qui limite les library.member à leurs propres réservations.
  5. Exposer une route JSON /api/library/borrow qui permet d'emprunter un livre via POST avec auth utilisateur.

🔗 Ressources

📋 Quiz de validation

Module 2 — Vues Module 4 — Pratique