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ì).

Ảnh chụp mã Python nền tối. Nhập request content_disposition Stream từ odoo http. Route http quan_ca_phe tai_ve type http auth public, hàm tai_ve_csv dựng một io StringIO và csv writer, ghi hàng tiêu đề Tên thẻ Hạng Điểm rồi lặp các thẻ ghi name hang_the diem, data bằng buf getvalue encode utf-8-sig kèm chú thích BOM cho Excel. ten bằng danh-sách-thẻ chấm csv tên có dấu. Trả về request make_response data với headers Content-Type text csv charset utf-8, Content-Disposition bằng content_disposition ten, Content-Length bằng str len data. Phần dưới file lớn Stream: route quan_ca_phe tai_ve int att_id type http auth user, hàm lấy attachment browse att_id, check_access read kiểm quyền, stream bằng Stream from_binary_field att raw, gán stream download_name att name và stream mimetype att mimetype, trả stream get_response as_attachment True

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"

Ảnh chụp mã Python nền tối file odoo http py Odoo 19 hàm content_disposition nhận filename và disposition_type mặc định attachment, chú thích attachment lưu xuống đĩa inline hiển thị trong trình duyệt, nếu disposition_type không thuộc attachment hoặc inline thì raise ValueError, trả về chuỗi disposition_type chấm phẩy filename sao bằng UTF-8 hai nháy rồi url_quote filename mã hoá tên có dấu an toàn, ghi chú filename sao UTF-8 cho phép tên tiếng Việt có dấu tải xuống đúng và Stream from_binary_field cộng get_response phục vụ file lớn theo luồng có etag last_modified hỗ trợ cache, có sẵn web content id và web image cho attachment

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ế:

Ảnh chụp terminal nền tối curl gạch s D gạch route tải về header và nội dung thật trên Odoo 19. Lệnh curl quan_ca_phe tai_ve trả về HTTP 200 OK, Content-Type text csv charset utf-8, Content-Disposition attachment filename sao bằng UTF-8 hai nháy danh gạch s phần trăm C3 phần trăm A1ch gạch th phần trăm E1 phần trăm BA phần trăm BB chấm csv, Content-Length 100, X-Content-Type-Options nosniff. Nội dung tải về có BOM cho Excel gồm dòng Tên thẻ Hạng Điểm và bốn dòng VIP-0001 bac 150, VIP-0003 vang 150, VIP-0002 bac 85, VIP-0004 bac 0. Lệnh thứ hai curl quan_ca_phe tai_ve 1166 route Stream file 229 KB trả về Content-Type text css Content-Length 229090 Content-Disposition attachment filename website website_builder_assets min css, tải đủ 229090 byte Stream đọc thẳng không nạp hết vào RAM

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ùng Stream bê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ề

  1. Trả file = make_response(bytes, headers) với Content-Type (loại) và Content-Disposition (tải xuống/hiển thị + tên); đừng viết tay Content-Disposition, dùng content_disposition(tên) để tên tiếng Việt có dấu không vỡ (RFC 6266, filename*=UTF-8''...).
  2. File lớn dùng Stream (from_binary_field/from_path + get_response) thay vì nạp hết vào RAM; nhớ gán download_name và mimetype, và tận dụng etag để trình duyệt cache.
  3. 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/image trướ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.