Pratique guidée : on construit ensemble, étape par étape, un module de gestion de bibliothèque complet.
Un module library complet avec :
library.book + library.reservationres.partner pour ajouter les livres d'un membrelibrary/
├── __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
{
'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',
}
data est CRITIQUELes 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.
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,
})
from . import library_book
from . import library_reservation
from . import res_partner
library.bookfrom 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')
library.reservationfrom 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'
res.partnerfrom 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'
))
<?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>
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
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.
<?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>
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
library/ dans addons-custom/ (monté dans /mnt/extra-addons)docker compose restart odoo?debug=1 dans l'URLdocker compose logs -f odoo| Erreur | Cause | Solution |
|---|---|---|
External ID not found: model_library_book | ACL CSV chargée avant que le modèle n'existe | Vérifier ordre dans data + restart Odoo |
Field 'xxx' does not exist | Vue 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'utilisateur | Vérifier le CSV + ajout de l'utilisateur au groupe |
| Module ne s'affiche pas | Manifest invalide ou installable: False | Lire les logs au démarrage : Odoo dit pourquoi |
--dev=reload,qweb,xml sauve la vieAvec 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).
Vous êtes maintenant prêt pour les 3 exercices pratiques et pour lire le code des modules Ezway en production.