實作:圖書借閱模組
開發入門 用最小的模組認識了清單檔、模型、檢視、權限。本頁從空資料夾開始,做出一個可以真正使用的模組:登記書籍、建立借閱單、借出與歸還、標示逾期,並依角色給不同權限。
做完你會用到 addon 開發最常見的 20%:關聯欄位、預設值、計算欄位、驗證、流水號、按鈕與狀態列、搜尋檢視、群組權限。
本頁所有程式碼都在 Odoo 18.0 社群版實際安裝過並通過測試。
先備:會啟動 Odoo(安裝與環境),看過 odoo-bin 指令 的 -i、-u、scaffold。
要做什麼
| 模型 | 用途 | 重點欄位 |
|---|---|---|
library.book(書籍) |
一本書的基本資料 | 書名、ISBN、作者、是否可借 |
library.loan(借閱單) |
誰在什麼時候借了哪一本 | 書、借閱人、借出日、應還日、狀態 |
借閱人直接用 Odoo 內建的聯絡人(res.partner),不另外建「讀者」資料表。能重用標準模型就重用,這是 Odoo 開發的基本原則。
流程:草稿 → 借出中 → 已歸還。同一本書在借出中時,不能再借給別人。
步驟 1:用 scaffold 建骨架
原始碼安裝:
Docker(本站 Compose,檔案會出現在專案的 ./addons/library/):
scaffold 產生的內容大多是註解掉的範例。我們用不到 controllers/ 與 views/templates.xml,所以刪掉它們,並把 models/models.py、views/views.xml、demo/demo.xml 換成下面自己命名的檔案。最後的結構:
library/
├─ __init__.py
├─ __manifest__.py
├─ models/
│ ├─ __init__.py
│ ├─ library_book.py
│ └─ library_loan.py
├─ security/
│ ├─ library_security.xml
│ └─ ir.model.access.csv
├─ data/
│ └─ ir_sequence_data.xml
├─ views/
│ ├─ library_book_views.xml
│ ├─ library_loan_views.xml
│ └─ library_menus.xml
└─ demo/
└─ library_demo.xml
檔名慣例是「模型名_views.xml」,一個模型一組檔案,日後才好找。
步驟 2:清單檔與入口
{
"name": "Library 圖書借閱",
"summary": "登記書籍,記錄誰借了哪一本、何時要還",
"version": "18.0.1.0.0",
"category": "Services",
"author": "唯客學院",
"license": "LGPL-3",
"depends": ["base"],
"data": [
"security/library_security.xml",
"security/ir.model.access.csv",
"data/ir_sequence_data.xml",
"views/library_book_views.xml",
"views/library_loan_views.xml",
"views/library_menus.xml",
],
"demo": [
"demo/library_demo.xml",
],
"application": True,
"installable": True,
}
新出現的欄位:
| 欄位 | 說明 |
|---|---|
license |
授權。沒寫時 Odoo 會警告並預設 LGPL-3 |
application |
True 表示它是一個獨立「應用程式」,會出現在應用程式清單與主選單 |
demo |
只在資料庫啟用示範資料時才載入,用來做展示與測試 |
data 的順序就是載入順序。權限(群組、CSV)要在檢視與選單之前,因為檢視與選單會引用群組;有依賴關係的檔案,被引用的放前面。
步驟 3:模型
書籍
from odoo import api, fields, models
class LibraryBook(models.Model):
_name = "library.book"
_description = "Library Book"
_order = "name"
name = fields.Char(string="書名", required=True)
isbn = fields.Char(string="ISBN", copy=False)
author = fields.Char(string="作者")
active = fields.Boolean(default=True)
loan_ids = fields.One2many("library.loan", "book_id", string="借閱紀錄")
is_available = fields.Boolean(
string="可借",
compute="_compute_is_available",
store=True,
)
_sql_constraints = [
("isbn_uniq", "unique(isbn)", "ISBN 不可重複。"),
]
@api.depends("loan_ids.state")
def _compute_is_available(self):
for book in self:
book.is_available = not book.loan_ids.filtered(
lambda loan: loan.state == "borrowed"
)
重點:
_description:模型的人類可讀名稱,沒寫會有警告。_order:預設排序。active:Odoo 內建慣例。有這個欄位,記錄就能「封存」(隱藏但不刪除),表單會自動出現封存按鈕,搜尋預設也會排除封存記錄。copy=False:複製記錄時不複製這個欄位。ISBN 複製後會重複,所以不應複製。_sql_constraints:資料庫層級的限制,最可靠。unique(isbn)允許多筆空值,只擋重複。One2many:「一本書有多筆借閱紀錄」。它不是資料庫欄位,只是library.loan.book_id的反向檢視,第二個參數要寫對方的Many2one欄位名。compute+store=True:計算欄位。store=True會存進資料庫,才能在搜尋、篩選、分組使用。@api.depends("loan_ids.state")告訴 Odoo「借閱單狀態一變,就重算這本書的可借狀態」。
compute 一定要寫 @api.depends
沒寫 depends 的計算欄位,第一次讀取後結果會被快取,之後來源資料變了也不會重算。繼承與擴充 會示範這個踩雷過程。
Odoo 19 的 SQL 限制寫法
Odoo 19 起 _sql_constraints 改為 models.Constraint。本站以 18.0 為準,升級時再改。
借閱單
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class LibraryLoan(models.Model):
_name = "library.loan"
_description = "Library Loan"
_order = "date_borrowed desc, id desc"
name = fields.Char(string="單號", default="New", readonly=True, copy=False)
book_id = fields.Many2one("library.book", string="書籍", required=True)
borrower_id = fields.Many2one("res.partner", string="借閱人", required=True)
date_borrowed = fields.Date(string="借出日", default=fields.Date.context_today)
loan_days = fields.Integer(string="借期(天)", default=14)
date_due = fields.Date(string="應還日", compute="_compute_date_due", store=True)
date_returned = fields.Date(string="歸還日", readonly=True, copy=False)
state = fields.Selection(
[("draft", "草稿"), ("borrowed", "借出中"), ("returned", "已歸還")],
string="狀態",
default="draft",
required=True,
copy=False,
)
is_overdue = fields.Boolean(string="逾期", compute="_compute_is_overdue")
@api.depends("date_borrowed", "loan_days")
def _compute_date_due(self):
for loan in self:
if loan.date_borrowed:
loan.date_due = loan.date_borrowed + timedelta(days=loan.loan_days)
else:
loan.date_due = False
@api.depends("state", "date_due")
def _compute_is_overdue(self):
today = fields.Date.context_today(self)
for loan in self:
loan.is_overdue = (
loan.state == "borrowed" and bool(loan.date_due) and loan.date_due < today
)
@api.constrains("loan_days")
def _check_loan_days(self):
for loan in self:
if loan.loan_days <= 0:
raise ValidationError(_("借期必須大於 0 天。"))
@api.constrains("book_id", "state")
def _check_book_not_lent_twice(self):
for loan in self.filtered(lambda l: l.state == "borrowed"):
other = self.search_count([
("book_id", "=", loan.book_id.id),
("state", "=", "borrowed"),
("id", "!=", loan.id),
])
if other:
raise ValidationError(
_("《%s》已經借出,還沒歸還。", loan.book_id.name)
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get("name", "New") == "New":
vals["name"] = self.env["ir.sequence"].next_by_code("library.loan") or "New"
return super().create(vals_list)
def action_borrow(self):
self.write({"state": "borrowed"})
def action_return(self):
self.write({
"state": "returned",
"date_returned": fields.Date.context_today(self),
})
逐項看:
| 寫法 | 說明 |
|---|---|
Many2one("res.partner") |
多對一:這張單指向一位聯絡人,資料庫存的是對方的 id |
default=fields.Date.context_today |
預設值是「使用者時區的今天」。傳函式本身,不要寫 context_today(),否則預設值會被固定在啟動當下 |
Selection([(值, 標籤)]) |
下拉選項。存進資料庫的是左邊的值(英文),右邊是畫面上的標籤 |
is_overdue(非儲存的計算欄位) |
依「今天」變動的值不適合存進資料庫,所以不用 store=True |
@api.constrains |
Python 驗證。寫入後觸發,raise ValidationError 就會回滾這次寫入 |
_("...%s", 參數) |
可翻譯字串。用逗號傳參數,不要自己用 % 拼,翻譯才抓得到 |
@api.model_create_multi |
覆寫 create,簽名固定是 vals_list(多筆)。覆寫後一定要呼叫 super().create(...) |
action_* 方法 |
給按鈕呼叫。self 可能是多筆記錄,所以用 write 一次寫入 |
「同一本書不能同時借給兩人」用 @api.constrains("book_id", "state") 實作:只要有借閱單被設為借出中,就檢查是否已有別張借出中的單。把商業規則放在模型,而不是放在畫面,這樣不管從網頁、匯入或外部程式寫入,規則都成立。
為什麼借出日是 Date,而不是 Datetime?
借書只需要到「哪一天」。Date 沒有時區問題,Datetime 在資料庫存 UTC,顯示時才轉換,比較容易出錯。需要精確到時間再用 Datetime。
步驟 4:流水號
create 已經呼叫了 next_by_code("library.loan"),現在定義這個序號:
<odoo noupdate="1">
<record id="seq_library_loan" model="ir.sequence">
<field name="name">Library Loan</field>
<field name="code">library.loan</field>
<field name="prefix">LN/%(year)s/</field>
<field name="padding">4</field>
</record>
</odoo>
單號會長得像 LN/2026/0001。
noupdate="1" 很重要:它表示「安裝後這些記錄就屬於資料庫,升級模組時不要再覆蓋」。序號的目前數字存在這筆記錄裡,若沒有 noupdate,每次 -u 都可能把流水號重置。
步驟 5:群組與權限
分兩層:群組決定「你是誰」,存取權(access)決定「這群人能對哪個模型做什麼」。
<odoo>
<record id="module_category_library" model="ir.module.category">
<field name="name">Library</field>
<field name="sequence">50</field>
</record>
<record id="group_library_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_library_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_library_book_user,library.book user,model_library_book,group_library_user,1,1,1,0
access_library_book_manager,library.book manager,model_library_book,group_library_manager,1,1,1,1
access_library_loan_user,library.loan user,model_library_loan,group_library_user,1,1,1,0
access_library_loan_manager,library.loan manager,model_library_loan,group_library_manager,1,1,1,1
| 概念 | 說明 |
|---|---|
ir.module.category |
在使用者設定頁把同一個模組的群組歸成一區 |
implied_ids |
「包含」關係。管理員(Manager)自動包含使用者(User),使用者又包含內部使用者 base.group_user |
(4, id) |
「加入既有記錄」的指令,用在 Many2many 欄位 |
users |
直接把 admin 放進管理員群組,安裝後馬上能用 |
| CSV 每一列 | 群組、模型、讀/寫/建立/刪除四個權限(1 或 0)。模型的 xml id 是 model_ 加上 _name 的點換成底線 |
我們的設計:使用者可以讀、寫、建立,但不能刪除;只有管理員能刪除。
沒有存取權,就沒有人能用
模型沒有任何 CSV 列時,除了系統內部的超級使用者,所有人都無法存取它,日誌也會警告:The models ['library.book', 'library.loan'] have no access rules in module library, consider adding some。看到這行,就是你忘了寫 CSV,或忘了把 CSV 放進 manifest 的 data。
還有更細的「記錄規則」(ir.rule),可以限定「只能看自己的記錄」或「只能看自己公司的記錄」,本頁不展開,見 官方文件:安全性。
步驟 6:檢視
書籍
<odoo>
<record id="view_library_book_list" model="ir.ui.view">
<field name="name">library.book.list</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="author"/>
<field name="isbn"/>
<field name="is_available" widget="boolean"/>
</list>
</field>
</record>
<record id="view_library_book_form" model="ir.ui.view">
<field name="name">library.book.form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="書名"/></h1>
</div>
<group>
<group>
<field name="author"/>
<field name="isbn"/>
</group>
<group>
<field name="is_available"/>
</group>
</group>
<notebook>
<page string="借閱紀錄" name="loans">
<field name="loan_ids" readonly="1">
<list>
<field name="name"/>
<field name="borrower_id"/>
<field name="date_borrowed"/>
<field name="state"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_library_book_search" model="ir.ui.view">
<field name="name">library.book.search</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<search>
<field name="name" string="書名/作者/ISBN"
filter_domain="['|', '|', ('name', 'ilike', self), ('author', 'ilike', self), ('isbn', 'ilike', self)]"/>
<filter name="available" string="可借" domain="[('is_available', '=', True)]"/>
<filter name="lent" string="已借出" domain="[('is_available', '=', False)]"/>
<separator/>
<filter name="archived" string="已封存" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="action_library_book" model="ir.actions.act_window">
<field name="name">書籍</field>
<field name="res_model">library.book</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">登記第一本書</p>
</field>
</record>
</odoo>
一個模型通常會寫四種記錄:列表、表單、搜尋、動作(action,決定選單點下去開哪個模型與哪些檢視)。
- 搜尋檢視的
filter_domain讓搜尋框輸入一次就同時比對書名、作者與 ISBN,'|'是「或」,寫在條件前面(波蘭表示法),三個條件要兩個'|'。 - 表單裡的
<group>會自動排成兩欄,欄位左側顯示標籤。
借閱單
<odoo>
<record id="view_library_loan_list" model="ir.ui.view">
<field name="name">library.loan.list</field>
<field name="model">library.loan</field>
<field name="arch" type="xml">
<list decoration-danger="is_overdue" decoration-muted="state == 'returned'">
<field name="name"/>
<field name="book_id"/>
<field name="borrower_id"/>
<field name="date_borrowed"/>
<field name="date_due"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-warning="state == 'borrowed'"
decoration-success="state == 'returned'"/>
<field name="is_overdue" column_invisible="1"/>
</list>
</field>
</record>
<record id="view_library_loan_form" model="ir.ui.view">
<field name="name">library.loan.form</field>
<field name="model">library.loan</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_borrow" string="借出" type="object"
class="btn-primary" invisible="state != 'draft'"/>
<button name="action_return" string="歸還" type="object"
class="btn-primary" invisible="state != 'borrowed'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,borrowed,returned"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="book_id" readonly="state != 'draft'"/>
<field name="borrower_id" readonly="state != 'draft'"/>
</group>
<group>
<field name="date_borrowed" readonly="state != 'draft'"/>
<field name="loan_days" readonly="state != 'draft'"/>
<field name="date_due"/>
<field name="date_returned" invisible="state != 'returned'"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_library_loan_search" model="ir.ui.view">
<field name="name">library.loan.search</field>
<field name="model">library.loan</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="book_id"/>
<field name="borrower_id"/>
<filter name="borrowed" string="借出中" domain="[('state', '=', 'borrowed')]"/>
<filter name="overdue" string="逾期"
domain="[('state', '=', 'borrowed'), ('date_due', '<', context_today().strftime('%Y-%m-%d'))]"/>
<group expand="0" string="分組">
<filter name="group_borrower" string="借閱人" context="{'group_by': 'borrower_id'}"/>
<filter name="group_book" string="書籍" context="{'group_by': 'book_id'}"/>
<filter name="group_state" string="狀態" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_library_loan" model="ir.actions.act_window">
<field name="name">借閱紀錄</field>
<field name="res_model">library.loan</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_borrowed': 1}</field>
</record>
</odoo>
這頁有幾個 Odoo 17 之後才有的寫法,網路上舊教學常寫成過時的形式:
| 想做的事 | Odoo 18 寫法 | 舊寫法(新版不再使用) |
|---|---|---|
| 依狀態隱藏按鈕或欄位 | invisible="state != 'draft'" |
attrs="{'invisible': [('state', '!=', 'draft')]}" |
| 依狀態設唯讀 | readonly="state != 'draft'" |
attrs="{'readonly': ...}"、states="draft" |
| 列表中隱藏欄位但保留資料 | column_invisible="1" |
invisible="1" |
| 列表標籤 | <list> |
<tree> |
其他細節:
decoration-*:依條件替列表的列或欄位上色。用到的欄位(is_overdue)必須出現在檢視裡,所以我們加了一個隱藏欄位。statusbar:表單上方的狀態列。header裡的<button type="object">會呼叫模型的同名方法。- XML 特殊字元:
<在 XML 要寫成<,否則檢視會解析失敗。 context_today():搜尋篩選的 domain 內可以用,代表使用者今天。search_default_borrowed:在動作的 context 加search_default_篩選名: 1,開啟時就預先套用該篩選。
步驟 7:選單
<odoo>
<menuitem id="menu_library_root" name="圖書借閱"
groups="group_library_user" sequence="50"/>
<menuitem id="menu_library_book" name="書籍"
parent="menu_library_root" action="action_library_book" sequence="10"/>
<menuitem id="menu_library_loan" name="借閱紀錄"
parent="menu_library_root" action="action_library_loan" sequence="20"/>
</odoo>
沒有 parent 的是頂層選單(畫面左上角的應用程式圖示);有 parent 的是它底下的項目。groups 讓不在該群組的人看不到這個選單。
應用程式圖示
想要專屬圖示,放一張 static/description/icon.png 到模組目錄,Odoo 會自動使用。沒有時會用預設圖示。
步驟 8:示範資料
<odoo>
<record id="partner_demo_reader" model="res.partner">
<field name="name">王小明(示範讀者)</field>
</record>
<record id="book_demo_odoo" model="library.book">
<field name="name">Odoo 開發入門</field>
<field name="author">唯客學院</field>
<field name="isbn">978-0-00-000001-0</field>
</record>
<record id="book_demo_python" model="library.book">
<field name="name">Python 學習手冊</field>
<field name="author">Mark Lutz</field>
<field name="isbn">978-0-00-000002-7</field>
</record>
<record id="loan_demo_1" model="library.loan">
<field name="book_id" ref="book_demo_odoo"/>
<field name="borrower_id" ref="partner_demo_reader"/>
</record>
<function model="library.loan" name="action_borrow" eval="[ref('loan_demo_1')]"/>
</odoo>
ref="..."用 xml id 指向另一筆記錄。<function>在載入時呼叫模型方法,這裡把示範借閱單直接「借出」,讓畫面一打開就有資料。- 示範資料裡的聯絡人自己建立,不依賴
base的示範資料,這樣用--without-demo=all的資料庫也不會壞。
步驟 9:安裝與試用
把模組所在資料夾放進 --addons-path,然後安裝:
# 原始碼安裝
./odoo-bin -d mydb --addons-path=addons,odoo/addons,~/my_addons -i library --stop-after-init
# Docker
docker compose run --rm odoo odoo -d mydb -i library --stop-after-init
日誌逐一出現 loading library/security/...、loading library/views/...,沒有 ERROR 或 WARNING 就是成功。接著啟動伺服器,用 admin 登入:
- 主選單多了「圖書借閱」。
- 開「書籍」,看示範資料,新增一本書。
- 開「借閱紀錄」,新增一張,選書與借閱人,按「借出」,狀態列跳到「借出中」,應還日自動算出。
- 再建一張借同一本書的單,按「借出」,會看到
《…》已經借出,還沒歸還。 - 按「歸還」,書恢復「可借」。
- 到 設定 → 使用者,把使用者加入 Library 的「User」群組,再用該帳號登入,確認看得到選單,但刪不掉記錄。
改程式後的節奏
改 Python 方法:重啟。改欄位或 XML:-u library。開發時用 --dev=all 可省下大部分重啟與升級,見 odoo-bin 指令。
出錯時,測試與除錯 整理了真實的錯誤訊息與處理方式。
練習
- 在
library.book加一個Selection欄位「類別」(小說、工具書、雜誌),並加入列表、表單與搜尋分組 - 把借期預設天數從 14 改成 7,並確認應還日跟著變
- 在借閱單加一個
Text欄位「備註」 - 新增一條記錄規則(
ir.rule):一般使用者只能看到自己建立的借閱單,管理員看得到全部