What you'll build:
- 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
Bash
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
Python
# 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.'
)
Ad – 728×90
Step 3: Return Book Wizard
Python
# 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
XML
<!-- 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>
Python
# 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
Extension challenges:
- 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