odoo_17.0.1/odoo/addons/base/tests/test_reports.py

557 lines
21 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import logging
from base64 import b64decode
from unittest import skipIf
import odoo
import odoo.tests
try:
from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import LAParams, LTFigure, LTTextBox
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfparser import PDFParser
pdfminer = True
except ImportError:
pdfminer = False
_logger = logging.getLogger(__name__)
@odoo.tests.tagged('post_install', '-at_install', 'post_install_l10n')
class TestReports(odoo.tests.TransactionCase):
def test_reports(self):
invoice_domain = [('move_type', 'in', ('out_invoice', 'out_refund', 'out_receipt', 'in_invoice', 'in_refund', 'in_receipt'))]
specific_model_domains = {
'account.report_original_vendor_bill': [('move_type', 'in', ('in_invoice', 'in_receipt'))],
'account.report_invoice_with_payments': invoice_domain,
'account.report_invoice': invoice_domain,
'l10n_th.report_commercial_invoice': invoice_domain,
}
Report = self.env['ir.actions.report']
for report in Report.search([('report_type', 'like', 'qweb')]):
report_model = 'report.%s' % report.report_name
try:
self.env[report_model]
except KeyError:
# Only test the generic reports here
_logger.info("testing report %s", report.report_name)
report_model_domain = specific_model_domains.get(report.report_name, [])
report_records = self.env[report.model].search(report_model_domain, limit=10)
if not report_records:
_logger.info("no record found skipping report %s", report.report_name)
# Test report generation
if not report.multi:
for record in report_records:
Report._render_qweb_html(report.id, record.ids)
else:
Report._render_qweb_html(report.id, report_records.ids)
else:
continue
def test_report_reload_from_attachment(self):
def get_attachments(res_id):
return self.env["ir.attachment"].search([('res_model', "=", "res.partner"), ("res_id", "=", res_id)])
Report = self.env['ir.actions.report'].with_context(force_report_rendering=True)
report = Report.create({
'name': 'test report',
'report_name': 'base.test_report',
'model': 'res.partner',
})
self.env['ir.ui.view'].create({
'type': 'qweb',
'name': 'base.test_report',
'key': 'base.test_report',
'arch': '''
<main>
<div class="article" data-oe-model="res.partner" t-att-data-oe-id="docs.id">
<span t-field="docs.display_name" />
</div>
</main>
'''
})
pdf_text = "0"
def _run_wkhtmltopdf(*args, **kwargs):
return bytes(pdf_text, "utf-8")
self.patch(type(Report), "_run_wkhtmltopdf", _run_wkhtmltopdf)
# sanity check: the report is not set to save attachment
# assert that there are no pre-existing attachment
partner_id = self.env.user.partner_id.id
self.assertFalse(get_attachments(partner_id))
pdf = report._render_qweb_pdf(report.id, [partner_id])
self.assertFalse(get_attachments(partner_id))
self.assertEqual(pdf[0], b"0")
# set the report to reload from attachment and make one
pdf_text = "1"
report.attachment = "'test_attach'"
report.attachment_use = True
report._render_qweb_pdf(report.id, [partner_id])
attach_1 = get_attachments(partner_id)
self.assertTrue(attach_1.exists())
# use the context key to not reload from attachment
# and not create another one
pdf_text = "2"
report = report.with_context(report_pdf_no_attachment=True)
pdf = report._render_qweb_pdf(report.id, [partner_id])
attach_2 = get_attachments(partner_id)
self.assertEqual(attach_2.id, attach_1.id)
self.assertEqual(b64decode(attach_1.datas), b"1")
self.assertEqual(pdf[0], b"2")
# Some paper format examples
PAPER_SIZES = {
(842, 1190): 'A3',
(595, 842): 'A4',
(420, 595): 'A5',
(297, 420): 'A6',
(612, 792): 'Letter',
(612, 1008): 'Legal',
(792, 1224): 'Ledger',
}
class Box:
"""
Utility class to help assertions
"""
def __init__(self, obj, page_height, page_width):
self.x1 = round(obj.x0, 1)
self.y1 = round(page_height-obj.y1, 1)
self.x2 = round(obj.x1, 1)
self.y2 = round(page_height-obj.y0, 1)
self.page_height = page_height
self.page_width = page_width
@property
def height(self):
return self.y2 - self.y1
@property
def width(self):
return self.x2 - self.x1
@property
def top(self):
return self.y1
@property
def left(self):
return self.x1
@property
def end_top(self):
return self.y2
@property
def end_left(self):
return self.x2
@property
def right(self):
return self.page_width - self.x2
@property
def bottom(self):
return self.page_height - self.y2
def __lt__(self, other):
return (self.y1, self.x1, self.y2, self.x2) < (other.y1, other.x1, other.y2, other.x2)
@skipIf(pdfminer is False, "pdfminer not installed")
class TestReportsRenderingCommon(odoo.tests.HttpCase):
def setUp(self):
super().setUp()
self.report = self.env['ir.actions.report'].create({
'name': 'Test Report Partner',
'model': 'res.partner',
'report_name': 'test_report.test_report_partner',
'paperformat_id': self.env.ref('base.paperformat_euro').id,
})
self.partners = self.env['res.partner'].create([{
'name': f'Report record {i}',
} for i in range(2)])
self.report_view = self.env['ir.ui.view'].create({
'type': 'qweb',
'name': 'test_report_partner',
'key': 'test_report.test_report_partner',
'arch': "<t></t>",
})
self.last_pdf_content = None
self.last_pdf_content_saved = False
def _addError(self, result, test, exc_info):
if self.last_pdf_content and not self.last_pdf_content_saved:
self.last_pdf_content_saved = True
self.save_pdf()
super()._addError(result, test, exc_info)
def get_paper_format(self, mediabox):
"""
:param: mediabox: a page mediabox. (Example: (0, 0, 595, 842))
:return: a (format, orientation). Example ('A4', 'portait')
"""
x, y, width, height = mediabox
self.assertEqual((x, y), (0, 0), "Expecting top corner to be 0, 0 ")
orientation = 'portait'
paper_size = (width, height)
if width > height:
orientation = 'landscape'
paper_size = (height, width)
return PAPER_SIZES.get(paper_size, f'custom{paper_size}'), orientation
def create_pdf(self, partners=None, header_content=None, page_content=None, footer_content=None):
if header_content is None:
header_content = '''
<img t-if="company.logo" t-att-src="image_data_uri(company.logo)" style="max-height: 45px;" alt="Logo"/>
<span>Some header Text</span>
'''
if footer_content is None:
footer_content = '''
<div style="text-align:center">Footer for <t t-esc="o.name"/> Page: <span class="page"/> / <span class="topage"/></div>
'''
if page_content is None:
page_content = '''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
</div>
</div>
'''
self.report_view.arch = f'''
<t t-name="test_report.test_report_partner">
<t t-set="company" t-value="res_company"/>
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<div class="header" style="font-family:Sans">
{header_content}
</div>
<div class="article" style="font-family:Sans">
{page_content}
</div>
<div class="footer" style="font-family:Sans">
{footer_content}
</div>
</t>
</t>
</t>
'''
# this templates doesn't use the "web.external_layout" in order to simplify the final result and make the edition of footer and header easier
# this test does not aims to test company base.document.layout, but the rendering only.
if partners is None:
partners = self.partners
self.last_pdf_content = self.env['ir.actions.report'].with_context(force_report_rendering=True)._render_qweb_pdf(self.report, partners.ids)[0]
return self.last_pdf_content
def save_pdf(self):
assert self.last_pdf_content
odoo.tests.save_test_file(self._testMethodName, self.last_pdf_content, 'pdf_', 'pdf', document_type='Report PDF', logger=_logger)
def _get_pdf_pages(self, pdf_content):
ioBytes = io.BytesIO(pdf_content)
parser = PDFParser(ioBytes)
doc = PDFDocument(parser)
return list(PDFPage.create_pages(doc))
def _parse_pdf(self, pdf_content, expected_format=('A4', 'portait')):
"""
:param: pdf_content: the bdf binary content
:param: expected_format: a get_paper_format like format.
:return: list[list[(box, Element)]] a list of element per page
Note: box is a 4 float tuple based on the top left corner to ease ordering of elements.
The result is also rounded to one digit
"""
pages = self._get_pdf_pages(pdf_content)
ressource_manager = PDFResourceManager()
device = PDFPageAggregator(ressource_manager, laparams=LAParams())
interpreter = PDFPageInterpreter(ressource_manager, device)
parsed_pages = []
for page in pages:
self.assertEqual(
self.get_paper_format(page.mediabox),
expected_format,
"Expecting pdf to be in A4 portait format",
) # this is the default expected format and other layout assertions are based on this one.
interpreter.process_page(page)
layout = device.get_result()
elements = []
parsed_pages.append(elements)
for obj in layout:
box = Box(
obj,
page_height=pages[0].mediabox[3],
page_width=pages[0].mediabox[2],
)
if isinstance(obj, LTTextBox):
#inverse x to start from top left corner
elements.append((box, obj.get_text().strip()))
elif isinstance(obj, LTFigure):
elements.append((box, 'LTFigure'))
elements.sort()
return parsed_pages
def assertPageFormat(self, paper_format, orientation):
pdf_content = self.create_pdf()
pages = self._get_pdf_pages(pdf_content)
self.assertEqual(len(pages), 2)
for page in pages:
self.assertEqual(
self.get_paper_format(page.mediabox),
(paper_format, orientation),
f"Expecting pdf to be in {paper_format} {orientation} format",
)
@odoo.tests.tagged('post_install', '-at_install', 'pdf_rendering')
class TestReportsRendering(TestReportsRenderingCommon):
"""
This test aims to test as much as possible the current pdf rendering,
especially multipage headers and footers
(the main reason why we are currently using wkhtmltopdf with patched qt)
A custom template without web.external_layout is used on purpose in order to
easily test headers and footer regarding rendering only,
without using any comany document.layout logic
"""
def test_format_A4(self):
self.report.paperformat_id = self.env.ref('base.paperformat_euro')
self.assertPageFormat('A4', 'portait')
def test_format_letter(self):
self.report.paperformat_id = self.env.ref('base.paperformat_us')
self.assertPageFormat('Letter', 'portait')
def test_format_landscape(self):
paper_format = self.env.ref('base.paperformat_euro')
paper_format.orientation = 'Landscape'
self.report.paperformat_id = paper_format
self.assertPageFormat('A4', 'landscape')
def test_layout(self):
pdf_content = self.create_pdf()
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 2)
page_contents = [[elem[1] for elem in page] for page in pages]
expected_pages_content = [[
'LTFigure',
'Some header Text',
f'Name: {partner.name}',
f'Footer for {partner.name} Page: 1 / 1',
] for partner in self.partners]
self.assertEqual(
page_contents,
expected_pages_content,
)
page_positions = [[elem[0] for elem in page] for page in pages]
logo, header, content, footer = page_positions[0]
# leaving this as reference but this is to fragile to make a strict assertion
# 14.3, 29.6, 43.1, 137.2 # logo
# 19.1, 137.2, 32.5, 214.2 # header
# 111.3, 29.6, 124.8, 123.7 # content
# 751.6, 220.1, 765.1, 375.0 # footer
#
# \ \ / // _ \ | | | || _ \ | |
# \ V /| (_) || |_| || / | |__ / _ \/ _` |/ _ \ Some header Text
# |_| \___/ \___/ |_|_\ |____|\___/\__, |\___/
#
#
# Name: Report record 0
#
#
#
#
#
#
# Footer for Report record 0 Page: 1 / 1
#
#
self.assertEqual(logo.left, content.left, 'Logo and content should have the same left margin')
self.assertEqual(header.left, logo.end_left, 'Header starts after logo')
self.assertGreaterEqual(header.top, logo.top, 'header is vertically centered on logo')
self.assertGreaterEqual(logo.end_top, header.end_top, 'header is vertically centered on logo')
self.assertGreaterEqual(content.top, logo.end_top, 'Content is bellow logo')
self.assertGreaterEqual(footer.top, content.end_top, 'Footer is bellow content')
self.assertGreaterEqual(100, footer.bottom, 'Footer is on the bottom of the page')
self.assertAlmostEqual(footer.left, footer.right, -1, 'Footer is centered on the page')
def test_report_pdf_page_break(self):
partners = self.partners[:2]
page_content = '''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
</div>
<div style="page-break-before:always;background-color:blue">
Last page for <t t-esc="o.name"/>
</div>
</div>
'''
pdf_content = self.create_pdf(partners=partners, page_content=page_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 4, "Expecting 2 pages * 2 partners")
expected_pages_contents = []
for partner in self.partners:
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
f'Name: {partner.name}',
f'Footer for {partner.name} Page: 1 / 2',
])
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
f'Last page for {partner.name}',
f'Footer for {partner.name} Page: 2 / 2',
])
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
def test_pdf_render_page_overflow(self):
nb_lines = 80
page_content = f'''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
<div t-foreach="range({nb_lines})" t-as="pos" t-esc="pos"/>
</div>
</div>
'''
pdf_content = self.create_pdf(page_content=page_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 4, '4 pages are expected, 2 per record (you may ensure `nb_lines` has a correct value to generate an oveflow)')
page_break_at = int(pages[1][2][1].split('\n')[0]) # This element should be the first line, 61 when this test was written
expected_pages_contents = []
for partner in self.partners:
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
f'Name: {partner.name}\n' + '\n'.join([str(i) for i in range(page_break_at)]),
f'Footer for {partner.name} Page: 1 / 2',
])
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
'\n'.join([str(i) for i in range(page_break_at, nb_lines)]),
f'Footer for {partner.name} Page: 2 / 2',
])
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
def test_thead_tbody_repeat(self):
"""
Check that thead and t-foot are repeated after page break inside a tbody
"""
nb_lines = 50
page_content = f'''
<div class="page">
<table class="table">
<thead><tr><th> T1 </th><th> T2 </th><th> T3 </th></tr></thead>
<tbody>
<t t-foreach="range({nb_lines})" t-as="pos">
<tr><td><t t-esc="pos"/></td><td><t t-esc="pos"/></td><td><t t-esc="pos"/></td></tr>
</t>
</tbody>
<tfoot><tr><th> T1 </th><th> T2 </th><th> T3 </th></tr></tfoot>
</table>
</div>
'''
pdf_content = self.create_pdf(page_content=page_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 4, '4 pages are expected, 2 per record (you may ensure `nb_lines` has a correct value to generate an oveflow)')
page_break_at = int(pages[1][5][1]) # This element should be the first line of the table, 28 when this test was written
def expected_table(start, end):
table = ['T1', 'T2', 'T3'] # thead
for i in range(start, end):
table += [str(i), str(i), str(i)]
table += ['T1', 'T2', 'T3'] # tfoot
return table
expected_pages_contents = []
for partner in self.partners:
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
* expected_table(0, page_break_at),
f'Footer for {partner.name} Page: 1 / 2',
])
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
* expected_table(page_break_at, nb_lines),
f'Footer for {partner.name} Page: 2 / 2',
])
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'pdf_rendering')
class TestReportsRenderingLimitations(TestReportsRenderingCommon):
def test_no_clip(self):
"""
Current version will add a fixed margin on top of document
This test demonstrates this limitation
"""
header_content = '''
<div style="background-color:blue">
<div t-foreach="range(15)" t-as="pos" t-esc="'Header %s' % pos"/>
</div>
'''
page_content = '''
<div class="page">
<div style="background-color:red; margin-left:100px">
<div t-foreach="range(10)" t-as="pos" t-esc="'Content %s' % pos"/>
</div>
</div>
'''
# adding a margin on page to avoid bot block to me considered as the same
pdf_content = self.create_pdf(page_content=page_content, header_content=header_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 2, "2 partners")
page = pages[0]
self.assertEqual(len(page), 3, "Expecting 3 box per page, Header, body, footer")
header = page[0][0]
content = page[1][0]
self.assertGreaterEqual(content.top, header.end_top, "EXISTING LIMITATION: large header shouldn't overflow on body, but they do")