Module 4 — Premier module Odoo de A à Z

Pratique guidée : on construit ensemble, étape par étape, un module de gestion de bibliothèque complet.

8h Pratique guidée

🎯 Ce qu'on va construire

Un module library complet avec :

📁 Structure cible

library/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   ├── library_book.py
│   ├── library_reservation.py
│   └── res_partner.py
├── wizard/
│   ├── __init__.py
│   └── library_book_lend_wizard.py
├── controllers/
│   ├── __init__.py
│   └── main.py
├── views/
│   ├── library_book_views.xml
│   ├── library_reservation_views.xml
│   ├── res_partner_views.xml
│   └── library_menus.xml
├── wizard/views/
│   └── library_book_lend_wizard_views.xml
├── report/
│   ├── library_book_report.xml
│   └── library_book_template.xml
├── security/
│   ├── library_security.xml
│   └── ir.model.access.csv
├── data/
│   ├── ir_sequence_data.xml
│   └── library_demo.xml
├── static/
│   └── description/
│       ├── icon.png
│       └── index.html
└── tests/
    ├── __init__.py
    └── test_library_book.py

Étape 1 — Le manifest

__manifest__.py
{
    'name': 'Library',
    'version': '19.0.1.0.0',
    'category': 'Services',
    'summary': 'Gestion de bibliothèque (livres, membres, prêts)',
    'description': """
Module de gestion d'une petite bibliothèque :
- catalogue de livres
- membres (adhérents)
- prêts & réservations
- rapport PDF par livre
    """,
    'author': 'Ezway Technology',
    'website': 'https://ezway-technology.com',
    'license': 'LGPL-3',
    'depends': [
        'base',
        'mail',        # pour le chatter / tracking
        'web',         # OWL frontend
    ],
    'data': [
        # SÉCURITÉ d'ABORD (ordre important)
        'security/library_security.xml',
        'security/ir.model.access.csv',
        # Data
        'data/ir_sequence_data.xml',
        # Vues
        'views/library_book_views.xml',
        'views/library_reservation_views.xml',
        'views/res_partner_views.xml',
        'wizard/views/library_book_lend_wizard_views.xml',
        'views/library_menus.xml',      # menus EN DERNIER
        # Rapports
        'report/library_book_report.xml',
        'report/library_book_template.xml',
    ],
    'demo': [
        'data/library_demo.xml',
    ],
    'installable': True,
    'application': True,
    'post_init_hook': '_post_init_hook',
}
📌
L'ordre dans data est CRITIQUE

Les fichiers sont chargés dans l'ordre. La sécurité doit venir avant les vues (sinon Odoo lève une erreur en chargeant une vue qui référence un groupe pas encore défini). Les menus en dernier (ils référencent des actions). Une erreur classique : mettre les ACL après les vues → install KO.

Étape 2 — Les __init__.py

__init__.py (racine)
from . import models
from . import wizard
from . import controllers


def _post_init_hook(env):
    # Exemple : créer la séquence si absente
    if not env['ir.sequence'].search([('code', '=', 'library.book')]):
        env['ir.sequence'].create({
            'name': 'Library Book Reference',
            'code': 'library.book',
            'prefix': 'BOOK/',
            'padding': 5,
        })
models/__init__.py
from . import library_book
from . import library_reservation
from . import res_partner

Étape 3 — Modèle library.book

models/library_book.py
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError, UserError


class LibraryBook(models.Model):
    _name = 'library.book'
    _description = 'Livre de bibliothèque'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _order = 'name asc'
    _rec_name = 'name'

    name = fields.Char(string='Titre', required=True, tracking=True)
    reference = fields.Char(string='Référence', copy=False, readonly=True, default='/')
    isbn = fields.Char(string='ISBN', tracking=True)
    publish_date = fields.Date(string='Date de publication')
    author_id = fields.Many2one(
        'res.partner',
        string='Auteur',
        domain=[('is_company', '=', False)],
        ondelete='restrict',
        tracking=True,
    )
    state = fields.Selection([
        ('draft', 'Brouillon'),
        ('available', 'Disponible'),
        ('borrowed', 'Emprunté'),
        ('lost', 'Perdu'),
        ('archived', 'Archivé'),
    ], default='draft', tracking=True, required=True)
    cover_image = fields.Binary(string='Couverture', attachment=True)
    description = fields.Html()
    tag_ids = fields.Many2many('library.book.tag', string='Tags')
    reservation_ids = fields.One2many('library.reservation', 'book_id')
    reservation_count = fields.Integer(compute='_compute_reservation_count')
    active = fields.Boolean(default=True)

    @api.depends('reservation_ids')
    def _compute_reservation_count(self):
        for book in self:
            book.reservation_count = len(book.reservation_ids)

    @api.constrains('isbn')
    def _check_isbn(self):
        for book in self:
            if book.isbn and len(book.isbn.replace('-', '')) not in (10, 13):
                raise ValidationError(_("L'ISBN doit faire 10 ou 13 caractères."))

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            if vals.get('reference', '/') == '/':
                vals['reference'] = self.env['ir.sequence'].next_by_code('library.book') or '/'
        return super().create(vals_list)

    def action_make_available(self):
        self.write({'state': 'available'})

    def action_lend(self):
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'name': _('Prêter le livre'),
            'res_model': 'library.book.lend.wizard',
            'view_mode': 'form',
            'target': 'new',
            'context': {
                'default_book_id': self.id,
                'active_model': 'library.book',
                'active_id': self.id,
            },
        }


class LibraryBookTag(models.Model):
    _name = 'library.book.tag'
    _description = 'Tag de livre'

    name = fields.Char(required=True)
    color = fields.Integer(string='Couleur')

Étape 4 — Modèle library.reservation

models/library_reservation.py
from odoo import models, fields, api, _
from odoo.exceptions import UserError


class LibraryReservation(models.Model):
    _name = 'library.reservation'
    _description = 'Prêt de livre'
    _order = 'date_start desc'

    book_id = fields.Many2one('library.book', required=True, ondelete='restrict')
    member_id = fields.Many2one('res.partner', required=True)
    date_start = fields.Date(default=fields.Date.today, required=True)
    date_return = fields.Date()
    date_returned = fields.Date(readonly=True)
    state = fields.Selection([
        ('active', 'En cours'),
        ('returned', 'Rendu'),
        ('overdue', 'En retard'),
    ], default='active', compute='_compute_state', store=True)

    @api.depends('date_returned', 'date_return')
    def _compute_state(self):
        today = fields.Date.today()
        for res in self:
            if res.date_returned:
                res.state = 'returned'
            elif res.date_return and res.date_return < today:
                res.state = 'overdue'
            else:
                res.state = 'active'

    def action_return(self):
        for res in self:
            if res.state == 'returned':
                raise UserError(_('Déjà rendu.'))
            res.date_returned = fields.Date.today()
            res.book_id.state = 'available'

Étape 5 — Héritage res.partner

models/res_partner.py
from odoo import models, fields


class ResPartner(models.Model):
    _inherit = 'res.partner'

    is_library_member = fields.Boolean(string='Adhérent bibliothèque')
    library_card_number = fields.Char(string='N° carte', copy=False)
    book_ids = fields.One2many('library.book', 'author_id', string='Livres écrits')
    reservation_ids = fields.One2many('library.reservation', 'member_id', string='Prêts')
    active_reservation_count = fields.Integer(compute='_compute_reservation_count')

    def _compute_reservation_count(self):
        for partner in self:
            partner.active_reservation_count = len(partner.reservation_ids.filtered(
                lambda r: r.state == 'active'
            ))

Étape 6 — Sécurité

security/library_security.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
  <record id="category_library" model="ir.module.category">
    <field name="name">Bibliothèque</field>
  </record>

  <record id="group_library_user" model="res.groups">
    <field name="name">Utilisateur Bibliothèque</field>
    <field name="category_id" ref="category_library"/>
  </record>

  <record id="group_library_manager" model="res.groups">
    <field name="name">Manager Bibliothèque</field>
    <field name="category_id" ref="category_library"/>
    <field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
  </record>
</odoo>
security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_book_user,book user,model_library_book,group_library_user,1,0,0,0
access_book_mgr,book mgr,model_library_book,group_library_manager,1,1,1,1
access_tag_user,tag user,model_library_book_tag,group_library_user,1,0,0,0
access_tag_mgr,tag mgr,model_library_book_tag,group_library_manager,1,1,1,1
access_resa_user,resa user,model_library_reservation,group_library_user,1,1,1,0
access_resa_mgr,resa mgr,model_library_reservation,group_library_manager,1,1,1,1
access_wizard,wiz,model_library_book_lend_wizard,group_library_user,1,1,1,1

Étape 7 — Vues

Vue form, list, search pour library.book. Reprendre les templates du module 2 et les adapter aux champs définis ici. Idem pour les réservations et l'héritage res.partner.

views/library_menus.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>

  <menuitem id="menu_library_root"
            name="Bibliothèque"
            web_icon="library,static/description/icon.png"
            groups="group_library_user"
            sequence="100"/>

  <menuitem id="menu_library_book" name="Livres"
            parent="menu_library_root"
            action="action_library_book"
            sequence="10"/>

  <menuitem id="menu_library_reservation" name="Prêts"
            parent="menu_library_root"
            action="action_library_reservation"
            sequence="20"/>

  <menuitem id="menu_library_config" name="Configuration"
            parent="menu_library_root"
            groups="group_library_manager"
            sequence="90"/>

  <menuitem id="menu_library_tag" name="Tags"
            parent="menu_library_config"
            action="action_library_book_tag"/>

</odoo>

Étape 8 — Test unitaire

tests/test_library_book.py
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError


class TestLibraryBook(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.author = cls.env['res.partner'].create({'name': 'Victor Hugo'})

    def test_create_book_assigns_reference(self):
        book = self.env['library.book'].create({
            'name': 'Les Misérables',
            'author_id': self.author.id,
        })
        self.assertTrue(book.reference.startswith('BOOK/'))

    def test_invalid_isbn_raises(self):
        with self.assertRaises(ValidationError):
            self.env['library.book'].create({
                'name': 'Test',
                'isbn': '1234',   # ni 10 ni 13
            })

    def test_lending_updates_state(self):
        book = self.env['library.book'].create({
            'name': 'Notre-Dame',
            'state': 'available',
        })
        member = self.env['res.partner'].create({'name': 'Lecteur'})
        wiz = self.env['library.book.lend.wizard'].create({
            'book_id': book.id,
            'member_id': member.id,
        })
        wiz.action_confirm()
        self.assertEqual(book.state, 'borrowed')

Lancer les tests :

docker compose exec odoo odoo \
  -d test19 \
  --test-tags=library \
  --stop-after-init \
  -u library

Étape 9 — Installation & debug

  1. Placer le dossier library/ dans addons-custom/ (monté dans /mnt/extra-addons)
  2. Redémarrer Odoo : docker compose restart odoo
  3. Activer le mode dev : ?debug=1 dans l'URL
  4. Apps → Update Apps List → chercher "Library" → Install
  5. Si erreur : docker compose logs -f odoo

Erreurs typiques au 1er install

ErreurCauseSolution
External ID not found: model_library_bookACL CSV chargée avant que le modèle n'existeVérifier ordre dans data + restart Odoo
Field 'xxx' does not existVue référence un champ supprimé/mal nomméCorriger le XML, restart, -u library
You are not allowed to access...Pas d'ACL pour le groupe de l'utilisateurVérifier le CSV + ajout de l'utilisateur au groupe
Module ne s'affiche pasManifest invalide ou installable: FalseLire les logs au démarrage : Odoo dit pourquoi
💡
Le mode --dev=reload,qweb,xml sauve la vie

Avec cette option dans odoo.conf, vos modifications de XML et de templates QWeb sont prises en compte sans redémarrer Odoo. Pour le Python, il faut quand même restart (ou utiliser odoo-bin --dev=reload mais c'est instable).

🎓 À ce stade vous savez

Vous êtes maintenant prêt pour les 3 exercices pratiques et pour lire le code des modules Ezway en production.

📋 Quiz de validation

Module 3 — Avancé Module 5 — Patterns Ezway