Một danh sách vài chục bản ghi thì trả hết một lần cũng được; vài nghìn thì không — trang nặng, tải chậm, người dùng lạc. Phân trang là bắt buộc. Ở phần portal ta đã dùng portal_pager có sẵn; bài này rút gọn cơ chế chung cho một route tự viết bất kỳ — bốn bước tính toán, và (quan trọng hơn) những cạm bẫy biến một trang danh sách thành trang trắng hay lỗi offset. Kiểm chứng bằng curl thật.

Bốn bước phân trang

Mọi phân trang server-side đều là bốn phép tính đơn giản: đếm tổng, suy ra số trang, lấy page từ URL và kẹp nó về khoảng hợp lệ, rồi search với limit/offset.

Ảnh chụp mã Python nền tối phân trang trong controller bốn bước. IPP bằng 2 items per page. Route http hai đường dẫn qcp_ds và qcp_ds page int page type http auth public. Hàm danh_sach nhận page mặc định 1 sortby diem. Lấy model quan the thanh vien sudo, domain rỗng, order từ dict diem là diem desc ten là name lấy theo sortby mặc định diem desc. Bước 1 total bằng search_count domain đếm tổng. Bước 2 page_count bằng max 1 và math ceil total chia IPP số trang. Bước 3 page bằng max 1 min int page page_count CLAMP về khoảng 1 tới n. Bước 4 offset bằng page trừ 1 nhân IPP. records bằng search domain order limit IPP offset offset. Return dict total page page_count ban_ghi records, giữ sortby khi sang trang khác trang_sau bằng qcp_ds page cộng 1 sortby nếu page nhỏ hơn page_count ngược lại None. Ghi chú khai 2 route qcp_ds trang 1 và qcp_ds page int cho URL đẹp, dùng sẵn portal pager cũng được pager url total page step

Hình 1: Bốn bước. (1) search_count đếm tổng. (2) page_count = ceil(total/IPP). (3) page = max(1, min(page, page_count)) — dòng clamp cứu bạn khỏi mọi giá trị page bậy. (4) offset = (page-1)*IPP, rồi search(..., limit=IPP, offset=offset). Khai hai route (/qcp_ds và /qcp_ds/page/<int:page>) để URL trang 2 đẹp là /qcp_ds/page/2.

Kết quả thật: hai trang, và clamp

Chạy trên 4 thẻ với items_per_page=2, curl từng trang:

Ảnh chụp terminal nền tối curl thật trên Odoo 19 với 4 thẻ items per page 2 thành 2 trang. Lệnh curl qcp_ds trang 1 trả về total 4 page 1 page_count 2 offset 0, ban_ghi gồm VIP-0001 diem 150 và VIP-0003 diem 150, trang_truoc null trang_sau qcp_ds page 2 sortby diem. Lệnh curl qcp_ds page 2 trang 2 trả về offset 2 ban_ghi gồm VIP-0002 diem 85 và VIP-0004 diem 0, trang_truoc qcp_ds page 1 sortby diem trang_sau null. Chống lỗi biên clamp: curl qcp_ds page 99 trả page bằng 2 vượt về trang cuối, curl qcp_ds page 0 trả page bằng 1 số 0 hoặc âm về trang 1

Hình 2: Trang 1 (offset 0): VIP-0001, VIP-0003 — trang_truoc: null, trang_sau: /qcp_ds/page/2. Trang 2 (offset 2): VIP-0002, VIP-0004 — trang_sau: null. Và clamp thật: /page/99 → page=2 (về trang cuối), /page/0 → page=1. Không có trang trắng, không lỗi offset âm.

Ba cạm bẫy hay gặp

Phân trang trông đơn giản nhưng ba lỗi này rất phổ biến:

  • Không clamp page. Người dùng (hay bot) gõ ?page=99, ?page=-1, ?page=abc. Không kẹp thì offset thành số khổng lồ hoặc âm → trang trắng hoặc lỗi. Dòng page = max(1, min(page, page_count)) giải quyết hết. Route dùng <int:page> cũng đã lọc bỏ chữ; nhưng đọc từ ?page= thì phải tự ép kiểu và bắt lỗi.

  • Đánh mất tham số khác khi sang trang. Nếu khách đang sắp theo "tên" hoặc lọc "hạng vàng", bấm trang 2 mà URL không mang theo sortby/filter là mất lựa chọn — trang 2 hiện danh sách khác kiểu. Luôn nhét các tham số hiện tại vào link trang trước/sau (ở đây ?sortby=%s).

  • offset không khớp limit. offset = (page-1)*IPP, limit = IPP — cùng một IPP. Tự chế công thức lệch (ví dụ offset = page*IPP) là bỏ sót/trùng bản ghi, và trang cuối thiếu số dòng.

Tự tính hay dùng portal_pager?

Hai lựa chọn, cùng bốn bước bên trong:

  • Tự tính (như bài này) khi cần toàn quyền kiểm soát cấu trúc dữ liệu trả về (JSON cho API, hay logic phân trang đặc thù). Gọn, không phụ thuộc module portal.
  • portal_pager (from odoo.addons.portal.controllers.portal import pager) khi làm trang portal/website — nó trả sẵn offset, page_count, danh sách số trang để template portal.portal_table vẽ pager đẹp, kèm dấu … khi nhiều trang. Xem phần 172.

Cả hai đều tính đúng offset và clamp — điểm khác là bạn tự dựng giao diện hay dùng khung có sẵn của Odoo.

Ba ý mang về

  1. Phân trang = 4 bước: search_count (tổng) → page_count = ceil(total/IPP) → clamp page = max(1, min(page, page_count)) → search(limit=IPP, offset=(page-1)*IPP); khai /route và /route/page/<int:page>.
  2. Luôn clamp page để ?page=99/0/âm không tạo trang trắng hay offset lỗi; và giữ tham số (sortby, filter) trong link trang trước/sau, kẻo mất lựa chọn khi chuyển trang.
  3. Tự tính cho API/logic riêng (gọn, không cần portal); portal_pager cho trang portal/website (có sẵn pager đẹp) — cùng công thức, khác cách dựng giao diện.

Ta đã lo mọi thứ bên trong Odoo. Phần sau bước ra ngoài: Phần sau mổ xẻ gọi Odoo từ chương trình ngoài bằng XML-RPC — cách một script Python/PHP/bất kỳ ngôn ngữ nào đăng nhập và đọc/ghi dữ liệu Odoo từ xa qua API chuẩn.