繼承與擴充
Odoo 客製最重要的觀念:不要改 Odoo 原始碼,也不要複製標準模組來改。改了原始碼,下次升級就全部被覆蓋。正確做法是用自己的模組去「繼承」標準的模型與檢視,在旁邊疊加功能。
本頁延續 實作:圖書借閱模組,替標準的聯絡人(res.partner)加上:借書證號欄位、借閱數的智慧按鈕(smart button)、有未歸還書籍時禁止刪除。這三件事剛好對應繼承的三種手法。
三種繼承手法
| 想做的事 | 手法 | 放在哪 |
|---|---|---|
| 幫既有模型加欄位、加方法 | 模型繼承:_inherit = "res.partner",不寫 _name |
models/*.py |
| 修改既有檢視(加欄位、加按鈕、隱藏) | 檢視繼承:inherit_id + xpath |
views/*.xml |
| 改變既有方法的行為 | 覆寫方法,並呼叫 super() |
models/*.py |
還有兩種進階用法,這裡只需要知道存在:
- Mixin:
_inherit = ["mail.thread"]一次獲得聊天室與追蹤功能(模組要加depends: ["mail"])。 _inherits委派:新模型「內含」另一個模型,例如員工內含聯絡人。
模型繼承:擴充聯絡人
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class ResPartner(models.Model):
_inherit = "res.partner"
loan_ids = fields.One2many("library.loan", "borrower_id", string="借閱紀錄")
library_card = fields.Char(string="借書證號")
loan_count = fields.Integer(string="借閱數", compute="_compute_loan_count")
@api.depends("loan_ids")
def _compute_loan_count(self):
groups = self.env["library.loan"]._read_group(
[("borrower_id", "in", self.ids)],
groupby=["borrower_id"],
aggregates=["__count"],
)
counts = {borrower.id: count for borrower, count in groups}
for partner in self:
partner.loan_count = counts.get(partner.id, 0)
def action_view_loans(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": _("借閱紀錄"),
"res_model": "library.loan",
"view_mode": "list,form",
"domain": [("borrower_id", "=", self.id)],
"context": {"default_borrower_id": self.id},
}
def unlink(self):
active_loans = self.env["library.loan"].search_count([
("borrower_id", "in", self.ids),
("state", "=", "borrowed"),
])
if active_loans:
raise UserError(_("尚有未歸還的書,不能刪除這位聯絡人。"))
return super().unlink()
記得在 models/__init__.py 加一行 from . import res_partner,並在 manifest 的 data 末尾加 "views/res_partner_views.xml"(下一節建立)。
逐項說明:
- 只有
_inherit、沒有_name:表示「不建立新模型,而是把這些內容加到既有的res.partner」。library_card會變成res_partner資料表的新欄位,升級模組(-u library)時才建立。 _read_group:Odoo 18 的分組查詢,回傳(記錄, 筆數)的列表。這裡一次查出所有聯絡人的借閱數,比在迴圈裡對每位聯絡人各查一次快得多,這是避免 N+1 查詢的標準做法。action_view_loans:按鈕呼叫的方法,回傳一個動作(action)字典,Odoo 就會開啟對應的視窗。domain篩出這位聯絡人的借閱單,context的default_borrower_id讓在這個畫面新增時自動帶入借閱人。unlink(覆寫):先檢查,符合條件就raise UserError(給使用者看的錯誤,會以對話框顯示),否則return super().unlink(),把工作交還給原本的實作。
覆寫方法一定要呼叫 super(),並回傳它的結果
忘了 super(),等於把標準行為整個吃掉;其他也繼承這個方法的模組也會跟著壞。除非你確定要完全取代,否則永遠是「先做自己的事,再 return super()...」或「先 super(),再做自己的事」。
踩雷實錄:計算欄位為什麼沒更新
這個模組的測試一開始沒寫 @api.depends("loan_ids"),結果跑出:
FAIL: TestLoan.test_partner_loan_count_and_unlink
self.assertEqual(self.partner.loan_count, 1)
AssertionError: 0 != 1
原因是:loan_count 第一次讀取得到 0,Odoo 把結果放進快取;沒有 depends,Odoo 不知道新增借閱單會影響它,所以之後一直拿到快取的 0。加上 @api.depends("loan_ids") 後就正確了。
規則:每個計算欄位都要寫 @api.depends,列出它讀取的所有欄位。 讀到關聯欄位的子欄位時用點號,如 @api.depends("loan_ids.state")。
另外,非儲存的計算欄位不能拿來搜尋:
env["res.partner"].search([("loan_count", ">", 0)])
# ERROR: Non-stored field res.partner.loan_count cannot be searched.
要能搜尋,可以改成 store=True(並確保 depends 完整),或提供 search= 方法。
檢視繼承:加按鈕與欄位
<odoo>
<record id="view_partner_form_inherit_library" model="ir.ui.view">
<field name="name">res.partner.form.inherit.library</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button name="action_view_loans" type="object"
class="oe_stat_button" icon="fa-book">
<field name="loan_count" widget="statinfo" string="借閱"/>
</button>
</div>
<xpath expr="//field[@name='phone']" position="after">
<field name="library_card"/>
</xpath>
</field>
</record>
</odoo>
inherit_id 指向要修改的原檢視(base.view_partner_form 是「模組.xml id」),arch 裡只寫「要改哪裡、怎麼改」,不需要複製整個檢視。
定位方式有兩種寫法:
- 簡寫:直接用原檢視裡的元素當定位點,並帶上
name屬性與position。例如<div name="button_box" position="inside">。 xpath:用 XPath 運算式精準定位,例如//field[@name='phone']是「name 為 phone 的 field」。
position 的可用值:
| position | 作用 |
|---|---|
inside |
放進該元素內部的最後面 |
after |
放在該元素後面(同層) |
before |
放在該元素前面 |
replace |
取代該元素(拿掉就寫空的) |
attributes |
修改該元素的屬性 |
改屬性的寫法,例如把電話設成必填:
<xpath expr="//field[@name='phone']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
怎麼知道原檢視的 xml id 與結構
先啟用開發者模式(網址加 ?debug=1),畫面上方會出現蟲形圖示,選單裡有編輯目前檢視、檢視中繼資料(含 xml id)等項目。也可以直接到原始碼搜尋,例如聯絡人表單在 odoo/addons/base/views/res_partner_views.xml。
升級並驗證
用 shell 快速驗證,不必開網頁:
>>> p = env["res.partner"].search([("name", "like", "王小明")])
>>> p.loan_count
1
>>> p.unlink()
odoo.exceptions.UserError: 尚有未歸還的書,不能刪除這位聯絡人。
網頁上打開該聯絡人,會看到電話下方多了「借書證號」,右上角多了「借閱」智慧按鈕,點下去進入這位聯絡人的借閱單清單。
繼承常見問題
| 現象 | 原因 |
|---|---|
| 升級後畫面沒有新欄位 | 檢視檔沒列進 manifest 的 data,或欄位改了但沒 -u |
ParseError ... Field "xxx" does not exist in model |
檢視引用的欄位不存在。欄位名稱打錯,或定義該欄位的檔案沒被 models/__init__.py 匯入 |
External ID not found in the system |
引用了還沒載入的 xml id。檢查 manifest data 順序,以及 depends 有沒有包含定義它的模組 |
繼承了 sale.order 卻找不到它 |
manifest 的 depends 沒加 sale。你繼承誰,就要依賴誰 |
| 覆寫的方法沒被呼叫 | 該方法在別處被以 SQL 或其他方式繞過,或父類別的簽名不同。先用 _logger 或 shell 確認 |
練習
- 在聯絡人表單「借書證號」欄位旁,顯示借閱中的書本數(提示:多寫一個計算欄位,
state == "borrowed") - 覆寫
library.loan.action_return:歸還時若已逾期,用_logger.info記錄一筆日誌(記得super()) - 仿照聯絡人的作法,替書籍表單也加一個「借閱數」智慧按鈕(提示:
library.book是自己的模型,直接改它的表單檢視即可,不需要_inherit)
下一步
測試與除錯:把上面驗證過的行為寫成自動化測試,並整理常見錯誤訊息。