🏋️ Practical Exercise
- Create the
library.bookandlibrary.membermodels. - Add a loan model linking them.
- Build views and a menu.
- Add access rights.
- Add a "return book" action.
🔥 Challenge Exercise
Build a complete Library module from scratch: models for books, members, and loans (with relations), views and menus, access rights, and a method to borrow and return a book with availability checks. Explain how the pieces (models, views, security, logic) fit together into a working module.
- Three models:
library.book,library.member,library.loan - Form, list, kanban, and search views
- Access rights and record rules
- Computed fields: loan count, overdue status, fine amount
- A wizard: "Return Book" dialog with batch processing
- Customer portal: member's loan history
- Scheduled action: mark overdue loans automatically
Project Overview
The library module tracks books and member loan history. The requirements:
- Librarians manage books, members, and loans from the backend
- Members can view their active and past loans via a portal page at
/my/loans - Overdue loans (past due date) are automatically marked by a daily cron job
- A "Return Book" wizard allows processing multiple returns at once
Step 1: Module Structure
library_management/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ ├── library_book.py
│ ├── library_member.py
│ └── library_loan.py
├── views/
│ ├── library_book_views.xml
│ ├── library_member_views.xml
│ ├── library_loan_views.xml
│ └── library_menus.xml
├── security/
│ ├── ir.model.access.csv
│ └── library_security.xml
├── wizards/
│ ├── __init__.py
│ ├── return_book_wizard.py
│ └── return_book_wizard_views.xml
├── controllers/
│ ├── __init__.py
│ └── portal.py
├── data/
│ └── library_cron.xml
└── templates/
└── portal_my_loans.xml
Step 2: Models
# models/library_loan.py
from odoo import models, fields, api
from odoo.exceptions import ValidationError
from datetime import date
class LibraryLoan(models.Model):
_name = 'library.loan'
_inherit = ['portal.mixin', 'mail.thread']
_description = 'Library Loan'
_order = 'loan_date desc'
member_id = fields.Many2one('res.partner', string='Member', required=True)
book_id = fields.Many2one('library.book', string='Book', required=True)
loan_date = fields.Date(string='Loan Date', default=fields.Date.today)
due_date = fields.Date(string='Due Date', required=True)
return_date = fields.Date(string='Return Date')
state = fields.Selection([
('active', 'Active'),
('returned', 'Returned'),
('overdue', 'Overdue'),
], default='active', tracking=True)
days_overdue = fields.Integer(
string='Days Overdue',
compute='_compute_days_overdue',
)
fine_amount = fields.Float(
string='Fine (€)',
compute='_compute_fine_amount',
store=True,
)
@api.depends('due_date', 'return_date', 'state')
def _compute_days_overdue(self):
today = date.today()
for loan in self:
end = loan.return_date or today
if loan.due_date and end > loan.due_date:
loan.days_overdue = (end - loan.due_date).days
else:
loan.days_overdue = 0
@api.depends('days_overdue')
def _compute_fine_amount(self):
rate = 0.50 # €0.50 per day overdue
for loan in self:
loan.fine_amount = loan.days_overdue * rate
def _compute_access_url(self):
for loan in self:
loan.access_url = f'/my/loans/{loan.id}'
@api.constrains('due_date', 'loan_date')
def _check_due_date(self):
for loan in self:
if loan.due_date and loan.loan_date:
if loan.due_date < loan.loan_date:
raise ValidationError(
'Due date must be after the loan date.'
)
Step 3: Return Book Wizard
# wizards/return_book_wizard.py
from odoo import models, fields, api
from datetime import date
class ReturnBookWizard(models.TransientModel):
_name = 'library.return.wizard'
_description = 'Return Book Wizard'
loan_ids = fields.Many2many(
'library.loan',
string='Loans to Return',
domain=[('state', 'in', ['active', 'overdue'])],
)
return_date = fields.Date(
string='Return Date',
default=fields.Date.today,
required=True,
)
def action_return(self):
self.loan_ids.write({
'state': 'returned',
'return_date': self.return_date,
})
for loan in self.loan_ids:
loan.book_id.available_copies += 1
return {'type': 'ir.actions.act_window_close'}
Step 4: Scheduled Action
<!-- data/library_cron.xml -->
<record id="ir_cron_mark_overdue_loans" model="ir.cron">
<field name="name">Library: Mark Overdue Loans</field>
<field name="model_id" ref="model_library_loan"/>
<field name="state">code</field>
<field name="code">model.mark_overdue_loans()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
# In LibraryLoan model
@api.model
def mark_overdue_loans(self):
from datetime import date
overdue = self.search([
('state', '=', 'active'),
('due_date', '<', date.today()),
])
overdue.write({'state': 'overdue'})
Step 5: Portal Integration
Add the portal controller (extend CustomerPortal) and a QWeb template. See the Portal Controllers and Portal Templates lessons for the full patterns to follow.
Key steps for the portal page:
- Add
portal.mixintolibrary.loan(already done above) - Add access rights for
base.group_portalinir.model.access.csv - Add record rule: portal users see only their own loans
- Write
LibraryPortal(CustomerPortal)withportal_my_loansandportal_loan_detailroutes - Write QWeb templates inheriting
portal.portal_layout
- Add a
library.categorymodel and filter books by category in the list view - Add an email notification when a loan becomes overdue (using mail.template)
- Extend the portal to allow members to request a loan via a form
- Add a Kanban view for loans grouped by state
- Add a dashboard with a count of active loans, overdue loans, and total fines
Interview Questions
- What models would a library app need?
- How do you model book–member loans?
- How do you enforce that a book cannot be double-borrowed?
- What security does the module need?
- How do you wire a button to a Python method?
Related Topics
FAQ
The return wizard only needs to exist for the duration of the user's action — the data (return date, condition, note) is not a permanent business record. TransientModel records are auto-cleaned by Odoo's vacuum job, so you don't accumulate wizard records in the database over time.
portal.mixin and why is it added to library.loan? +portal.mixin adds _compute_access_url() and an access token field, giving each loan record a unique portal URL. The controller uses _document_check_access() (also from the mixin) to verify the logged-in user is the loan's member before rendering the page.
The cron domain filters [('state', '=', 'active'), ('due_date', '<', fields.Date.today())] — only active loans past their due date are updated to overdue. Returned or lost loans are already in a terminal state and are excluded by the domain.

