起因
在 dodo 中经常会遇到一种需求:用户点击某个按钮后自动下载后台临时生成的一个文件。
在 odoo 的设计中,点击一个按钮后可以返回各种不同的 action
,如:ir.actions.act_window
、ir.actions.act_url
、ir.actions.client
,但是这些 action
都不支持下载文件。
因此考虑添加一个 ir.actions.act_down
用于支持点击按钮后下载文件的需求。
当前解决方案
用户点击按钮后,后台返回 ir.actions.act_down
。
def btn_export_bom(self):
return {
'type': 'ir.actions.act_down',
'url': '/dtdream_product/dtdream_bom_export',
'datas': {'id':self.id,'uid': self._uid}
}
前端接收到 ir.actions.act_down
时,将 datas 作为参数,调用 url 接口获取文件并下载。
[!example]- 后台接口: 生成附件并下载
@http.route('/dtdream_product/dtdream_bom_export', type='http', auth='public') def dtdream_bom_export(self, data, token=None): output = io.BytesIO() workbook = xlsxwriter.Workbook(output, {'in_memory': True}) ... # 填充表格内容 workbook.close() output.seek(0) # 返回文件 return request.make_response( output.read(), headers=[('Content-Disposition', self.content_disposition("Bom导出模板.xls")), ('Content-Type', self.content_type)], cookies={'fileToken': token} )
[!example]- 前端添加对
ir.actions.act_down
的响应odoo.define('dtdream.ir_actions_act_down', function (require) { "use strict"; var ActionManager = require('web.ActionManager'); var session = require('web.session'); var Model = require('web.Model'); ActionManager.include({ _handleAction: function (action, options) { switch (action.type) { case 'ir.actions.act_down': return this.ir_actions_act_down(action); default: return this._super.apply(this, arguments); } }, ir_actions_act_down: function (action) { var self = this; $.blockUI(); session.get_file({ url: action.url, data: {data: [JSON.stringify(action.datas)]}, complete: $.unblockUI, error: function (data) { return new Model('raise.warning').call('raise_warning', [data.msg]); } }); }, }); });
当前解决方案的问题
- 每个下载按钮都要添加一个
http.route
接口,接口太多不好管理 - 生成文件的代码放在
http.route
接口中,需要检查用户对接口的调用权限,没有对接口参数进行细致的检查很容易泄露数据。
改进的解决方案
主要改进点;将生成文件的代码放在按钮对应的函数中,以 uuid 为 key 将生成的文件及 header 信息写入 redis 中,前端通过固定的接口和 uuid 值获取文件。
改进后的好处:
- 整个系统中只需要一个统一的
http.route
接口,减少了接口的数量,方便统计分析。 http.route
接口中不需要进行权限判定和参数校验,降低因参数未严格校验导致的数据泄露风险。- 添加新的下载按钮时,开发人员只需要实现
btn_
方法,不需要再添加http.route
接口。
[!example]- 统一的文件下载接口
@http.route('/dtdream_web/download/<string:_uuid>', type='http', auth="public") def content_common(self, _uuid): r = get_redis(db=4) content = r.get(_uuid) json_headers = json.loads(r.get(f'{_uuid}_headers') or '{}') if not content: content = f"未找到要下载的资源,请尝试重新下载\n{_uuid}" json_headers = { 'Content-Disposition': f'attachment; filename=download_error.txt', } new_headers = { 'Content-Type': 'application/octet-strea', 'Content-Length': len(content), **json_headers, } headers = [(k, v) for k, v in new_headers.items()] response = request.make_response(content, headers) return response
[!example]- 按钮对应的文件生成方法
def btn_down_zip(self): file = io.BytesIO() ... # 填充文件内容 return ir_actions_act_down(filename, file.getvalue())
[!example]- 封装的 ir_actions_act_down 方法,将数据存入 redis 并返回动作
FILE_TYPE_DICT = { 'zip': 'application/zip', 'xls': 'application/vnd.ms-excel', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ods': 'application/vnd.oasis.opendocument.spreadsheet', 'csv': 'text/csv', 'gif': 'image/gif', 'jpe': 'image/jpeg', 'jpg': 'image/jpg', 'png': 'image/png', 'svg': 'image/svg+xml', } def ir_actions_act_down(filename, file_content, ex=360): r = get_redis(db=4) content_type = FILE_TYPE_DICT.get(filename.split('.')[-1], 'application/octet-stream') escaped = urllib.parse.quote(filename) headers = { 'Content-Disposition': f'attachment; filename={escaped}', 'Content-Type': content_type, } _uuid = uuid.uuid4().hex r.set(_uuid, file_content, ex=ex) r.set(f'{_uuid}_headers', json.dumps(headers), ex=ex) return { 'type': 'ir.actions.act_down', 'url': f'/dtdream_web/download/{_uuid}', 'datas': [] }