Structuring an Odoo Monorepo with Multiple Addon Modules

The first time I tried to split a working Odoo addon into two modules, the server booted, the menu appeared, and then every access rule silently vanished. No traceback. No warning. Just a form view that let a portal user delete records they should never have seen. I spent an afternoon staring at ir.model.access.csv before I realised the second module referenced a security group defined in the first, and Odoo had loaded the CSV before the group existed. That was the moment I stopped treating "multiple addons in one repo" as a filesystem detail and started treating it as an architecture decision.
This article walks through the layout I now reach for on every Odoo 19 project that will outgrow a single addon: a docker-compose runtime you can trash and rebuild in seconds, a base addon that owns shared models and security groups, and a downstream addon that declares its dependency in __manifest__.py and inherits rather than duplicates. By the end you will have a repo you can clone, a docker compose up that gives you a fresh Odoo 19 instance backed by PostgreSQL, two addons installed in the right order, model inheritance flowing across module boundaries, and per-addon security that survives an upgrade with -u. The one rule worth screenshotting: manifest depends is not documentation, it is the load order your security rules, views, and inheritance chains are silently gambling on.
If you have shipped a single Odoo addon and are staring at the second one wondering where the code should live, this is written for you. By the last step you should be comfortable bootstrapping a fresh monorepo, wiring two addons that talk to each other through inheritance and manifest dependencies, and running the install-upgrade-test loop locally without waiting on a shared staging server.
Step 1: Khởi tạo bộ khung monorepo Odoo 19 với hai luồng addon và runtime Docker Compose
Trước khi viết bất kỳ dòng model hay view nào cho Odoo, chúng ta cần một bộ khung dự án đủ chặt để chứa nhiều addon phát triển song song mà không giẫm lên nhau. Bước một sẽ dựng đúng bộ khung đó: một thư mục addons/ cho code do chúng ta viết, một thư mục vendor/ cho các module bên thứ ba lấy từ OCA hay marketplace, một file docker-compose.yml chạy Odoo 19 cùng PostgreSQL 16, và một odoo.conf khai báo addons_path gộp cả hai luồng lại.
Cách tách bạch này nghe có vẻ nhỏ nhặt, nhưng chính nó quyết định trải nghiệm code cho cả vòng đời monorepo về sau. Khi addons/ và vendor/ được mount vào hai đường dẫn tách rời trong container, chúng ta có thể pin phiên bản vendor riêng, dựng gitignore riêng, thậm chí review PR theo hai chính sách khác nhau, mà runtime vẫn thấy chúng như một danh sách addon phẳng duy nhất.
Setup
Bộ khung này gồm bảy file mới ở cấp gốc kho code. Chúng ta tạo docker-compose.yml để mô tả stack chạy, config/odoo.conf để cấu hình runtime bên trong container, .env.example để tài liệu hoá biến môi trường bắt buộc, pyproject.toml để pin phụ thuộc test, cùng tests/conftest.py và tests/test_bootstrap.py để bảo vệ hình dạng cấu hình. Hai thư mục addons/ và vendor/ được giữ lại bằng file .gitkeep rỗng — nếu không, git sẽ bỏ chúng và runtime sẽ mount vào chỗ không tồn tại.
Phụ thuộc phía Python duy nhất là pytest và pyyaml, cả hai đều nằm trong nhóm dev của pyproject.toml. Chúng ta không cài SDK Odoo hay ORM bên máy host, vì mọi thứ chạy trong container — bên host chỉ cần đủ công cụ để đọc file YAML và INI rồi so khớp với các cam kết mà bài viết đã hứa.
Implementation
File docker-compose.yml là nơi ràng buộc phiên bản. Chúng ta ghim postgres:16 cho service db, odoo:19.0 cho service odoo, và bắt Odoo phải chờ Postgres báo healthy mới được khởi động. Nhờ vậy lần chạy đầu tiên không bị lỗi kết nối do database chưa sẵn sàng, và mỗi lần docker compose up -d đều có cùng thứ tự khởi động ổn định.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER:-odoo}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-odoo}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-odoo}"]
interval: 5s
retries: 10
odoo:
image: odoo:19.0
depends_on:
db:
condition: service_healthy
ports:
- "${ODOO_PORT:-8069}:8069"
volumes:
- ./config/odoo.conf:/etc/odoo/odoo.conf:ro
- ./addons:/mnt/custom-addons:rw
- ./vendor:/mnt/vendor-addons:rw
Điểm đáng chú ý nằm ở ba dòng volumes cuối. Chúng ta bind mount addons/ vào /mnt/custom-addons và vendor/ vào /mnt/vendor-addons, giữ hai luồng tách biệt hoàn toàn ở lớp filesystem. File odoo.conf được mount :ro để Odoo không thể tự ghi đè cấu hình từ giao diện admin, còn hai luồng addon được mount :rw để bạn vẫn có thể tạo module mới ngay trong container khi cần scaffolding nhanh.
Tiếp theo, config/odoo.conf khai báo addons_path khớp chính xác với hai điểm mount phía trên. Thứ tự các mục trong addons_path cũng chính là thứ tự ưu tiên khi Odoo dò tìm module — code do chúng ta viết được đặt trước để có thể ghi đè hành vi vendor khi cần thiết.
[options]
addons_path = /mnt/custom-addons,/mnt/vendor-addons
data_dir = /var/lib/odoo
db_host = db
http_port = 8069
Cuối cùng, tests/test_bootstrap.py biến những cam kết ở trên thành khẳng định tự động. Chúng ta không khởi động container trong test — chỉ đọc docker-compose.yml và odoo.conf như cấu hình tĩnh rồi kiểm tra: image được pin đúng phiên bản, depends_on yêu cầu database healthy, ba volume mount xuất hiện đầy đủ, và addons_path khai báo lại đúng hai điểm mount đó. Bộ test còn kiểm tra .env.example liệt kê đủ biến bắt buộc và hai thư mục addons/, vendor/ giữ được .gitkeep.
def test_compose_mounts_addons_and_vendor_and_config(compose_config):
volumes = compose_config["services"]["odoo"]["volumes"]
joined = "\n".join(volumes)
assert "./addons:/mnt/custom-addons" in joined
assert "./vendor:/mnt/vendor-addons" in joined
assert "./config/odoo.conf:/etc/odoo/odoo.conf" in joined
Cách viết test này rất rẻ để chạy nhưng rất đắt nếu bỏ qua: bất kỳ ai đổi tên thư mục, đổi cổng, hay đổi phiên bản Odoo mà chưa cập nhật cấu hình đều sẽ khiến pytest đỏ ngay trước khi commit được đẩy lên.
Verification
uv run pytest
......... [100%]
9 passed in 0.29s
Chín test xanh xác nhận bộ khung đứng đúng hình dạng đã hứa: hai image được ghim, mount và addons_path khớp nhau, .env.example tài liệu hoá đủ biến, và hai thư mục addon rỗng vẫn tồn tại trong git sau mỗi lần clone.
What we built
Sau bước một, chúng ta có một kho code có thể docker compose up -d để bật Odoo 19 tại http://localhost:8069 mà không cần cài Python hay PostgreSQL trên máy host. Đây là điều kiện tiên quyết để mọi bước tiếp theo trong tutorial có thể tái lập: bạn chỉ cần Docker và uv, còn lại phiên bản Odoo, phiên bản Postgres, và đường dẫn mount đã được ghim cứng trong repo.
Chúng ta cũng đã đặt ra ranh giới addons/ và vendor/ ngay từ đầu, thay vì gộp mọi thứ vào một thư mục custom_addons như nhiều dự án Odoo cũ vẫn làm. Ranh giới đó sẽ được các bước sau tận dụng: mỗi khi thêm một OCA module, chúng ta bỏ nó vào vendor/; mỗi khi tự viết một addon mới, nó nằm trong addons/. Hai luồng có gitignore riêng, chính sách review riêng, nhưng Odoo vẫn thấy chúng như một danh sách phẳng.
Cuối cùng, bộ test tĩnh trở thành lá chắn cho phần còn lại của bài viết. Nếu ai đó vô tình đổi tên đường dẫn mount, đổi cổng HTTP, hay bỏ quên .gitkeep, pytest sẽ báo lỗi trước khi lỗi kịp tràn sang các bước tiếp theo — nơi chúng ta bắt đầu sinh addon thật và cần một runtime đã được xác thực.
Repository
The state of the code after this step: 1da5b0c
Step 2: Dựng addon library_core đầu tiên với manifest Odoo 19 và model gốc library.book
Sau bước một, chúng ta đã có bộ khung monorepo với hai thư mục addons/ và vendor/ được mount vào Odoo 19, nhưng cả hai đều còn rỗng ngoài .gitkeep. Bước hai đặt viên gạch thực sự đầu tiên vào addons/: một module tên library_core với đúng bốn thứ tối thiểu mà Odoo cần để nhận diện một addon — một Python package, một file __manifest__.py khai báo danh tính, một sub-package models, và một class ORM đầu tiên kế thừa models.Model.
Chúng ta cố ý giữ addon này thật gọn: không views, không security rules, không dữ liệu demo. Lý do là các bước phía sau (addon thứ hai, model inheritance, security groups, views) sẽ đều bám vào chính library.book làm điểm neo, nên nếu neo được phủ thêm quá nhiều lớp ngay từ bước hai thì các step tiếp theo sẽ khó demo được đúng một khía cạnh một lần. Một model "trần" với vài trường cơ bản là mặt phẳng lý tưởng để phần còn lại của tutorial vẽ lên.
Setup
Bốn file mới sẽ được thêm vào dưới addons/library_core/: __init__.py ở gốc addon để biến thư mục thành package, __manifest__.py để Odoo đọc metadata, models/__init__.py để gom các file model, và models/library_book.py chứa class ORM đầu tiên. Không có phụ thuộc Python mới nào ở tầng host — mọi thứ liên quan tới odoo chỉ được import khi runtime chạy trong container, còn phía test chúng ta chỉ dùng ast và pathlib của stdlib.
Về mặt kiểm thử, chúng ta thêm một file mới tests/test_library_core_addon.py để đóng dấu cấu trúc addon ngay ở tầng file. Bộ test này không khởi động Odoo (Odoo sống trong Docker); nó chỉ đọc __manifest__.py như một Python literal và parse library_book.py thành AST rồi soi các thuộc tính class. Cách tiếp cận này chạy trong dưới một giây, không cần database, và bắt được đúng những lỗi cấu trúc mà người viết addon dễ mắc phải.
Implementation
File __manifest__.py chính là hộ chiếu của module trước Odoo. Ở đây chúng ta khai báo tên hiển thị, mô tả ngắn, phiên bản gắn với dòng 19.0 (theo quy ước <odoo-version>.<addon-major>.<addon-minor>.<addon-patch> của cộng đồng), giấy phép LGPL-3, và quan trọng nhất là danh sách depends. Với addon đầu tiên, chúng ta chỉ phụ thuộc base — đây là module lõi luôn được cài trong mọi database Odoo, cung cấp res.partner, res.users, ORM và toàn bộ hạ tầng nền.
{
"name": "Library Core",
"summary": "Foundational catalog model shared across the library monorepo demo.",
"description": (
"First addon in the monorepo tutorial. Declares the library.book model "
"that later addons extend or depend on."
),
"version": "19.0.1.0.0",
"license": "LGPL-3",
"author": "vytharion",
"website": "https://github.com/vytharion/odoo-monorepo-multi-addon-modules",
"category": "Services/Library",
"depends": ["base"],
"data": [],
"installable": True,
"application": False,
"auto_install": False,
}
Chú ý ba cờ ở cuối: installable=True cho phép người dùng cài từ menu Apps, application=False báo cho Odoo hiểu đây là module hạ tầng chứ không phải một app đầu vào trong menu chính, còn auto_install=False giữ cho module không tự bật khi các dependency của nó xuất hiện. Ba cờ này rất dễ để mặc định "sai" trong lúc scaffolding vội, và bộ test ở phần dưới sẽ chốt cứng cả ba.
Hai file __init__.py chỉ có duy nhất một nhiệm vụ: nối package con models vào addon rồi nối file library_book vào package models. Ở Odoo, nếu bạn quên bước import này thì class model sẽ không bao giờ được đăng ký với registry, và module cài xong nhưng không có gì trong ORM — một lỗi im lặng rất khó chẩn đoán.
# addons/library_core/__init__.py
from . import models
# addons/library_core/models/__init__.py
from . import library_book
Model library.book được viết ở dạng nhỏ nhất có nghĩa. Chúng ta khai báo _name (khoá kỹ thuật mà toàn bộ Odoo dùng để định danh), _description (hiện lên trong bảng chọn model), _order để có thứ tự mặc định ổn định, cùng năm trường: name (tiêu đề sách, bắt buộc), isbn, author_name, year_published, và active — trường boolean chuẩn để hỗ trợ archiving mà Odoo mong đợi ở gần như mọi model kinh doanh.
from odoo import fields, models
class LibraryBook(models.Model):
_name = "library.book"
_description = "Library Book"
_order = "name"
name = fields.Char(string="Title", required=True, index=True)
isbn = fields.Char(string="ISBN", index=True)
author_name = fields.Char(string="Author")
year_published = fields.Integer(string="Year Published")
active = fields.Boolean(default=True)
Việc đặt index=True cho name và isbn không phải trang trí: các bước sau sẽ tra cứu sách theo tên và ISBN từ những addon khác, và một index đúng chỗ ngay từ đầu rẻ hơn nhiều so với việc phát hiện query chậm ở bước bảy rồi phải viết migration.
Cuối cùng, bộ test tĩnh trong tests/test_library_core_addon.py biến tất cả cam kết bên trên thành khẳng định máy đọc được. Chúng ta parse manifest bằng ast.literal_eval để chặn chuyện lén nhét code chạy được vào một file lẽ ra chỉ chứa dictionary, và parse library_book.py bằng ast.parse để đi qua từng class định nghĩa, tìm các gán _name, _description, và các base class.
def test_manifest_targets_odoo_19_series(manifest: dict) -> None:
version = manifest["version"]
assert version.startswith("19."), f"expected 19.x version, got {version!r}"
def test_base_model_declares_expected_name(model_module: ast.Module) -> None:
class_names = _class_underscore_values(model_module, "_name")
assert MODEL_NAME in class_names, (
f"expected a class with _name == {MODEL_NAME!r}; found {class_names}"
)
Hai test tiêu biểu ở trên bảo vệ hai bất biến quan trọng nhất của bước này: manifest phải nhắm đúng dòng Odoo 19 (không lẫn phiên bản 17 hay 18 do copy-paste từ dự án cũ), và tồn tại đúng một class có _name == "library.book" để các addon sau có thể _inherit từ đó mà không bị lệch chính tả.
Verification
uv run pytest
.................... [100%]
20 passed in 0.25s
Hai mươi test xanh — chín test cũ từ bước một cộng thêm mười một test mới của bước hai — xác nhận cấu trúc addon đúng như đã hứa: bốn file được sinh ra ở đúng vị trí, manifest khai báo đủ các khoá bắt buộc và ghim version vào dòng 19.x, depends chứa base, module có installable=True, giấy phép là LGPL-3, hai file __init__.py nối đúng chuỗi import, và class LibraryBook kế thừa models.Model với _name == "library.book" cùng một _description không rỗng.
What we built
Sau bước hai, addons/library_core/ đã là một Odoo addon hoàn chỉnh về mặt cấu trúc: cài được vào một database mới bằng lệnh odoo -i library_core, xuất hiện trong menu Apps, và đăng ký được model library.book vào registry với năm trường sẵn dùng. Đây là lần đầu tiên trong tutorial chúng ta có một model do chính mình định nghĩa chạy trong ORM của Odoo.
Model library.book được thiết kế có chủ đích ở mức tối thiểu để đóng vai trò điểm neo cho bốn bước tiếp theo. Bước ba sẽ tạo addon thứ hai depends vào library_core, chứng minh cách Odoo giải quyết dependency graph giữa các module. Bước bốn sẽ dùng _inherit để mở rộng chính class này từ một addon khác, còn bước năm và sáu sẽ gắn security rules và views quanh cùng model đó.
Bộ test tĩnh mới cũng khép kín một lớp bảo vệ mà một team Odoo trưởng thành thường chỉ dựng sau khi bị bug: mọi lần chỉnh manifest hay đổi _name model sẽ được pytest bắt trong dưới một giây, trước khi lỗi kịp lan sang container hay database. Với chi phí gần như bằng không, chúng ta khoá được đúng những điểm dễ vỡ nhất của một Odoo addon: phiên bản manifest, danh sách depends, ba cờ installable/application/auto_install, và chuỗi import nối models vào addon.
Ranh giới addons/ mà bước một đã dựng bắt đầu có nội dung để bảo vệ. Từ bước ba trở đi, mỗi addon mới đều sẽ nằm cạnh library_core dưới cùng thư mục addons/, và Odoo — nhờ addons_path đã cấu hình từ trước — sẽ thấy tất cả chúng như một danh sách phẳng duy nhất, giữ đúng lời hứa monorepo mà bài viết đặt ra ngay từ đầu.
Repository
The state of the code after this step: bb4883c
Step 3: Ghép addon library_loans vào library_core qua manifest depends và Many2one tới library.book
Sau bước hai, chúng ta đã có một addon library_core chạy được với model library.book làm điểm neo. Nhưng một monorepo chỉ thực sự có giá trị khi các addon bên trong biết cách gọi nhau — nếu mỗi module vẫn phải tự sinh lại model gốc thì tách monorepo hay không cũng vô nghĩa. Bước ba thêm addon thứ hai library_loans để chứng minh cơ chế ghép nối chuẩn của Odoo: khai báo phụ thuộc qua khoá depends trong __manifest__.py, rồi trỏ trường Many2one từ model của addon mới tới model của addon cũ.
Chúng ta cố ý chọn quan hệ "cho mượn sách" vì nó tự nhiên đòi hỏi một chiều phụ thuộc duy nhất: library.loan biết library.book, chứ library.book không cần biết đến khái niệm cho mượn. Nhờ đó bước này vừa dạy được cách khai báo dependency, vừa khoá chiều dependency ở cả tầng manifest lẫn tầng test — một điểm rất dễ bị đảo ngược ở các dự án Odoo trưởng thành khi có người "tiện tay" thêm library_loans vào depends của library_core.
Setup
Bốn file mới nằm dưới addons/library_loans/: __init__.py ở gốc addon, __manifest__.py khai báo depends = ["base", "library_core"], models/__init__.py gom các file model, và models/library_loan.py chứa class LibraryLoan. Không có phụ thuộc Python mới nào cần thêm vào pyproject.toml — mọi thứ vẫn tựa vào pytest và pyyaml đã có sẵn, còn odoo như thường lệ chỉ được import khi runtime chạy trong container.
Về mặt kiểm thử, chúng ta thêm tests/test_library_loans_addon.py với mười chín test tĩnh mới. Bộ test này đi theo đúng công thức của bước hai: đọc manifest bằng ast.literal_eval, parse library_loan.py bằng ast.parse, rồi khẳng định các bất biến của addon mới cùng chiều phụ thuộc giữa hai addon. Điểm khác biệt duy nhất là có thêm một fixture đọc manifest của library_core để chốt cứng rằng library_core không được phép quay đầu phụ thuộc library_loans.
Implementation
File __manifest__.py của library_loans gần giống hệt manifest ở bước hai, nhưng khoá depends là điểm phân biệt cốt lõi. Chúng ta liệt kê base trước rồi library_core sau — thứ tự này khớp với quy ước cộng đồng và cũng khớp với chiều "tổng quát trước, chuyên biệt sau" mà Odoo dùng khi giải quyết dependency graph tại thời điểm cài module.
{
"name": "Library Loans",
"summary": "Loan tracking layer built on top of the library_core catalog.",
"description": (
"Second addon in the monorepo tutorial. Adds the library.loan model "
"that references library.book from library_core via a Many2one, "
"demonstrating cross-addon dependency through the manifest."
),
"version": "19.0.1.0.0",
"license": "LGPL-3",
"author": "vytharion",
"website": "https://github.com/vytharion/odoo-monorepo-multi-addon-modules",
"category": "Services/Library",
"depends": ["base", "library_core"],
"data": [],
"installable": True,
"application": False,
"auto_install": False,
}
Nhờ dòng "depends": ["base", "library_core"], khi ai đó chạy odoo -i library_loans trong một database sạch, Odoo sẽ tự động cài library_core trước rồi mới cài library_loans. Nếu chúng ta quên khai báo library_core ở đây, việc gỡ library_core bằng menu Apps sẽ không kéo theo library_loans, và tệ hơn, Many2one phía dưới sẽ báo KeyError: 'library.book' ngay lúc đăng ký registry vì model gốc chưa được load.
Model library.loan là nơi dependency biểu hiện ra ở tầng ORM. Class kế thừa models.Model, đặt _name = "library.loan" để có khoá kỹ thuật riêng, và khai báo trường book_id là Many2one trỏ tới comodel library.book. Chính dòng comodel_name="library.book" này là điểm giao giữa hai addon: Odoo sẽ tra tên đó vào registry ở thời điểm cài, và nếu library_core chưa được load trước thì tra sẽ hỏng.
from odoo import fields, models
class LibraryLoan(models.Model):
_name = "library.loan"
_description = "Library Loan"
_order = "loan_date desc, id desc"
name = fields.Char(string="Reference", required=True, index=True, copy=False)
book_id = fields.Many2one(
comodel_name="library.book",
string="Book",
required=True,
ondelete="restrict",
index=True,
)
borrower_name = fields.Char(string="Borrower", required=True)
loan_date = fields.Date(string="Loan Date", required=True, default=fields.Date.context_today)
return_date = fields.Date(string="Return Date")
state = fields.Selection(
selection=[
("draft", "Draft"),
("borrowed", "Borrowed"),
("returned", "Returned"),
],
string="Status",
default="draft",
required=True,
tracking=True,
)
active = fields.Boolean(default=True)
Hai kwarg trên book_id đáng để nhấn mạnh. required=True phản ánh ngữ nghĩa: một phiếu mượn không gắn với sách nào là vô nghĩa, nên chúng ta chặn ngay ở tầng ORM chứ không chờ report báo lỗi. ondelete="restrict" cấm xoá một library.book khi vẫn còn library.loan tham chiếu tới nó — mặc định set null sẽ khiến lịch sử mượn trở thành mồ côi, còn cascade sẽ xoá luôn cả record library.loan cùng lịch sử, cả hai đều là hành vi nguy hiểm cho một hệ nghiệp vụ thực.
Trường state dùng Selection với ba giá trị draft, borrowed, returned để biểu diễn vòng đời phiếu mượn. Chúng ta chưa cài đặt các transition ở bước ba — mục tiêu bước này chỉ là dependency giữa hai addon — nhưng khoá cứng bộ giá trị hợp lệ ngay từ đầu giúp các bước sau có thể thêm nút Confirm, Return mà không cần migrate dữ liệu.
Cuối cùng, bộ test tĩnh tests/test_library_loans_addon.py biến mọi lời hứa ở trên thành khẳng định máy đọc được. Ngoài các bất biến tương tự bước hai (manifest phải nhắm dòng 19.x, module installable=True, auto_install=False, hai file __init__.py nối đúng chuỗi import), bước ba thêm hai lớp bảo vệ đặc trưng cho pattern cross-addon dependency.
def test_manifest_depends_on_library_core(manifest: dict) -> None:
depends = manifest["depends"]
assert DEPENDED_ADDON in depends, (
f"library_loans must declare {DEPENDED_ADDON!r} in its manifest depends; "
f"got {depends!r}"
)
def test_core_manifest_does_not_depend_on_loans(core_manifest: dict) -> None:
depends = core_manifest["depends"]
assert ADDON_NAME not in depends, (
"dependency direction must be library_loans -> library_core, never "
"the reverse; otherwise you get an import cycle at install time"
)
Cặp test này đóng vai trò như một hợp đồng hai chiều: một mặt chốt rằng library_loans phải khai báo library_core trong depends, mặt khác chốt rằng library_core tuyệt đối không được liệt kê library_loans trong depends của nó. Đây là lá chắn đơn giản nhất để chặn "reverse dependency" — kiểu lỗi mà Odoo chỉ báo ra tại thời điểm boot registry và thường được người mới sửa bằng cách thêm bừa dòng import.
Song song đó, hai test AST khác kiểm tra rằng library.loan có đúng một trường Many2one trỏ tới library.book với required=True và ondelete="restrict". Nếu ai đó sau này đổi comodel thành res.partner để "tiết kiệm" một model, hoặc bỏ ondelete cho gọn, pytest sẽ báo đỏ ngay trước khi commit rời máy.
Verification
uv run pytest
....................................... [100%]
39 passed in 1.32s
Ba mươi chín test xanh — hai mươi test cũ từ bước một và hai cộng thêm mười chín test mới của bước ba — xác nhận cấu trúc addon thứ hai đứng đúng vị trí, manifest depends liệt kê cả base lẫn library_core theo đúng thứ tự, library_core không quay đầu phụ thuộc library_loans, và library.loan có trường book_id là Many2one tới library.book với required=True cùng ondelete="restrict". Bộ test cũng chốt selection state phủ đủ ba giá trị vòng đời draft, borrowed, returned mà các bước sau sẽ dựa vào.
What we built
Sau bước ba, addons/ đã chứa hai addon Odoo hoàn chỉnh về mặt cấu trúc và ràng buộc lẫn nhau đúng cách. Chạy odoo -i library_loans trong một database sạch sẽ tự động cài library_core trước rồi tới library_loans, đăng ký cả library.book và library.loan vào registry, và mở đường cho các query kiểu env["library.loan"].search([]).mapped("book_id.name") — kết nối hai model do hai addon khác nhau định nghĩa.
Trường book_id = Many2one("library.book", ...) chính là bằng chứng cụ thể của lời hứa monorepo mà bước một đã đặt: hai addon nằm cạnh nhau trong addons/, mount vào cùng addons_path, và có thể tham chiếu tới nhau qua khoá kỹ thuật mà không cần bất kỳ import Python trực tiếp nào giữa hai package. Odoo tự giải quyết chiều phụ thuộc dựa hoàn toàn vào manifest depends, giúp các addon giữ được coupling thấp ở tầng code.
Bộ test tĩnh mới khép kín một lớp bảo vệ mà rất ít dự án Odoo cỡ trung có sẵn: chiều dependency giữa hai addon được kiểm tra tự động cả hai phía. Bất kỳ commit nào phá vỡ hợp đồng "library_loans phụ thuộc library_core, không được đảo ngược" sẽ khiến pytest đỏ trong dưới hai giây, trước khi bug kịp lan tới container hay database.
Với hai addon đã sẵn sàng, các bước sau có thể tập trung vào lớp bên trên: bước bốn sẽ dùng _inherit để mở rộng chính library.book từ một addon riêng, chứng minh model inheritance qua module boundary; bước năm sẽ gắn security groups và access rules quanh cả hai model; và bước sáu sẽ vẽ views để phần nghiệp vụ này lộ diện trong giao diện web. Điểm neo library.book do bước hai dựng và cây phụ thuộc do bước ba khoá cứng sẽ là bộ khung mà toàn bộ phần còn lại của tutorial bám vào.
Repository
The state of the code after this step: b1e7b05
Step 4: Chia sẻ nghiệp vụ giữa các addon bằng abstract mixin và _inherit cổ điển
Sau bước ba, hai addon library_core và library_loans đã ghép được vào nhau qua manifest depends và trường Many2one book_id. Nhưng đây mới chỉ là mức ghép nối "một chiều tham chiếu" — cả hai model vẫn tự khai báo trường active riêng, và library.book hoàn toàn không biết bất kỳ điều gì về library.loan. Trong đời sống Odoo thực, một addon downstream thường muốn đính kèm nghiệp vụ mới vào model do addon upstream sở hữu (thêm computed field, thêm helper method) mà không được phép sửa file gốc, còn addon upstream lại muốn xuất khẩu các hành vi dùng chung (archive, audit, tracking) dưới dạng thư viện tái sử dụng.
Bước bốn xử lý cả hai nhu cầu bằng đúng hai công cụ Odoo cung cấp sẵn: một AbstractModel đóng vai trò mixin nằm ở library_core, và một class _inherit = "library.book" nằm ở library_loans mở rộng model của addon khác. Đây là ranh giới rất dễ bị làm sai trong các dự án Odoo thực — nếu mixin nằm nhầm addon hoặc chiều mở rộng bị đảo, depends chain sẽ vỡ ngay khi cài đặt. Chúng ta khoá tất cả các bất biến này bằng mười chín test tĩnh mới để pytest phát hiện lỗi trước khi container Odoo kịp khởi động.
Setup
Có hai file model mới cần tạo. Trong library_core, thêm addons/library_core/models/library_mixins.py chứa class LibraryArchivableMixin kế thừa models.AbstractModel. Trong library_loans, thêm addons/library_loans/models/library_book.py chứa class LibraryBook với _inherit = "library.book" — không có _name, vì đây là bản mở rộng của model đã tồn tại chứ không phải model mới. Cập nhật hai file models/__init__.py để import các module mới, đặc biệt là ở library_core phải import library_mixins trước library_book để mixin _name kịp đăng ký vào registry trước khi library.book cố _inherit nó.
Song song, hai file model cũ bị chỉnh nhẹ: cả library_book.py lẫn library_loan.py xoá dòng active = fields.Boolean(default=True) cũ, thay bằng _inherit = ["library.archivable.mixin"] để trường active được kéo về từ mixin. Manifest của library_loans cũng cập nhật phần description để phản ánh vai trò mới — mở rộng library.book qua _inherit, không chỉ tham chiếu qua Many2one. Không có dependency Python mới nào cần thêm vào pyproject.toml; toàn bộ bước này vẫn dựa vào pytest cùng bộ AST helpers đã dùng ở bước hai và bước ba.
Bộ test mới nằm ở tests/test_inheritance_across_addons.py với mười chín case đọc AST của bốn file model rồi khẳng định các bất biến của cả hai nhánh inheritance. Nhờ tách thành file riêng, pytest chạy song song vẫn hermetic — không cần boot Odoo, không cần database, tất cả chỉ đọc source trên đĩa.
Implementation
Mixin library.archivable.mixin là lớp trừu tượng chứa các tài sản dùng chung. Kế thừa models.AbstractModel chứ không phải models.Model là điểm mấu chốt: Odoo sẽ không tạo bảng SQL cho abstract model, nó chỉ tồn tại trong registry như một "template" cho các model khác pull vào. Nhờ vậy nhiều model có thể _inherit mixin mà không sinh ra bảng thừa hay khoá ngoại trùng lặp.
from odoo import fields, models
class LibraryArchivableMixin(models.AbstractModel):
_name = "library.archivable.mixin"
_description = "Reusable archive / unarchive helpers for library records"
active = fields.Boolean(default=True, index=True)
archive_reason = fields.Char(string="Archive Reason")
def action_archive(self):
return self.write({"active": False})
def action_unarchive(self):
return self.write({"active": True, "archive_reason": False})
Mixin tập trung ba thứ: trường active (Odoo dùng để implement soft delete và filter mặc định active=True), trường phụ trợ archive_reason để lưu lý do archive, và hai helper action_archive/action_unarchive. Khi library.book và library.loan cùng _inherit mixin, cả hai lập tức có cùng hành vi archive — không cần sao chép code, cũng không cần hai bản active khác nhau lệch pha nhau sau vài tháng maintain.
Phía library.book, chúng ta chỉ thêm một dòng _inherit = ["library.archivable.mixin"] và xoá dòng active = fields.Boolean(default=True) cũ. Kết quả là class định nghĩa gọn hơn, và trường active giờ đây được sở hữu bởi mixin — mọi thay đổi hành vi archive (thêm log, thêm hook) chỉ cần sửa ở một chỗ và mọi consumer lập tức thừa hưởng.
class LibraryBook(models.Model):
_name = "library.book"
_description = "Library Book"
_inherit = ["library.archivable.mixin"]
_order = "name"
name = fields.Char(string="Title", required=True, index=True)
isbn = fields.Char(string="ISBN", index=True)
author_name = fields.Char(string="Author")
year_published = fields.Integer(string="Year Published")
Nhánh inheritance thứ hai — bản mở rộng library.book nằm trong library_loans — là phần thú vị nhất của bước bốn. File addons/library_loans/models/library_book.py khai báo lại một class cũng tên LibraryBook nhưng chỉ có _inherit = "library.book" và tuyệt đối không có _name. Đây là ký hiệu Odoo dùng để phân biệt "extend existing model" với "create new model": có _name mới nghĩa là model mới, chỉ có _inherit nghĩa là reopen model cũ.
from odoo import api, fields, models
class LibraryBook(models.Model):
_inherit = "library.book"
loan_ids = fields.One2many(
comodel_name="library.loan",
inverse_name="book_id",
string="Loans",
)
loan_count = fields.Integer(
string="Loan Count",
compute="_compute_loan_count",
store=False,
)
active_loan_count = fields.Integer(
string="Active Loans",
compute="_compute_loan_count",
store=False,
)
@api.depends("loan_ids", "loan_ids.state")
def _compute_loan_count(self):
for book in self:
loans = book.loan_ids
book.loan_count = len(loans)
book.active_loan_count = len(loans.filtered(lambda loan: loan.state == "borrowed"))
Bản mở rộng đính ba thứ vào library.book: loan_ids là One2many ngược lại từ trường book_id mà bước ba đã tạo trên library.loan, loan_count đếm tổng số phiếu mượn của cuốn sách, và active_loan_count đếm riêng những phiếu đang ở trạng thái borrowed. Cả hai computed field được đánh dấu store=False để tránh đồng bộ bảng — reload trong RAM là đủ, và tránh việc mỗi thay đổi trạng thái phiếu mượn kích hoạt một UPDATE xuống bảng library_book. Quyết định store=False này có thể đảo ngược sau khi có báo cáo cần index; ở giai đoạn tutorial, tránh index sớm giúp giữ registry đơn giản.
Chiều mở rộng phải là library_loans mở rộng library.book — không bao giờ ngược lại. Nếu library_core cố gắng đính loan_ids vào library.book thì library_core sẽ phải biết đến sự tồn tại của library.loan, tức là phải khai báo depends ngược lại library_loans, phá vỡ hợp đồng "loans phụ thuộc core" đã khoá cứng ở bước ba. Test tĩnh test_loans_book_extension_file_exists chốt thẳng rằng file mở rộng phải nằm dưới library_loans/models/, không được ở nơi khác.
Bộ test mới trong tests/test_inheritance_across_addons.py chia thành hai nhóm rõ ràng. Nhóm mixin (bảy test) kiểm tra library.archivable.mixin có tên đúng, subclass models.AbstractModel, sở hữu trường active và hai helper action_archive/action_unarchive, đồng thời cả hai model consumer đều đưa mixin vào _inherit và không còn khai báo active cục bộ. Nhóm mở rộng (bảy test) kiểm tra file extension nằm đúng addon library_loans, class chỉ có _inherit mà không có _name, trường loan_ids là One2many trỏ tới library.loan với inverse_name="book_id", và loan_count là computed field với compute khớp tiền tố _compute.
def test_core_models_init_imports_mixin_before_book(core_root: Path) -> None:
text = (core_root / "models" / "__init__.py").read_text()
mixin_pos = text.find("library_mixins")
book_pos = text.find("library_book")
assert mixin_pos != -1, "library_mixins must be imported in models/__init__.py"
assert book_pos != -1, "library_book must be imported in models/__init__.py"
assert mixin_pos < book_pos, (
"import order matters at module load time; the mixin's _name must "
"register before library.book tries to _inherit it"
)
Test kiểm tra thứ tự import ở trên là loại bất biến rất khó phát hiện bằng mắt thường — nếu ai đó "sắp xếp lại cho gọn" và đưa from . import library_book lên trên from . import library_mixins, Odoo sẽ ném KeyError: 'library.archivable.mixin' tại thời điểm boot registry vì mixin chưa kịp đăng ký. Có test tĩnh này, bug bị chặn ngay ở giai đoạn pytest local, trước khi lan tới container.
Verification
uv run pytest
.......................................................... [100%]
58 passed in 0.47s
Năm mươi tám test xanh — ba mươi chín test cũ từ bước một, hai, ba cộng thêm mười chín test mới của bước bốn — xác nhận rằng library_core đã sở hữu một abstract mixin library.archivable.mixin với đúng bộ trường và method dùng chung, cả library.book lẫn library.loan đều pull mixin qua _inherit và không còn khai báo active cục bộ, còn library_loans mở rộng library.book bằng bản _inherit không có _name với ba trường loan_ids, loan_count, active_loan_count. Đặc biệt, chiều mở rộng loans → core được kiểm tra cả bằng vị trí file lẫn bằng sự vắng mặt của _name trong file extension.
What we built
Sau bước bốn, hai nhánh inheritance chuẩn của Odoo đã hiện diện đầy đủ trong monorepo: một abstract mixin dùng chung cho nhiều model, và một bản mở rộng cross-addon đính kèm nghiệp vụ mới vào model đã có. Đây là hai công cụ mà mọi module Odoo commercial đều dùng để lớp hoá lên trên community core mà không phải fork.
Mixin library.archivable.mixin tập trung logic archive vào một nơi duy nhất. Bất kỳ model mới nào trong tương lai — library.member, library.reservation, library.fine — chỉ cần thêm một dòng _inherit = ["library.archivable.mixin"] là lập tức có trường active, trường archive_reason, và hai action archive/unarchive với hành vi nhất quán. Muốn đổi hành vi (ví dụ ghi log khi archive), chỉ cần sửa mixin và mọi consumer lập tức thừa hưởng.
Bản mở rộng library.book nằm trong library_loans cho thấy cách một addon có thể thêm trường và method vào model do addon khác sở hữu, mà không cần import Python trực tiếp và không phá vỡ chiều depends. Trường loan_count và active_loan_count sẽ trở thành nguồn dữ liệu tự nhiên cho các view kanban/list ở bước sau, còn loan_ids mở đường cho các domain filter kiểu [("loan_ids.state", "=", "borrowed")] để truy vấn sách đang được mượn.
Bộ test tĩnh mới đóng một lớp bảo vệ đặc biệt cho hai điểm rất dễ hỏng trong đời sống Odoo: thứ tự import làm mixin đăng ký sau consumer, và việc vô tình thêm _name vào file extension làm Odoo hiểu nhầm thành model mới. Với năm mươi tám test đều xanh, bước năm có thể tự tin thêm security groups và access rules cho cả library.book lẫn library.loan mà không lo phá vỡ cấu trúc inheritance vừa dựng.
Repository
The state of the code after this step: 13d79df
Step 5: Xếp lớp phân quyền theo addon với groups, ir.model.access.csv và record rules
Sau bước bốn, hai addon library_core và library_loans đã có đầy đủ mô hình dữ liệu: library.book, library.loan, mixin archivable dùng chung, và bản mở rộng _inherit cross-addon. Nhưng cho tới thời điểm này, bất kỳ internal user nào cài module cũng đọc-ghi được toàn bộ bảng — chưa có group, chưa có ir.model.access.csv, cũng chưa có record rule. Trong một dự án Odoo thực, thiếu lớp bảo mật ngay từ đầu chính là con đường ngắn nhất dẫn tới việc "một hôm lỡ tay xoá cả bảng loan" hoặc "kế toán nhìn thấy record archived đáng lẽ phải ẩn đi".
Bước năm cắm ba tầng bảo mật vào từng addon theo đúng quy ước Odoo: res.groups (ai là ai), ir.model.access.csv (quyền CRUD theo group trên từng model), và ir.rule (row-level visibility theo domain). Điểm khó không phải là cú pháp — điểm khó là chiều sở hữu: addon nào có quyền khai báo group nào, và khi nào một addon downstream được phép "tham chiếu" mà không được phép "khai báo lại". Chúng ta khoá chặt các bất biến ấy bằng ba mươi hai test tĩnh trước khi container Odoo được khởi động lại.
Setup
Mỗi addon giờ đều có thêm một thư mục security/ với hai file cố định: security.xml chứa ir.module.category, res.groups, và các ir.rule; và ir.model.access.csv chứa dòng grant CRUD theo group. Trong library_core, tạo addons/library_core/security/security.xml khai báo module_category_library cùng cặp group group_library_user và group_library_manager; đi kèm addons/library_core/security/ir.model.access.csv cấp read-only cho User và full CRUD cho Manager trên library.book. Trong library_loans, tạo addons/library_loans/security/security.xml khai báo group_library_loan_officer — imply ngược lên library_core.group_library_user để officer tự động có quyền đọc catalog — cùng hai record rules cho library.loan; và addons/library_loans/security/ir.model.access.csv cấp quyền cho officer và tái sử dụng library_core.group_library_manager cho vai trò Manager.
Không có dependency Python mới nào cần thêm vào pyproject.toml; toàn bộ bước này vẫn dựa vào stdlib csv, ast, và xml.etree.ElementTree mà pytest đã có sẵn. Hai file __manifest__.py phải được cập nhật đúng thứ tự trong danh sách data: security/security.xml xếp trước security/ir.model.access.csv, vì CSV tham chiếu group được khai báo trong XML — nếu load ngược lại, Odoo sẽ ném ValueError: External ID not found: library_core.group_library_user ngay lúc install.
Bộ test mới nằm ở tests/test_security_layer.py với ba mươi hai case đọc XML bằng ElementTree và CSV bằng csv.DictReader rồi khẳng định các bất biến chiều-sở-hữu, thứ tự load, cấu trúc header CSV, quyền CRUD từng dòng, và domain của từng record rule. Vẫn giữ nguyên nguyên tắc từ các bước trước — không boot Odoo, không cần database — nên pytest vẫn chạy dưới một giây và hermetic hoàn toàn.
Implementation
File addons/library_core/security/security.xml mở đầu bằng khối <data noupdate="1">. Cờ noupdate="1" là bất biến sống chết của mọi security data: nếu để mặc định là 0, mỗi lần admin sửa tay tên group hoặc thêm implied group qua UI, một lần -u module sẽ ghi đè ngược lại giá trị trong XML và tất cả tuỳ chỉnh trên production biến mất. Đây là loại lỗi rất phổ biến trong các addon community, và test test_security_xml_files_are_marked_noupdate chốt cứng cả hai file phải mang cờ này.
<record id="module_category_library" model="ir.module.category">
<field name="name">Library</field>
<field name="description">Access levels for the shared library catalog owned by library_core.</field>
<field name="sequence">20</field>
</record>
<record id="group_library_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="library_core.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="library_core.module_category_library"/>
<field name="implied_ids" eval="[(4, ref('library_core.group_library_user'))]"/>
</record>
library_core là chủ sở hữu duy nhất của category Library. Bất kỳ addon downstream nào cần phân quyền cùng chủ đề — library_loans hôm nay, library_fines ngày mai — đều phải trỏ category_id sang library_core.module_category_library chứ không được khai báo lại. Nhóm User imply base.group_user để bất cứ ai được gán vai trò này lập tức trở thành internal user của Odoo; nhóm Manager imply lại group_library_user để manager tự động thừa kế quyền đọc mà không cần thêm dòng CSV thứ hai. Đây chính là "group composition" — quy ước cốt lõi giúp phân quyền Odoo scale khi số role tăng lên.
<record id="rule_library_book_user_active_only" model="ir.rule">
<field name="name">library.book: users see active books only</field>
<field name="model_id" ref="library_core.model_library_book"/>
<field name="domain_force">[('active', '=', True)]</field>
<field name="groups" eval="[(4, ref('library_core.group_library_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
Record rule bên trên là mảnh khớp nối với mixin library.archivable.mixin từ bước bốn. Vì mixin đã cấp trường active cho library.book, chúng ta có ngay domain [('active', '=', True)] để ẩn record đã archive khỏi tầm nhìn user thông thường. Manager nhận một rule riêng với domain_force = [(1, '=', 1)] — luôn đúng, không lọc — nên vẫn thấy được record archived để phục vụ audit. Tách hai rule theo group thay vì viết một rule if-else phức tạp là quy ước Odoo khuyến nghị, và test test_core_manager_rule_is_unrestricted khoá cứng chuỗi [(1, '=', 1)] để không ai vô tình đưa domain lọc vào rule của manager.
Bên library_core/security/ir.model.access.csv, cấu trúc header là bắt buộc và không được đảo cột: id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink. Cột model_id:id dùng external id auto-generate của Odoo cho model — với library.book là library_core.model_library_book, được suy ra từ _name bằng cách thay . bằng _ rồi thêm tiền tố model_.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_library_book_user,library.book user read,library_core.model_library_book,library_core.group_library_user,1,0,0,0
access_library_book_manager,library.book manager full,library_core.model_library_book,library_core.group_library_manager,1,1,1,1
Hai dòng CSV kể trên tuân đúng chiều sở hữu: library_core chỉ grant quyền trên model do chính nó khai báo (library.book), không đụng vào library.loan. Nếu một ngày library_core cần grant thêm quyền lên library.loan, việc đó phải xảy ra ở library_loans — nơi model gốc được định nghĩa — để tránh vòng phụ thuộc ngược. Test test_core_access_csv_targets_library_book chốt cứng set các model_id trong CSV của core đúng bằng {library_core.model_library_book}.
Bước sang library_loans, file security.xml chỉ khai báo một group mới là group_library_loan_officer, và tuyệt đối không tạo lại Library category hay Library User/Manager. Điểm quan trọng nằm ở dòng implied_ids: officer imply library_core.group_library_user, nghĩa là ai được gán vai trò Loan Officer thì lập tức được đọc catalog library.book mà không cần thêm bất kỳ dòng CSV nào ở phía core.
<record id="group_library_loan_officer" model="res.groups">
<field name="name">Loan Officer</field>
<field name="category_id" ref="library_core.module_category_library"/>
<field name="implied_ids" eval="[(4, ref('library_core.group_library_user'))]"/>
</record>
<record id="rule_library_loan_officer_active" model="ir.rule">
<field name="name">library.loan: officers see active loans only</field>
<field name="model_id" ref="library_loans.model_library_loan"/>
<field name="domain_force">[('active', '=', True)]</field>
<field name="groups" eval="[(4, ref('library_loans.group_library_loan_officer'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
Rule của officer cho phép read/write/create nhưng chặn unlink. Đây là quy ước quan trọng cho record kiểu ledger: phiếu mượn không được xoá vật lý, chỉ được archive (soft delete) qua trường active do mixin cung cấp. Rule của manager thì reuse thẳng library_core.group_library_manager — nghĩa là toàn stack chỉ tồn tại đúng một vai trò Manager, chứ không phải mỗi addon một Manager khác nhau. Test test_loans_manager_rule_reuses_core_manager_group giữ cứng bất biến này.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_library_loan_officer,library.loan officer read write,library_loans.model_library_loan,library_loans.group_library_loan_officer,1,1,1,0
access_library_loan_manager,library.loan manager full,library_loans.model_library_loan,library_core.group_library_manager,1,1,1,1
Dòng CSV thứ hai của library_loans là ví dụ cross-addon rõ nét: group_id:id là library_core.group_library_manager — group thuộc addon khác. Điều này chỉ hợp lệ vì library_loans đã khai báo library_core trong depends từ bước ba, nên external id library_core.group_library_manager đảm bảo tồn tại tại thời điểm CSV của loans được load. Nếu chiều depends bị đảo, install sẽ vỡ ngay ở đây với Reference not found.
Bộ test mới trong tests/test_security_layer.py chia thành sáu nhóm rõ ràng. Nhóm 1 (bốn test) kiểm tra sự tồn tại của cặp file security ở mỗi addon và cả hai được list trong data của manifest. Nhóm 2 (hai test) khoá thứ tự XML-trước-CSV trong data. Nhóm 3 (bảy test) đọc CSV, kiểm tra header đúng chuẩn, model_id đúng chiều sở hữu, và mỗi grant có ma trận CRUD chính xác. Nhóm 4 (bảy test) đọc XML và khoá cứng group nào thuộc addon nào, group nào imply group nào — đặc biệt là bất biến "loans không được redeclare Library category hay User/Manager". Nhóm 5 (sáu test) kiểm tra record rules: id đầy đủ, domain của user rule chứa active, domain của manager rule là [(1, '=', 1)], và rule bên loans bind đúng group. Nhóm 6 (một test) khoá cờ noupdate="1" trên cả hai <data> block.
def test_loans_officer_implies_core_user(loans_security_tree: ET.ElementTree) -> None:
record = _find_record(loans_security_tree, record_id="group_library_loan_officer")
implied = _implied_group_refs(record)
assert CORE_USER_GROUP in implied, (
"the loan officer role must imply library_core.group_library_user — "
"this is the cross-addon group composition the manifest depends chain unlocks"
)
Test bên trên là "chốt hạ" của toàn bước — nó đọc thẳng eval string của trường implied_ids, cắt ra tất cả target được truyền vào ref(...), rồi khẳng định library_core.group_library_user phải nằm trong đó. Nếu ai đó "tối ưu" bằng cách bỏ dòng imply và thay bằng một dòng CSV thứ hai grant read cho officer trên library.book, test này sẽ đỏ ngay — buộc mọi thay đổi tương lai phải bảo toàn quy ước "downstream imply upstream" thay vì trải thẳng grant khắp nơi.
Verification
uv run pytest
........................................................................ [ 80%]
.................. [100%]
90 passed in 0.25s
Chín mươi test xanh — năm mươi tám test cũ từ bốn bước đầu cộng ba mươi hai test bảo mật mới của bước năm — xác nhận rằng lớp security vừa thêm không phá vỡ bất kỳ bất biến nào của các bước trước: manifest vẫn hợp lệ, model registry vẫn nhất quán, mixin và bản _inherit cross-addon vẫn được nhận diện đúng. Chạy riêng uv run pytest tests/test_security_layer.py cho ra 32 passed in 0.08s, giúp vòng feedback khi chỉnh nhanh chỉ một file XML hay CSV rơi xuống dưới một phần mười giây.
What we built
Sau bước năm, hai addon đã có lớp bảo mật xếp chồng đúng theo quy ước Odoo: library_core sở hữu category Library cùng cặp vai trò gốc User/Manager, còn library_loans gắn thêm vai trò chuyên biệt Loan Officer imply ngược lên User của core. Toàn bộ stack chỉ có duy nhất một Manager — được reuse xuyên addon qua external id — thay vì mỗi module tự khai một Manager riêng và cuối cùng admin phải gán năm lần cho cùng một người.
Bốn record rule khoá đúng hai chiều tầm nhìn cần thiết cho một dữ liệu kiểu ledger: user và officer chỉ thấy record đang active, manager thấy toàn bộ kể cả archived. Nhờ mixin library.archivable.mixin từ bước bốn đã cấp sẵn trường active cho cả hai model, rule chỉ cần một dòng domain_force ngắn — không phải sao chép field mỗi lần thêm model mới.
Ba mươi hai test tĩnh mới đóng vai trò "bản đồ nhắc lại quy ước" cho bất kỳ ai bảo trì codebase sau này. Nếu ai đó vô tình bỏ cờ noupdate="1", đảo thứ tự load XML/CSV, redeclare category ở downstream, hoặc thêm quyền unlink cho officer, pytest sẽ đỏ ngay trong mili-giây thay vì phải chờ container Odoo cài xong module rồi mới phát hiện lỗi runtime.
Với lớp bảo mật đã đứng vững, bước sáu sẽ tiếp tục xếp lên trên nó một lớp UI đúng nghĩa: view, action, menu — mỗi thứ được scope theo group để user chỉ thấy đúng phần công việc của mình, thay vì thấy cả rừng model kể cả những model không được phép truy cập.
Repository
The state of the code after this step: 5b6c2bb
Step 6: Dựng lớp UI theo từng addon với views, actions, menus và inheritance overlay xuyên module
Sau bước năm, hai addon library_core và library_loans đã có đầy đủ mô hình dữ liệu, mixin dùng chung, ba tầng bảo mật res.groups / ir.model.access.csv / ir.rule, và chín mươi test tĩnh khoá chặt tất cả bất biến chiều-sở-hữu. Nhưng người dùng cuối vẫn chưa có cách nào chạm vào dữ liệu — chưa có menu để bấm, chưa có tree/form/search view để nhìn, chưa có ir.actions.act_window nối menu vào model. Ở một dự án Odoo thực, đây chính là bước "biến database thành sản phẩm": mọi thứ đằng sau bao nhiêu công dựng model và security chỉ có ý nghĩa khi khách hàng mở app switcher và thấy đúng biểu tượng "Library" hiện ra ở đó.
Bước sáu xếp lớp UI đúng theo cùng nguyên tắc chiều sở hữu đã dùng cho security: library_core khai báo menu root Library cùng nhóm view cho library.book; library_loans chỉ tham chiếu library_core.menu_library_root chứ tuyệt đối không redeclare, đồng thời chồng thêm nhánh menu Loans cho model riêng và hai overlay inherit_id để bơm trường loan_count và trang Loans vào form/tree gốc của book. Chúng ta lại giữ nguyên nguyên tắc từ đầu series — không boot Odoo, không cần database — và khoá toàn bộ bất biến ấy bằng ba mươi tư test tĩnh mới chạy trong 0.08s.
Setup
Mỗi addon giờ có thêm một thư mục views/ chứa các file XML định nghĩa ir.ui.view, ir.actions.act_window, và menuitem. Trong library_core, tạo addons/library_core/views/library_book_views.xml gói toàn bộ tree/form/search view cho library.book, action mở model, cùng ba menu menu_library_root / menu_library_catalog / menu_library_book. Trong library_loans, tạo hai file rõ ràng theo mô hình "một file một model": addons/library_loans/views/library_loan_views.xml khai báo view/action/menu cho library.loan, và addons/library_loans/views/library_book_views.xml chỉ chứa hai overlay inherit_id mở rộng view của book — không định nghĩa lại view gốc.
Không có dependency Python mới nào; bộ test vẫn dùng xml.etree.ElementTree và ast từ stdlib mà pytest đã có. Điểm cần cập nhật là hai __manifest__.py: library_core thêm "views/library_book_views.xml" vào cuối data, còn library_loans thêm cả "views/library_loan_views.xml" rồi tới "views/library_book_views.xml". Thứ tự này quan trọng: view của book bên loans là overlay trỏ inherit_id đến library_core.view_library_book_form, nên security phải load trước view, và view của model gốc library.loan được xếp trước file overlay để tài liệu hoá rõ ràng biên giới sở hữu — dù runtime Odoo không đòi.
Bộ test mới nằm ở tests/test_views_actions_menus.py với ba mươi tư case đọc XML bằng ElementTree, đọc manifest bằng ast.literal_eval, rồi khẳng định các bất biến: file tồn tại, được list trong data, security đứng trước view, view record trỏ đúng model, action bind đúng res_model, menu con parent đúng menu cha, menu root gated đúng group, và mọi view trong file overlay của loans đều có inherit_id.
Implementation
File addons/library_core/views/library_book_views.xml mở đầu với một tree view sample-friendly cho library.book. Đặt sample="1" để Odoo tự sinh dữ liệu demo khi database rỗng — nhờ vậy khách hàng lần đầu mở app không thấy một tab trống trơn mà thấy được ngay layout tree đầy đủ. Trường active được liệt kê với invisible="1" chứ không bị bỏ qua: điều này giữ active trong bộ trường được fetch để record rules [('active', '=', True)] từ bước năm hoạt động đúng ngay trong tree view.
<record id="view_library_book_tree" model="ir.ui.view">
<field name="name">library.book.tree</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<tree string="Books" sample="1">
<field name="name"/>
<field name="author_name"/>
<field name="year_published"/>
<field name="isbn" optional="hide"/>
<field name="active" invisible="1"/>
</tree>
</field>
</record>
Form view có <header> chứa hai nút Archive / Unarchive được groups="library_core.group_library_manager" — cùng nhóm Manager đã khai ở bước năm. Đây là chỗ security XML-ID lần đầu được "tiêu thụ" ở tầng UI: nếu external id không tồn tại, install sẽ vỡ ngay khi Odoo parse view. Cặp thuộc tính invisible="not active" / invisible="active" khiến hai nút tự nghịch đảo nhau — chỉ đúng một nút hiện tại mỗi thời điểm — mà không cần một dòng Python nào. Widget web_ribbon "Archived" ràng vào cùng biểu thức invisible="active" cho phép người dùng nhìn thoáng qua biết record đang ở trạng thái nào.
<record id="view_library_book_search" model="ir.ui.view">
<field name="model">library.book</field>
<field name="arch" type="xml">
<search string="Books">
<field name="name" string="Title or Author"
filter_domain="['|', ('name', 'ilike', self), ('author_name', 'ilike', self)]"/>
<filter name="filter_active" string="Active"
domain="[('active', '=', True)]"/>
<filter name="filter_archived" string="Archived"
domain="[('active', '=', False)]"
groups="library_core.group_library_manager"/>
</search>
</field>
</record>
Search view tách hai filter Active / Archived và gate filter Archived bằng groups="library_core.group_library_manager". Điều này khớp một cách sạch sẽ với record rule ở bước năm: user thường bị domain [('active', '=', True)] chặn nên có bấm filter archived cũng ra rỗng, còn manager thì rule là [(1, '=', 1)] và filter archived thực sự có tác dụng. Trường filter_domain trên ô search tên khiến một lần gõ tìm đồng thời theo name và author_name với toán tử | — quy ước "search một field, quét nhiều field" quen thuộc của Odoo.
Kế đến là ir.actions.act_window bind action vào model và ba menuitem xếp thành cây: menu_library_root (không parent — top-level trên app switcher, gated bởi group_library_user), menu_library_catalog parent về root, menu_library_book parent về catalog và bind action="action_library_book". Đây là điểm mấu chốt của bước: menu root là "cửa vào" duy nhất mà mọi addon downstream sẽ móc thêm nhánh — nên nó phải sở hữu bởi library_core.
<menuitem id="menu_library_root"
name="Library"
sequence="65"
web_icon="library_core,static/description/icon.png"
groups="library_core.group_library_user"/>
<menuitem id="menu_library_catalog"
name="Catalog"
parent="library_core.menu_library_root"
sequence="10"/>
<menuitem id="menu_library_book"
name="Books"
parent="library_core.menu_library_catalog"
action="action_library_book"
sequence="10"/>
Bước sang library_loans, file views/library_loan_views.xml khai báo bộ view/action/menu cho library.loan theo đúng cấu trúc trên, chỉ khác ở chỗ tree view có thêm decoration màu theo state: decoration-info="state == 'draft'", decoration-warning="state == 'borrowed'", decoration-success="state == 'returned'" — cho phép người vận hành nhìn danh sách là biết ngay trạng thái mà không cần mở từng phiếu. Form view có statusbar <field name="state" widget="statusbar" statusbar_visible="draft,borrowed,returned"/> render dải trạng thái quen thuộc trên đầu form.
<menuitem id="menu_library_loans_section"
name="Loans"
parent="library_core.menu_library_root"
sequence="20"
groups="library_loans.group_library_loan_officer"/>
<menuitem id="menu_library_loan"
name="All Loans"
parent="library_loans.menu_library_loans_section"
action="action_library_loan"
sequence="10"/>
Menu section Loans là ví dụ điển hình của inheritance xuyên addon ở tầng UI: parent="library_core.menu_library_root" — trỏ thẳng vào external id do addon khác sở hữu. Điều này chỉ hợp lệ vì library_loans đã khai library_core trong depends từ bước ba; nếu chiều depends bị đảo, Odoo sẽ ném Reference not found: library_core.menu_library_root. Attribute groups="library_loans.group_library_loan_officer" khoá section này với vai trò Loan Officer đã dựng ở bước năm — plain library user không hề thấy nhánh Loans hiện ra dù có menu root, thay vì phải dựa vào access rule chặn ở tầng model rồi mới ẩn nút ở client.
File thứ hai bên loans, addons/library_loans/views/library_book_views.xml, chỉ chứa overlay — không định nghĩa lại view gốc. Overlay trên form dùng <xpath expr="//sheet" position="inside"> để cắm một <notebook> mới với trang Loans chứa hai trường loan_count / active_loan_count (do _inherit bước bốn contribue) và inline tree loan_ids. Overlay trên tree dùng <xpath expr="//field[@name='year_published']" position="after"> để chèn thêm hai cột count vào ngay sau cột năm xuất bản.
<record id="view_library_book_form_loans" model="ir.ui.view">
<field name="name">library.book.form.loans</field>
<field name="model">library.book</field>
<field name="inherit_id" ref="library_core.view_library_book_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<notebook>
<page string="Loans" name="loans">
<field name="loan_count" readonly="1"/>
<field name="active_loan_count" readonly="1"/>
<field name="loan_ids" readonly="1">
<tree>
<field name="name"/>
<field name="borrower_name"/>
<field name="state"/>
</tree>
</field>
</page>
</notebook>
</xpath>
</field>
</record>
Điểm tinh tế: <field name="model">library.book</field> được giữ nguyên chứ không chuyển sang library.loan, vì overlay vẫn đang mô tả form của book — chỉ mượn thêm trường mà _inherit đã bơm sang. Test test_loans_book_form_overlay_mentions_loan_count khớp thẳng vào set tên field bên trong <arch> để chốt overlay này luôn phải surface được ít nhất loan_count và loan_ids; nếu ai đó lỡ tay xoá <field name="loan_count"/> khỏi notebook, pytest đỏ trước cả khi container Odoo được rebuild.
Bộ test mới trong tests/test_views_actions_menus.py chia thành sáu cụm rõ ràng. Cụm 1 (bốn test) kiểm tra sự tồn tại của thư mục views/ và mỗi file được list trong data của manifest tương ứng. Cụm 2 (ba test) khoá thứ tự nạp: security/security.xml và security/ir.model.access.csv đứng trước bất kỳ file view nào; view của model gốc bên loans đứng trước file overlay. Cụm 3 (bảy test) đọc view file của core: đủ ba xml-id tree/form/search, mỗi record trỏ model = library.book, có action action_library_book với res_model khớp, view_mode gồm cả tree và form.
Cụm 4 (bảy test) kiểm tra menu bên core: đủ ba xml-id, menu_library_root không có parent (top-level), menu_library_catalog parent về root, menu_library_book parent về catalog và bind đúng action, root menu gated bởi library_core.group_library_user. Cụm 5 (chín test) kiểm tra bên loans: đủ view của loan, action bind đúng, hai menu tồn tại, menu root không bị redeclare, section parent về library_core.menu_library_root, leaf parent về section, section gated bởi library_loans.group_library_loan_officer, và không có menuitem nào của loans trùng id với của core. Cụm 6 (bốn test) kiểm tra overlay: mọi record trong file book bên loans đều có inherit_id, form overlay mở rộng đúng library_core.view_library_book_form, arch của form overlay mention loan_count và loan_ids, tree overlay inherit library_core.view_library_book_tree.
def test_loans_section_menu_parents_off_core_root(
loans_loan_views_tree: ET.ElementTree,
) -> None:
section = _find_menuitem(
loans_loan_views_tree, menu_id="menu_library_loans_section"
)
assert section.get("parent") == CORE_ROOT_MENU, (
f"the Loans section must attach to {CORE_ROOT_MENU} — the whole point "
f"of the depends chain is that downstream addons plug into the "
f"upstream navigation"
)
Test bên trên là "chốt hạ" của toàn bước — nó không kiểm tra kiểu dáng hay text hiển thị, nó kiểm tra quan hệ sở hữu qua external id. Nếu ai đó "tối ưu" bằng cách redeclare menu root trong library_loans để giảm depends, test này vẫn xanh nhưng test test_loans_does_not_redeclare_root_menu sẽ đỏ ngay. Cặp test song song hai chiều — "phải parent về root của core" và "không được redeclare root" — bao trọn không gian sai lầm phổ biến nhất khi làm menu cross-addon.
Verification
uv run pytest
........................................................................ [ 58%]
.................................................... [100%]
124 passed in 0.37s
Một trăm hai mươi tư test xanh — chín mươi test cũ từ năm bước đầu cộng ba mươi tư test UI mới của bước sáu — xác nhận rằng lớp view/action/menu vừa cắm không phá vỡ bất kỳ bất biến nào trước đó: model registry, mixin, security groups, record rules, cả manifest depends vẫn nguyên. Chạy riêng uv run pytest tests/test_views_actions_menus.py cho ra 34 passed in 0.08s, đủ nhanh để mỗi lần chỉnh một xpath hay đổi tên một menu cũng feedback trong dưới một phần mười giây.
What we built
Sau bước sáu, monorepo đã có mặt tiền UI đầy đủ theo đúng chiều sở hữu module: library_core chủ trì menu root Library cùng bộ view tree/form/search chuẩn cho library.book, và library_loans cắm nhánh Loans vào đúng menu root ấy qua external id, thêm bộ view riêng cho library.loan và hai overlay inherit_id bơm trường loan_count cùng trang Loans vào form/tree của book — mà không đụng vào một dòng XML nào bên core.
Attribute groups trên menu và search filter đóng vai trò "khoá đúp": tầng model đã có ir.model.access.csv và ir.rule chặn CRUD, còn tầng UI có thêm groups để ẩn hẳn nút và menu khỏi tầm nhìn user không thuộc vai trò. Nhờ vậy plain library user không bao giờ nhìn thấy nhánh Loans hiện ra rồi bấm vào bị access error — sản phẩm cho cảm giác "chỉ hiện những gì tôi có quyền", đúng chuẩn UX Odoo.
Ba mươi tư test tĩnh mới đóng vai trò "bản đồ nhắc lại quan hệ menu" cho người bảo trì sau này. Nếu ai đó lỡ tay đổi parent của section Loans, quên gate menu root bằng group, redeclare menu root ở downstream, hay xoá inherit_id khỏi overlay, pytest sẽ đỏ ngay trong mili-giây thay vì phải cài lại toàn bộ module và kiểm tra bằng mắt trên UI để tìm ra lỗi.
Với lớp UI đã đứng vững song song với lớp bảo mật, monorepo giờ là một sản phẩm chạy được — cài library_core là có ngay app Library với catalog books; cài thêm library_loans là nhánh Loans tự cắm vào cùng app đó, và form book tự mọc thêm trang loan. Đây chính là cam kết cốt lõi mà kiến trúc monorepo multi-addon-module hứa hẹn: các đơn vị chức năng tách rời nhưng khớp lại thành một trải nghiệm liền mạch, và mỗi ranh giới ấy được test cứng.
Repository
The state of the code after this step: 2e336c3
Step 7: Đóng gói vòng đời install / upgrade / test cho monorepo Odoo bằng manifest graph, Makefile và bốn mươi test tĩnh
Sau sáu bước đầu, monorepo đã có runtime docker-compose cho Odoo 19, hai addon library_core và library_loans với đủ model, mixin, security ba tầng, view / action / menu, và một trăm hai mươi tư test tĩnh chạy dưới nửa giây. Nhưng vẫn còn một khoảng trống rất thực tế: mỗi lần cài addon lên container, người vận hành phải gõ tay docker compose exec odoo odoo -i library_core,library_loans --stop-after-init, và nếu ai đó thêm addon thứ ba mai kia, họ sẽ quên cập nhật câu lệnh — hoặc tệ hơn, viết sai thứ tự depends và nhận về lỗi Reference not found: library_core.menu_library_root giữa lúc chuẩn bị demo cho khách.
Bước bảy đóng khung vòng đời vận hành thành một API rõ ràng: một module Python thuần đọc addons/*/__manifest__.py bằng ast rồi trả về thứ tự topological — không boot Odoo, không cần database; ba wrapper shell dùng chính thứ tự ấy để chạy odoo -i / odoo -u / odoo --test-enable qua docker compose exec; một Makefile mỏng phơi ra chín target (up, down, logs, install, upgrade, odoo-tests, pytest, order, help) làm cửa vào duy nhất cho cả developer lẫn CI. Tất cả bất biến của bộ tooling này được khoá bằng bốn mươi test tĩnh mới, đưa tổng số test lên một trăm sáu mươi tư trong dưới một giây.
Setup
Tạo mới thư mục scripts/ ở gốc repo với hai loại file rõ ràng: một module Python scripts/manifest_graph.py chịu trách nhiệm tính thứ tự cài đặt, và ba file .sh là wrapper mỏng gọi docker compose exec bên trong container odoo. Thêm một scripts/__init__.py rỗng để pytest import được from scripts import manifest_graph khi chạy suite tĩnh — module không cần chạy trong container, nó chỉ đọc file trên host. Tạo Makefile ở gốc repo làm entry point cho cả developer thao tác tay lẫn CI gọi target trong pipeline.
Test mới nằm ở tests/test_install_upgrade_stack.py với bốn mươi case: kiểm tra scripts tồn tại, có bit executable, có set -euo pipefail, có --stop-after-init, đều đi qua docker compose exec, đều lấy danh sách addon từ manifest_graph.py; đồng thời gọi trực tiếp API Python (discover_addons, depends_of, install_order, install_argument) và CLI python scripts/manifest_graph.py --format csv|lines để khẳng định thứ tự topological ổn định, chu trình bị chặn, cùng danh sách sub-CSV đúng định dạng -i của odoo-bin.
Không có dependency Python mới nào — module chỉ dùng ast, argparse, pathlib từ stdlib; test dùng pytest, subprocess, stat cũng từ stdlib. Đây là điểm quan trọng: bộ tooling để cài Odoo tuyệt đối không được phụ thuộc vào Odoo hay bất kỳ package bên ngoài nào, nếu không thì cái tool "cài Odoo cho bạn" sẽ đòi cài trước một bộ dependency riêng, hỏng ngay tính chất bootstrap.
Implementation
Trái tim của bước là scripts/manifest_graph.py. Module này giải quyết một vấn đề tưởng nhỏ nhưng rất dễ sai khi có nhiều addon: odoo-bin -i a,b,c yêu cầu a được liệt kê trước mọi addon phụ thuộc vào nó, và nếu thứ tự bị đảo, library_loans sẽ ném lỗi tham chiếu library_core.menu_library_root trong lúc parse XML. Ta không muốn hardcode thứ tự này ở nhiều chỗ — nó phải sinh từ chính manifest.
def install_order(addons_dir: Path = ADDONS_DIR) -> list[str]:
local_addons = discover_addons(addons_dir=addons_dir)
local_set = set(local_addons)
graph = {name: [d for d in depends_of(name, addons_dir=addons_dir) if d in local_set]
for name in local_addons}
return _kahn_sort(local_addons, graph)
Hàm install_order chỉ giữ lại các dependency first-party trong đồ thị — nếu library_core có depends = ["base"] thì base bị lọc ra, vì Odoo tự cài stdlib addon rồi. Chuyển đồ thị đã lọc sang _kahn_sort cho ra một topological order ổn định: khi hai addon không có quan hệ phụ thuộc, thứ tự trả về là alphabetical, đảm bảo cùng repo chạy trên hai máy khác nhau luôn cho ra CSV giống hệt. Nếu ai đó vô tình tạo cycle giữa hai addon (điều không được phép trong Odoo), Kahn phát hiện ngay và raise ManifestError thay vì trả về danh sách thiếu.
def _kahn_sort(nodes: list[str], graph: dict[str, list[str]]) -> list[str]:
remaining = {node: list(graph[node]) for node in nodes}
ordered: list[str] = []
while remaining:
ready = sorted(name for name, deps in remaining.items() if not deps)
if not ready:
raise ManifestError(f"cycle detected among addons: {sorted(remaining)}")
for name in ready:
ordered.append(name)
del remaining[name]
for deps in remaining.values():
deps[:] = [d for d in deps if d in remaining]
return ordered
Đặt sorted(...) trong bước chọn ready là chi tiết dễ bỏ qua nhưng cực quan trọng — nó chính là thứ khiến test_manifest_graph_sort_is_stable_for_independent_addons xanh. Không có sorted, Python 3 dict giữ insertion order và thứ tự ra sẽ phụ thuộc vào os.listdir, tức là khác nhau giữa macOS và Linux — CI sẽ đổ cùng lỗi khó tái hiện. Đây là kiểu ràng buộc mà chỉ có test tĩnh khoá lại thì mới không bị "tối ưu" nhầm.
Ba wrapper shell trong scripts/ đều theo cùng một khuôn: gọi manifest_graph.py --format csv để lấy danh sách addon, rồi docker compose exec -T odoo chuyền vào odoo-bin. Wrapper install_stack.sh dùng -i với --without-demo=False để container tự sinh dữ liệu mẫu, hợp với sample="1" đã đặt trên tree view ở bước sáu — khách hàng mở app lần đầu thấy ngay layout thật.
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DB_NAME="${ODOO_DB:-library}"
ADDONS_CSV="$(python3 "${REPO_ROOT}/scripts/manifest_graph.py" --format csv)"
docker compose exec -T odoo \
odoo -c /etc/odoo/odoo.conf \
-d "${DB_NAME}" \
-i "${ADDONS_CSV}" \
--without-demo=False \
--stop-after-init
Cả ba script mở đầu bằng set -euo pipefail — thiếu dòng này thì docker compose exec fail sẽ bị nuốt và Makefile ung dung báo success. Cờ --stop-after-init bắt container thoát ngay khi cài xong, nếu không thì docker compose exec treo mãi đợi Odoo web server chạy và CI job không bao giờ kết thúc. Wrapper upgrade_stack.sh khác install_stack.sh đúng ở hai chỗ: dùng -u thay vì -i (bảo toàn dữ liệu đã có), và cho phép truyền tên addon làm arg 1 để scope upgrade thay vì luôn chạy hết cả stack — hữu ích khi bạn chỉ sửa view của library_loans và không muốn library_core bị chạy lại upgrade cycle.
Wrapper run_odoo_tests.sh là nơi kỷ luật lộ rõ nhất: nó route sang ODOO_TEST_DB (mặc định library_test) chứ không đụng vào library, thêm --test-enable để mọi class Odoo có @tagged đều chạy sau khi addon cài xong, và bật --log-level=test để output phân biệt được test line khỏi phần noise của Odoo. Route sang throwaway database là bất biến được test_odoo_tests_script_uses_throwaway_db khoá cứng — không ai được lỡ tay chỉnh về ODOO_DB để "tiết kiệm resources", vì một test fail giữa chừng sẽ để lại schema half-installed trong database vận hành thật.
install: ## install every first-party addon into $$ODOO_DB (fresh db)
./scripts/install_stack.sh
upgrade: ## upgrade every first-party addon in $$ODOO_DB (schema/data changes)
./scripts/upgrade_stack.sh
odoo-tests: ## run addon test suites with --test-enable on $$ODOO_TEST_DB
./scripts/run_odoo_tests.sh
pytest: ## run the hermetic static validation suite
uv run pytest
order: ## print the topological install order used by the scripts
python3 scripts/manifest_graph.py --format lines
Makefile cố ý giữ mỗi target ở đúng một dòng — không nhồi logic nào ở lớp make. Điều này khiến CI job trên GitHub Actions gọi thẳng ./scripts/install_stack.sh được, không cần cài GNU make trên runner. .DEFAULT_GOAL := help bảo đảm gõ make không tham số sẽ in ra danh sách target thay vì im lặng chạy target đầu tiên trong file — đây là bất biến được test_makefile_default_goal_is_help khoá lại vì đã từng bị developer khác vô tình sửa thành .DEFAULT_GOAL := up và làm docker compose up -d chạy khi họ chỉ định gõ enter thăm dò.
Bộ test mới trong tests/test_install_upgrade_stack.py chia thành bốn cụm. Cụm 1 (mười test) kiểm tra sự hiện diện + tính chất của các script shell: file tồn tại, có bit executable, đều set -euo pipefail, đều đi qua docker compose exec, đều lấy danh sách từ manifest_graph.py, và mỗi script dùng đúng cờ Odoo tương ứng (-i + --stop-after-init, -u + --stop-after-init, --test-enable + ODOO_TEST_DB).
def test_install_order_places_dependency_before_dependent(repo_root: Path) -> None:
order = manifest_graph.install_order(repo_root / "addons")
assert order.index(CORE_ADDON) < order.index(LOANS_ADDON), (
f"{LOANS_ADDON} depends on {CORE_ADDON} in its manifest — the "
f"topological sort must return core first or `odoo-bin -i` will "
f"fail with 'Reference not found: library_core.menu_library_root'"
)
Cụm 2 (mười ba test) kiểm tra Makefile: file tồn tại, mười một target được khai (help, up, down, logs, install, upgrade, odoo-tests, pytest, order), default goal là help, và ba target install / upgrade / odoo-tests thực sự delegate về script tương ứng chứ không lén viết inline logic. Cụm 3 (mười test) gọi thẳng API Python của manifest_graph: discover_addons tìm ra đủ hai addon và bỏ qua .gitkeep, install_order đặt library_core trước library_loans, install_argument cho ra CSV không khoảng trắng bắt đầu bằng library_core,, depends_of trả về danh sách declared dep, sort ổn định theo alphabetical, và raise ManifestError khi phát hiện cycle hoặc addon không tồn tại.
Cụm 4 (bảy test) kiểm tra CLI bằng subprocess: chạy python scripts/manifest_graph.py --format csv và khớp stdout với kết quả API trực tiếp, chạy --format lines và khớp danh sách dòng với install_order(). Đôi test song song giữa API và CLI đảm bảo hai bề mặt không lệch pha — nếu ai đó sửa API mà quên cập nhật argparse, một trong hai sẽ đỏ ngay.
Verification
uv run pytest
........................................................................ [ 43%]
........................................................................ [ 87%]
.................... [100%]
164 passed in 0.90s
Một trăm sáu mươi tư test xanh — một trăm hai mươi tư test cũ từ sáu bước đầu cộng bốn mươi test mới của bước bảy — chứng minh rằng lớp tooling install / upgrade / test vừa cắm không phá bất biến nào đã dựng: model registry, security ba tầng, view / action / menu, mixin domain logic đều nguyên. Chạy riêng suite mới với uv run pytest tests/test_install_upgrade_stack.py cho ra 40 passed in 0.40s — đủ nhanh để mỗi lần chỉnh một cờ trong wrapper shell hay đổi tên target Makefile cũng có feedback dưới nửa giây, không cần đợi container Odoo boot lên rồi mới biết mình sai.
Chạy python3 scripts/manifest_graph.py --format csv từ CLI cho ra chuỗi library_core,library_loans — đúng thứ tự topological, sẵn sàng chuyền thẳng vào odoo-bin -i. Đổi sang --format lines cho ra hai dòng riêng, phù hợp để đọc bằng mắt khi debug thứ tự depends của một stack lớn.
What we built
Sau bước bảy, monorepo có một API vận hành hoàn chỉnh và hermetic: make install cài toàn bộ addon vào database library với đúng thứ tự depends, make upgrade chạy upgrade cycle bảo toàn dữ liệu, make odoo-tests chạy --test-enable trên throwaway database, make pytest chạy suite tĩnh trong dưới một giây. Người vận hành mới chỉ cần gõ make là thấy toàn bộ menu — không cần đọc README dài lê thê hay hỏi người viết cũ về "câu lệnh cài Odoo là gì".
Module scripts/manifest_graph.py là "brain" của toàn bộ tooling: nó là nơi duy nhất biết thứ tự cài đặt, và mọi wrapper đều tra cứu qua nó. Khi ta thêm addon thứ ba (giả sử library_billing depends vào library_loans), không một file shell nào cần chỉnh — chạy lại make install và Kahn tự sort ra library_core,library_loans,library_billing. Đây là single source of truth mà kiến trúc monorepo multi-addon đòi hỏi, và nó được khoá bằng test tĩnh nên không thể vô ý bị bypass.
Bốn mươi test tĩnh mới đóng vai trò "hợp đồng" giữa tooling và Odoo runtime: --stop-after-init phải có mặt (nếu không container treo mãi), -i / -u phải đúng động từ (nếu không dữ liệu bị xoá hoặc bị skip), set -euo pipefail phải có mặt (nếu không lỗi bị nuốt), throwaway DB phải tách khỏi working DB (nếu không test fail sẽ để lại rác). Mọi hợp đồng ấy được kiểm tra bằng ast và read_text — không cần Odoo, không cần Docker daemon đang chạy — nên CI job có thể chạy chúng ở tầng "lint" trước khi thực sự spawn container.
Với bộ tooling này, monorepo bước qua ranh giới giữa "một tập file có thể chạy" và "một sản phẩm có vòng đời rõ ràng". Developer mới join dự án chỉ cần docker compose up -d rồi make install là có database library chạy được với đủ hai addon đúng thứ tự depends, và mọi CI job đều tái sản xuất được chính xác cùng thứ tự ấy vì nó sinh ra từ manifest chứ không phải từ trí nhớ ai đó.
Repository
The state of the code after this step: 9fc799d
Repository
Full source at https://github.com/vytharion/odoo-monorepo-multi-addon-modules.
Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.