第十九章 报表
我们这里所说的报表,实际上指的是报表动作(ir.actions.report),不是应用意义上的报表文件. 报表动作是连接WC与QWeb引擎的桥梁, 它负责将模型数据与QWeb的模板文件相结合, 最终渲染成为我们看到的报表文件.我们编程意义上的定义一个报表,实际上也是定义一个报表动作,本章我们就来详细了解以下odoo中报表的工作机制.
报表的工作原理
我们在第一部分介绍报表时只简单介绍了如何定义一个报表,并没有详细介绍报表的工作机制. 实际上,我们在第一部分定义的报表就是一个报表动作.
<record model="ir.actions.report" id="sale_tag_report.report">
<field name="name">标签打印</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">sale_tag_report.tag</field>
<field name="print_report_name">(object.name)</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
</record>
我们在定义了报表动作之后, 当我们在Web Client中点击了打印报表按钮之后, Web Client会向Controller发起一个路径为/web/action/load的请求, 该请求携带了要请求的动作ID和相关的模型ID和记录.
Controller在收到请求以后,会校验参数中的动作ID, 如果确认为报表动作那么会在上下文中加入bin_size参数并设置为True, 然后继续读取动作的值,并返回给前端.
下面是一个报表动作的返回示例:
{
"id": 297,
"name": "Invoices",
"type": "ir.actions.report",
"binding_type": "report",
"report_type": "qweb-html",
"report_name": "account.report_invoice_with_payments",
"xml_id": "account.account_invoices",
"help": false,
"binding_model_id": [
433,
"Journal Entry"
],
"binding_view_types": "list,form",
"display_name": "Invoices"
}
WebClient在收到动作以后,根据不同地报表类型进行不同的处理工作(关于WebClient相关的知识请参考第四部分),发起不同类型的请求.
整个工作的流程图如下:
报表动作
在odoo中报表动作有三种类型,分别是:
- html(qweb-html)
- pdf (qweb-pdf)
- text (qweb-text)
html类型是HTML页面的报表,如果PDF类型报表不可用(缺少Htmltopdf插件)时,会自动使用html类型的报表.而text类型的报表是用来直接驱动打印机进行打印的命令式报表.
class IrActionsReport(models.Model):
_name = 'ir.actions.report'
_description = 'Report Action'
_inherit = 'ir.actions.actions'
_table = 'ir_act_report_xml'
_sequence = 'ir_actions_id_seq'
_order = 'name'
我们从报表动作的定义可以看出, 报表动作本质上还是动作的一种, 只不过由于相对比较特殊被单独拎了出来. 报表存储在数据库中的ir_act_report_xml表中.
每个报表都会绑定一个数据模型,动作报表中的model字段用来存储数据模型的名称,model_id则是根据model名称对应的Many2one类型的字段,可以被直接使用的快捷字段.
报表动作用来指定打印模板的字段是report_name, 它指定是qweb的模板文件, 在打印过程中会使用该模板进行渲染. report_file是打印模板的文件路径.
默认情况下, 报表动作不论是在表单视图或者树形列表视图中都可以打印, 用户也可以选择只在树形列表视图中勾选后进行打印, 方法就是把报表动作的Multi字段设置为True.
打印出来的文件名称也可以在print_report_name字段中自定义,该字段支持python语法动态赋值.下面是一个例子:
(object.state in ('draft', 'sent') and 'Quotation - %s' % (object.name)) or 'Order - %s' % (object.name)
object这里指的是模型的记录数据.
另外, 打印报表可以作为附件存储在系统中,当用户第二次打印的时候, 直接从附件中读取第一次打印的结果返回,不再进行重复打印. 方法是把attachment_use字段设置为True, 第一次打印的结果将被存储在attachment字段中.
打印赋值过程
我们在之前的介绍中知道了, WebClient会根据返回的动作结果再次发起请求, 最终实现报表的渲染(html)或者下载(PDF). 那么这个过程是什么样子的呢?下面我们就来详细看一下这个赋值过程.
我们知道WebClient第二次请求时,会根据不同报表类型组成URL格式:/report/
@http.route([
'/report/<converter>/<reportname>',
'/report/<converter>/<reportname>/<docids>',
], type='http', auth='user', website=True)
def report_routes(self, reportname, docids=None, converter=None, **data):
report = request.env['ir.actions.report']._get_report_from_name(reportname)
context = dict(request.env.context)
if docids:
docids = [int(i) for i in docids.split(',')]
if data.get('options'):
data.update(json.loads(data.pop('options')))
if data.get('context'):
data['context'] = json.loads(data['context'])
context.update(data['context'])
if converter == 'html':
html = report.with_context(context)._render_qweb_html(docids, data=data)[0]
return request.make_response(html)
elif converter == 'pdf':
pdf = report.with_context(context)._render_qweb_pdf(docids, data=data)[0]
pdfhttpheaders = [('Content-Type', 'application/pdf'), ('Content-Length', len(pdf))]
return request.make_response(pdf, headers=pdfhttpheaders)
elif converter == 'text':
text = report.with_context(context)._render_qweb_text(docids, data=data)[0]
texthttpheaders = [('Content-Type', 'text/plain'), ('Content-Length', len(text))]
return request.make_response(text, headers=texthttpheaders)
else:
raise werkzeug.exceptions.HTTPException(description='Converter %s not implemented.' % converter)
ReportController的核心职责就是根据不同的报表类型分发到不同的报表渲染方法内,具体的渲染工作是由报表动作自己内部的不同方法进行处理的.
根据对_render_qweb_html,_report_qweb_pdf,_report_qweb_text方法的分析,他它们最终都指向了核心渲染方法_render_template.
def _render_template(self, template, values=None):
"""Allow to render a QWeb template python-side. This function returns the 'ir.ui.view'
render but embellish it with some variables/methods used in reports.
:param values: additional methods/variables used in the rendering
:returns: html representation of the template
:rtype: bytes
"""
if values is None:
values = {}
context = dict(self.env.context, inherit_branding=False)
# Browse the user instead of using the sudo self.env.user
user = self.env['res.users'].browse(self.env.uid)
website = None
if request and hasattr(request, 'website'):
if request.website is not None:
website = request.website
context = dict(context, translatable=context.get('lang') != request.env['ir.http']._get_default_lang().code)
view_obj = self.env['ir.ui.view'].sudo().with_context(context)
values.update(
time=time,
context_timestamp=lambda t: fields.Datetime.context_timestamp(self.with_context(tz=user.tz), t),
user=user,
res_company=user.company_id,
website=website,
web_base_url=self.env['ir.config_parameter'].sudo().get_param('web.base.url', default=''),
)
return view_obj._render_template(template, values).encode()
_render_template的作用是将一些环境变量封装起来,然后使用ir.ui.view对象的_render_template方法将报表最终渲染出来. 也就是说, 最终的渲染方法是使用的视图对象的渲染方法.
另外,在每种报表渲染之前,都会使用名叫_get_rendering_context的方法获取报表渲染前的数据变量. 如果模型自己定义了报表变量, 那么报表动作将会使用模型自己定义的变量进行渲染(_get_report_value),否则将使用统一的变量机型渲染:
- doc_ids: 报表渲染的记录IDS
- doc_model: 报表渲染的数据模型
- docs: 报表渲染的数据记录集
动态报表
如果想要对报表进行动态的渲染, 只需要根据数据的值,结合t-if语法进行判断即可,这个在第一部分已经讨论过了. 这里想要阐明的是(也是客户实际需求出现过的)根据不同的记录值显示不同的报表动作名称.
举个例子, 在发货单报表打印中, 有个一报表名为Delivery Slip, 我们希望这个报表打印的名称能够根据不同的单据显示不同的名称,发货单显示为Deliver Slip, 退货单显示为Return Note.
实际上,该功能已经超出了报表定义的范畴, 涉及到了视图渲染的过程,这里简单说明优化下,实现这个功能的难点在于, 我们在渲染视图的时候, 首先调用了load_views加载视图, 而此时我们尚未读取记录数据, 也就因此无法根据记录的值改变动作的名称.
当然, 要实现这个需求也是有办法的,只不过要绕点圈子. 笔者已经解决了这个问题,并把它集成在了欧姆基础模块中.
打印报表前的校验
实际项目中,有些客户提出了想要控制打印按钮的需求, 例如, 只希望在调拨单完成时打印调拨单.
这时候,我们需要在用户点击了打印按钮之后, 系统渲染报表之前, 嵌入一个新的校验逻辑, 如果不满足客户规定的条件, 那么我们就弹出提示框给客户以警告.
笔者已经将该思路集成到了上面提到的欧姆基础模块中, 下面我们来简单看一下用法和示例:
class stock_picking(models.Model):
_inherit="stock.picking"
def _pre_report_action(self):
for picking in self:
if picking.state != 'done':
raise UserError("Unconfirmed delivery, unable to print the delivery note.")
在需要进行打印前鉴权的模型中定义方法_pre_report_action, 然后在该方法内编写业务逻辑即可, 如果不符合条件则抛出异常. 最后的实现效果如下:
报表布局(layout)
odoo在默认情况下支持4个报表布局,分别是:
- *
而他们的都继承自基础布局(basic_layout)