Phần trước khách gửi file lên; giờ làm chiều ngược lại — cho khách tải file về: một bản CSV danh sách, một hoá đơn PDF, một ảnh đính kèm. Trả file nghe đơn giản ("cứ return nội dung ra"), nhưng làm ẩu thì trình duyệt hiển thị thẳng chuỗi bytes thay vì tải xuống, hoặc lưu ra file tên download vô nghĩa, hoặc tên tiếng Việt có dấu bị vỡ. Bài này chỉ cách trả file đúng: Content-Type và Content-Disposition chuẩn, hàm content_disposition cho tên có dấu, và Stream cho file lớn — qua một route xuất CSV dựng thật rồi curl xem header sống trên Odoo 19.
make_response + hai header quyết định
Trả file là trả một Response với thân là bytes và header nói cho trình duyệt biết đây là gì, làm gì với nó. Hai header cốt lõi: Content-Type (loại file) và Content-Disposition (tải xuống hay hiển thị, và tên gì).

Hình 1: Route CSV (trên) dựng file bằng csv.writer vào io.StringIO, encode('utf-8-sig') (thêm BOM để Excel nhận UTF-8), rồi make_response(data, headers=[...]). Header Content-Disposition không viết tay — dùng hàm content_disposition(tên) để đặt tên tải xuống đúng chuẩn. Route dưới dùng Stream cho file lớn.
content_disposition: tên có dấu không vỡ
Đây là chỗ tinh tế. Nếu bạn viết tay 'Content-Disposition': 'attachment; filename=danh-sách-thẻ.csv', ký tự có dấu (á, ẻ) không hợp lệ trong header HTTP thuần ASCII — trình duyệt có thể cắt cụt hay hiển thị sai. Odoo có sẵn hàm content_disposition làm đúng theo RFC 6266:
from odoo.http import content_disposition
content_disposition('danh-sách-thẻ.csv')
# → "attachment; filename*=UTF-8''danh-s%C3%A1ch-th%E1%BA%BB.csv"

Hình 2: Lõi content_disposition trong Odoo 19 — trả về chuỗi filename*=UTF-8''... theo RFC 6266, url-quote tên để dấu tiếng Việt không vỡ.
Nó dùng cú pháp filename*=UTF-8''<đã-mã-hoá> — url-quote tên theo UTF-8, nên tên tiếng Việt tải xuống đúng dấu trên mọi trình duyệt hiện đại. Tham số thứ hai disposition_type chọn 'attachment' (mặc định — lưu xuống đĩa) hay 'inline' (hiển thị trong trình duyệt, ví dụ xem PDF ngay tab).
Curl thật route này, header và nội dung trả về đúng như thiết kế:

Hình 3: Header thật. Content-Disposition: attachment; filename*=UTF-8''danh-s%C3%A1ch-th%E1%BA%BB.csv — chính là "danh-sách-thẻ.csv" đã mã hoá UTF-8, trình duyệt giải mã lại đúng tên có dấu khi lưu. Nội dung là CSV 4 thẻ sắp theo điểm. Route thứ hai (Stream) trả file 229 KB đủ 229090 byte.
Stream: cho file lớn, không nạp hết vào RAM
Route CSV ở trên dựng nội dung nhỏ trong bộ nhớ — ổn. Nhưng nếu bạn trả một attachment lớn (PDF vài chục MB, ảnh gốc), make_response(att.raw) sẽ nạp toàn bộ file vào RAM rồi mới gửi — tốn bộ nhớ, chậm với nhiều người tải cùng lúc. Odoo 19 có lớp Stream để phục vụ file theo luồng:
stream = Stream.from_binary_field(att, 'raw')
stream.download_name = att.name
stream.mimetype = att.mimetype
return stream.get_response(as_attachment=True)
Stream.from_binary_field (hoặc Stream.from_path cho file trên đĩa) tạo một luồng; get_response(as_attachment=True) trả về Response đã đặt sẵn Content-Disposition, kèm etag/last_modified để trình duyệt cache được (lần sau tải nhanh, tiết kiệm băng thông). Lưu ý ở Hình 1: from_binary_field không tự điền tên/loại, nên phải gán download_name và mimetype trước khi gọi get_response — thiếu là lỗi.
Đừng quên kiểm quyền
Route tải file rất dễ trở thành lỗ hổng: một /tai_ve/<id> không kiểm quyền cho phép bất kỳ ai đổi số id để tải file của người khác (IDOR). Ở route Stream tôi gọi att.check_access('read') trước khi trả — nó ném lỗi nếu người dùng hiện tại không được đọc attachment đó. Với route auth='public', càng phải cẩn thận: chỉ trả file thật sự công khai, và nếu cần bảo mật thì dùng access_token (nhớ portal.mixin ở phần 171).
Đã có sẵn: /web/content và /web/image
Trước khi tự viết route tải attachment, nhớ Odoo đã có sẵn:
/web/content/<id>— tải một attachment bất kỳ theo id (dùngStreambên trong, có kiểm quyền vàaccess_token)./web/image/<model>/<id>/<field>— trả một ảnh từ field, còn resize được (?width=128).
Chỉ tự viết controller khi cần logic riêng: dựng file tại chỗ (như CSV báo cáo), gộp nhiều file, hay đặt tên/định dạng đặc thù.
Ba ý mang về
- Trả file =
make_response(bytes, headers)vớiContent-Type(loại) vàContent-Disposition(tải xuống/hiển thị + tên); đừng viết tayContent-Disposition, dùngcontent_disposition(tên)để tên tiếng Việt có dấu không vỡ (RFC 6266,filename*=UTF-8''...). - File lớn dùng
Stream(from_binary_field/from_path+get_response) thay vì nạp hết vào RAM; nhớ gándownload_namevàmimetype, và tận dụngetagđể trình duyệt cache. - Luôn kiểm quyền (
check_access('read')) để tránh IDOR; và cân nhắc dùng sẵn/web/content/<id>,/web/imagetrước khi tự viết.
Website và controller đã đủ để dựng trang, nhận và trả file. Nhưng một trang tuỳ biến còn cần được công cụ tìm kiếm thấy. Phần sau nói về sitemap và SEO cho trang tuỳ biến — đưa route của bạn vào sitemap.xml, đặt thẻ meta và dữ liệu có cấu trúc để Google index đúng.