2023062901_dodo临时生成文件并下载


起因

在 dodo 中经常会遇到一种需求:用户点击某个按钮后自动下载后台临时生成的一个文件。
在 odoo 的设计中,点击一个按钮后可以返回各种不同的 action,如:ir.actions.act_windowir.actions.act_urlir.actions.client,但是这些 action 都不支持下载文件。
因此考虑添加一个 ir.actions.act_down 用于支持点击按钮后下载文件的需求。

当前解决方案

用户点击按钮后,后台返回 ir.actions.act_down

python
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]- 后台接口: 生成附件并下载

python
@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 的响应

js
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]- 统一的文件下载接口

python
@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]- 按钮对应的文件生成方法

python
def btn_down_zip(self):
    file = io.BytesIO()

    ... # 填充文件内容

    return ir_actions_act_down(filename, file.getvalue())

[!example]- 封装的 ir_actions_act_down 方法,将数据存入 redis 并返回动作

python
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': []
    }

幻翼 2023年6月29日 15:54 收藏文档