535 lines
23 KiB
Python
535 lines
23 KiB
Python
import json
|
||
import logging
|
||
import re
|
||
|
||
from io import BytesIO
|
||
from lxml import html, etree
|
||
from markupsafe import Markup
|
||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||
|
||
from odoo import http, tools
|
||
from odoo.fields import Domain
|
||
from odoo.http import content_disposition, request
|
||
from odoo.tools import format_amount, format_date, format_datetime, format_duration, format_time, html_sanitize
|
||
from odoo.tools.image import image_data_uri
|
||
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
def convert_html_to_pdf(html, footer=False):
|
||
Report = request.env['ir.actions.report'].with_context(page_format='audit_report')
|
||
content = Report._run_wkhtmltopdf([html], footer=footer)
|
||
return PdfFileReader(BytesIO(content))
|
||
|
||
|
||
def is_html_element_empty(root):
|
||
return not root.xpath("//*[translate(normalize-space(.), ' ', '') != '']")
|
||
|
||
|
||
def xpath_has_class(class_name):
|
||
""" Returns an XPath expression that checks whether an element contains the
|
||
specified class name. This provides the same behavior as a hypothetical
|
||
hasclass() function, which is not available in lxml's XPath implementation.
|
||
:param str class_name: Class name """
|
||
return f'contains(concat(" ", normalize-space(@class), " "), " { class_name } ")'
|
||
|
||
|
||
def render_placeholder(text, template_variables):
|
||
for to_replace, value in template_variables.items():
|
||
text = text.replace(to_replace, value)
|
||
return text
|
||
|
||
|
||
def get_toc_pdf(headings, offset=0):
|
||
base_url = request.env['ir.qweb'].get_base_url()
|
||
toc_html = request.env['ir.qweb']._render(
|
||
'accountant_knowledge.audit_report_table_of_content', {
|
||
'base_url': base_url,
|
||
'headings': headings,
|
||
'offset': offset})
|
||
return convert_html_to_pdf(toc_html)
|
||
|
||
|
||
def get_attached_pdfs(root):
|
||
domains = []
|
||
for element in root.xpath(f'.//*[@data-embedded="file" or { xpath_has_class("o_file_box") }]'):
|
||
if element.get('data-embedded') == 'file':
|
||
embedded_props = json.loads(element.get('data-embedded-props', '{}'))
|
||
file_data = embedded_props.get('fileData')
|
||
if file_data:
|
||
file_type = file_data.get('type')
|
||
if file_type == 'binary':
|
||
domains.extend([[
|
||
('mimetype', 'in', ['application/pdf', 'application/pdf;base64']),
|
||
('id', '=', file_data.get('id')),
|
||
('access_token', '=', file_data.get('access_token'))
|
||
]])
|
||
else:
|
||
for link in element.xpath(f'.//*[{ xpath_has_class("o_link_readonly") }]'):
|
||
parsed_url = urlsplit(link.get('href'))
|
||
match = re.search(r'^\/web\/content\/(?P<ir_attachment_id>[0-9]+)$', parsed_url.path)
|
||
if match:
|
||
url_params = parse_qs(parsed_url.query)
|
||
domains.extend([[
|
||
('mimetype', 'in', ['application/pdf', 'application/pdf;base64']),
|
||
('id', '=', int(match.group('ir_attachment_id'))),
|
||
('access_token', '=', url_params.get('access_token', [False])[0])
|
||
]])
|
||
if not domains:
|
||
return
|
||
all_ir_attachments = request.env['ir.attachment'].search(Domain.OR(domains))
|
||
for domain in domains:
|
||
ir_attachments = all_ir_attachments.filtered_domain(domain)
|
||
if ir_attachments:
|
||
yield PdfFileReader(BytesIO(ir_attachments[0].raw))
|
||
|
||
|
||
def get_account_reports_pdfs(root):
|
||
all_account_report_options = []
|
||
for element in root.xpath('.//*[@data-embedded="accountReport"]'):
|
||
embedded_props = json.loads(element.get('data-embedded-props', '{}'))
|
||
all_account_report_options.append(embedded_props.get('options', {}))
|
||
|
||
AccountReport = request.env['account.report'].with_context(
|
||
exclude_page_footer=True,
|
||
page_format='audit_report')
|
||
all_account_reports = AccountReport.browse({
|
||
account_report_options['report_id']
|
||
for account_report_options in all_account_report_options
|
||
})
|
||
|
||
for account_report_options in all_account_report_options:
|
||
company_id = account_report_options.get("forced_companies", [False])[0] \
|
||
or (account_report_options.get("companies") and account_report_options["companies"][0].get("id"))
|
||
account_report_id = account_report_options['report_id']
|
||
account_report = all_account_reports.filtered(
|
||
lambda account_report: account_report.id == account_report_id)
|
||
|
||
if account_report:
|
||
if company_id:
|
||
account_report = account_report.with_company(company_id)
|
||
result = account_report.dispatch_report_action(account_report_options, 'export_to_pdf')
|
||
yield PdfFileReader(BytesIO(result.get('file_content')))
|
||
|
||
|
||
def flatten_outline(outlines, depth=0):
|
||
""" PyPDF represents outlines as a nested structure reflecting the document's
|
||
heading hierarchy. This method returns a generator for linear traversal
|
||
of the outlines in title order.
|
||
Example of PyPDF outline structure:
|
||
[{ "/Title": "h1", ... }, [{ "/Title": "h2", ... }]]
|
||
For this structure, the method will yield:
|
||
(0, { "/Title": "h1", ... }) then (1, { "/Title": "h2", ... }) where
|
||
the first element of the tuple indicates the heading's depth.
|
||
"""
|
||
for outline in outlines:
|
||
if isinstance(outline, list):
|
||
yield from flatten_outline(outline, depth + 1)
|
||
else:
|
||
yield (depth, outline)
|
||
|
||
|
||
def get_links_from_pdf(pdf):
|
||
""" Returns a generator that yields all links present in the given PDF. """
|
||
for page_number, page in enumerate(pdf.pages):
|
||
if '/Annots' not in page:
|
||
continue
|
||
for annotation in page['/Annots']:
|
||
object = annotation.get_object()
|
||
if object['/Subtype'] != '/Link':
|
||
continue
|
||
yield {
|
||
'page_number': page_number,
|
||
'object': object
|
||
}
|
||
|
||
|
||
def delete_all_annotations(page):
|
||
""" Delete all annotations from the given page (i.e: the comments, the links,
|
||
the highlights, the form fields and the markups). """
|
||
if '/Annots' in page:
|
||
del page['/Annots']
|
||
|
||
|
||
def get_front_cover_pdf(article):
|
||
base_url = request.env['ir.qweb'].get_base_url()
|
||
front_cover_layout_pdf = PdfFileReader(BytesIO(
|
||
request.env.ref('accountant_knowledge.front_cover_layout').raw))
|
||
front_cover_html = request.env['ir.qweb']._render('accountant_knowledge.audit_report_front_cover', {
|
||
'audit_report': article.inherited_audit_report_id,
|
||
'base_url': base_url,
|
||
'format_addr': tools.formataddr,
|
||
'format_amount': lambda amount, currency, lang_code=None, trailing_zeroes=True: tools.format_amount(request.env, amount, currency, lang_code, trailing_zeroes),
|
||
'format_date': lambda value, lang_code=None, date_format=False: format_date(request.env, value, lang_code, date_format),
|
||
'format_datetime': lambda value, tz=False, dt_format='medium', lang_code=None: format_datetime(request.env, value, tz, dt_format, lang_code),
|
||
'format_duration': format_duration,
|
||
'format_time': lambda value, tz=False, time_format='medium', lang_code=None: format_time(request.env, value, tz, time_format, lang_code),
|
||
'image_data_uri': image_data_uri,
|
||
})
|
||
front_cover_pdf = convert_html_to_pdf(front_cover_html)
|
||
|
||
writer = PdfFileWriter()
|
||
for k in range(front_cover_pdf.getNumPages()):
|
||
writer.addPage(front_cover_layout_pdf.getPage(0))
|
||
page = writer.getPage(-1)
|
||
page.mergePage(front_cover_pdf.getPage(k))
|
||
page.compressContentStreams()
|
||
|
||
output_stream = BytesIO()
|
||
writer.write(output_stream)
|
||
return PdfFileReader(output_stream)
|
||
|
||
|
||
def get_back_cover_pdf():
|
||
return PdfFileReader(BytesIO(
|
||
request.env.ref('accountant_knowledge.back_cover_layout').raw))
|
||
|
||
|
||
def compute_total_assets(audit_report):
|
||
balance_sheet_report = request.env.ref('account_reports.balance_sheet').with_company(audit_report.company_id)
|
||
balance_sheet_report_options = balance_sheet_report.get_options({
|
||
'forced_companies': audit_report.company_id.ids,
|
||
'date': {
|
||
'date_from': str(audit_report.start_date),
|
||
'date_to': str(audit_report.end_date),
|
||
'mode': 'range',
|
||
'filter': 'custom',
|
||
},
|
||
'rounding_unit': 'decimals',
|
||
})
|
||
balance_sheet_report._init_currency_table(balance_sheet_report_options)
|
||
all_expressions = next(iter(
|
||
balance_sheet_report._compute_expression_totals_for_each_column_group(
|
||
balance_sheet_report.line_ids.expression_ids,
|
||
balance_sheet_report_options).values()))
|
||
total_assets_line = request.env.ref('account_reports.account_financial_report_total_assets0')
|
||
for expression, totals in all_expressions.items():
|
||
if expression.report_line_id == total_assets_line:
|
||
return totals.get('value')
|
||
return 0
|
||
|
||
|
||
def compute_net_profit_and_total_revenue(audit_report):
|
||
profit_and_loss_report = request.env.ref('account_reports.profit_and_loss').with_company(audit_report.company_id)
|
||
profit_and_loss_report_options = profit_and_loss_report.get_options({
|
||
'forced_companies': audit_report.company_id.ids,
|
||
'date': {
|
||
'date_from': str(audit_report.start_date),
|
||
'date_to': str(audit_report.end_date),
|
||
'mode': 'range',
|
||
'filter': 'custom',
|
||
},
|
||
'rounding_unit': 'decimals',
|
||
})
|
||
profit_and_loss_report._init_currency_table(profit_and_loss_report_options)
|
||
all_expressions = next(iter(
|
||
profit_and_loss_report._compute_expression_totals_for_each_column_group(
|
||
profit_and_loss_report.line_ids.expression_ids,
|
||
profit_and_loss_report_options).values()))
|
||
|
||
net_profit = 0
|
||
total_revenue = 0
|
||
|
||
net_profit_report_line = request.env.ref('account_reports.account_financial_report_net_profit0')
|
||
total_revenue_report_line = request.env.ref('account_reports.account_financial_report_revenue0')
|
||
|
||
for expression, totals in all_expressions.items():
|
||
if expression.report_line_id == net_profit_report_line:
|
||
net_profit = totals.get('value')
|
||
elif expression.report_line_id == total_revenue_report_line:
|
||
total_revenue = totals.get('value')
|
||
|
||
return {
|
||
'net_profit': net_profit,
|
||
'total_revenue': total_revenue
|
||
}
|
||
|
||
|
||
def get_template_variables(article):
|
||
audit_report = article.inherited_audit_report_id
|
||
results = compute_net_profit_and_total_revenue(audit_report)
|
||
return {
|
||
"{{ start of period }}": format_date(request.env, audit_report.start_date),
|
||
"{{ end of period }}": format_date(request.env, audit_report.end_date),
|
||
"{{ company name }}": audit_report.company_id.name,
|
||
"{{ total balance sheet }}": format_amount(request.env, compute_total_assets(audit_report), audit_report.company_id.currency_id),
|
||
"{{ revenue }}": format_amount(request.env, results.get('total_revenue', 0), audit_report.company_id.currency_id),
|
||
"{{ net accounting result }}": format_amount(request.env, results.get('net_profit', 0), audit_report.company_id.currency_id)
|
||
}
|
||
|
||
|
||
class KnowledgeAuditReportController(http.Controller):
|
||
|
||
def _get_template_variables(self, article):
|
||
return get_template_variables(article)
|
||
|
||
def _get_html_template_variables(self, article):
|
||
return {}
|
||
|
||
def _get_front_cover_pdf(self, article):
|
||
return get_front_cover_pdf(article)
|
||
|
||
@http.route(
|
||
'/knowledge_accountant/article/<model("knowledge.article"):root_article>/audit_report',
|
||
type='http', auth='user', methods=['GET'])
|
||
def export_article_to_pdf(self, root_article, include_pdf_files, include_child_articles, **kwargs):
|
||
|
||
# Dirty hack to force the print delay to 100ms during our PDF generation
|
||
# It defaults to 1000ms inside `_run_wkhtmltopdf` and as we generate a lot
|
||
# of different pdf files, it adds an unnecessary significant number of times.
|
||
|
||
IrConfigParameterSudo = request.env['ir.config_parameter'].sudo()
|
||
print_delay = IrConfigParameterSudo.get_param('report.print_delay')
|
||
IrConfigParameterSudo.set_param('report.print_delay', 100)
|
||
|
||
body_pdfs = []
|
||
headings = []
|
||
root_article_body = html.fragment_fromstring(root_article.body, create_parent='div')
|
||
generate_headings = root_article_body.find('.//*[@data-embedded="articleIndex"]') is not None
|
||
page_offset_in_body = 0
|
||
base_url = request.env['ir.qweb'].get_base_url()
|
||
|
||
stack = [root_article]
|
||
template_variables = self._get_template_variables(root_article)
|
||
html_template_variables = self._get_html_template_variables(root_article)
|
||
|
||
SUPPORTED_IMAGE_TYPES = {
|
||
'image/jpeg', 'image/png', 'image/gif',
|
||
'image/webp', 'image/svg+xml'
|
||
}
|
||
|
||
def render_article_body(root, template_variables):
|
||
def render_html_placeholder(element, template_variables):
|
||
for to_replace, value in template_variables.items():
|
||
if to_replace not in element.text:
|
||
continue
|
||
node = html.fragment_fromstring(value, create_parent='div')
|
||
element.text = element.text.replace(to_replace, '')
|
||
element.append(node)
|
||
|
||
# Replace all the placeholder values:
|
||
for element in root.iter():
|
||
if element.text:
|
||
element.text = render_placeholder(element.text, template_variables)
|
||
render_html_placeholder(element, html_template_variables)
|
||
if element.tail:
|
||
element.tail = render_placeholder(element.tail, template_variables)
|
||
|
||
elements = []
|
||
for child in root.getchildren():
|
||
elements.append(
|
||
html.tostring(child, encoding='unicode', method='html'))
|
||
return Markup(html_sanitize(''.join(elements)))
|
||
|
||
while stack:
|
||
article = stack.pop()
|
||
root = html.fragment_fromstring(article.body, create_parent='div')
|
||
|
||
# Remove elements with the `d-print-none` class to avoid empty pages:
|
||
for element in root.xpath(f'//*[{ xpath_has_class("d-print-none") }]'):
|
||
parent = element.getparent()
|
||
if parent is not None:
|
||
parent.remove(element)
|
||
|
||
# Replace the embedded images with standard <img /> tags:
|
||
for element in root.xpath('.//*[@data-embedded="file"]'):
|
||
try:
|
||
embedded_props = json.loads(
|
||
element.get('data-embedded-props', '{}'))
|
||
except json.JSONDecodeError:
|
||
continue
|
||
|
||
if not isinstance(embedded_props, dict):
|
||
continue
|
||
|
||
file_data = embedded_props.get('fileData', {})
|
||
if not isinstance(file_data, dict):
|
||
continue
|
||
|
||
is_embedded_image = (
|
||
file_data.get('type') == 'binary'
|
||
and file_data.get('mimetype') in SUPPORTED_IMAGE_TYPES
|
||
)
|
||
|
||
if not is_embedded_image:
|
||
continue
|
||
|
||
if attachment_id := file_data["id"]:
|
||
url = f'/web/content/{attachment_id}'
|
||
if access_token := file_data.get('access_token'):
|
||
url += '?' + urlencode({'access_token': access_token})
|
||
|
||
img = etree.Element('img', src=url, style='max-width: 100%')
|
||
img.tail = element.tail
|
||
parent = element.getparent()
|
||
parent.replace(element, img)
|
||
|
||
article_headings = root.xpath(
|
||
"//*[self::h1 or self::h2 or self::h3][translate(normalize-space(.), ' ', '') != '']")
|
||
article_has_only_one_nonempty_heading = (
|
||
len(article_headings) == 1 and
|
||
not bool(root.xpath(
|
||
"//text()[translate(normalize-space(.), ' ', '') != '' and not(ancestor::h1 or ancestor::h2 or ancestor::h3)]")))
|
||
|
||
# Append a title page if the article only contains an h1, h2 or h3
|
||
if article_has_only_one_nonempty_heading:
|
||
title_page_html = request.env['ir.qweb']._render(
|
||
'accountant_knowledge.audit_report_title_page', {
|
||
'base_url': base_url,
|
||
'title': article_headings[0].text})
|
||
|
||
title_page_pdf = convert_html_to_pdf(title_page_html)
|
||
if generate_headings:
|
||
try:
|
||
for (depth, outline) in flatten_outline(title_page_pdf.outlines):
|
||
headings.append({
|
||
'page_offset_in_body': page_offset_in_body,
|
||
'depth': depth,
|
||
'outline': outline
|
||
})
|
||
except PdfReadError:
|
||
# version 1.26 of PyPDF2 is not capable of generating the outline / headers
|
||
# see https://github.com/py-pdf/pypdf/issues/193
|
||
_logger.warning('Unable to generate Annual Report heading, please update your PyPDF version.')
|
||
generate_headings = False
|
||
|
||
body_pdfs.append(title_page_pdf)
|
||
page_offset_in_body += title_page_pdf.getNumPages()
|
||
|
||
# Append the account reports present in the article:
|
||
account_report_pdfs = list(get_account_reports_pdfs(root))
|
||
body_pdfs.extend(account_report_pdfs)
|
||
page_offset_in_body += sum(
|
||
account_report_pdf.getNumPages() for account_report_pdf in account_report_pdfs)
|
||
|
||
# Append the article body if not empty:
|
||
if not article_has_only_one_nonempty_heading and not is_html_element_empty(root):
|
||
article_body = render_article_body(root, template_variables)
|
||
article_html = request.env['ir.qweb']._render(
|
||
'accountant_knowledge.audit_report_page_layout', {
|
||
'base_url': base_url,
|
||
'body': article_body})
|
||
article_pdf = convert_html_to_pdf(article_html)
|
||
|
||
if generate_headings:
|
||
try:
|
||
for (depth, outline) in flatten_outline(article_pdf.outlines):
|
||
headings.append({
|
||
'page_offset_in_body': page_offset_in_body,
|
||
'depth': depth,
|
||
'outline': outline
|
||
})
|
||
except PdfReadError:
|
||
# version 1.26 of PyPDF2 is not capable of generating the outline / headers
|
||
# see https://github.com/py-pdf/pypdf/issues/193
|
||
_logger.warning('Unable to generate Annual Report heading, please update your PyPDF version.')
|
||
generate_headings = False
|
||
|
||
body_pdfs.append(article_pdf)
|
||
page_offset_in_body += article_pdf.getNumPages()
|
||
|
||
# Append the pdf attachments present in the article:
|
||
if include_pdf_files == 'true':
|
||
attached_pdfs = list(get_attached_pdfs(root))
|
||
body_pdfs.extend(attached_pdfs)
|
||
page_offset_in_body += sum(
|
||
attached_pdf.getNumPages() for attached_pdf in attached_pdfs)
|
||
|
||
# Append the child articles:
|
||
if include_child_articles == 'true':
|
||
stack.extend(article.child_ids.sorted(
|
||
lambda child: child.sequence, reverse=True))
|
||
|
||
front_cover_pdf = self._get_front_cover_pdf(root_article)
|
||
# Create the PDF output:
|
||
writer = PdfFileWriter()
|
||
writer.appendPagesFromReader(front_cover_pdf)
|
||
back_cover_pdf = get_back_cover_pdf()
|
||
toc_pdf = False
|
||
toc_links = []
|
||
if headings:
|
||
toc_pdf = get_toc_pdf(headings)
|
||
# Regenerate the table of content to update the page numbers:
|
||
toc_pdf = get_toc_pdf(headings, offset=toc_pdf.getNumPages())
|
||
|
||
toc_links = list(get_links_from_pdf(toc_pdf))
|
||
|
||
if toc_pdf:
|
||
writer.appendPagesFromReader(toc_pdf,
|
||
after_page_append=delete_all_annotations)
|
||
for body_pdf in body_pdfs:
|
||
writer.appendPagesFromReader(body_pdf)
|
||
writer.appendPagesFromReader(back_cover_pdf)
|
||
|
||
toc_pdf_num_pages = (toc_pdf.getNumPages() if toc_pdf else 0)
|
||
# Add the links:
|
||
for link, heading in zip(toc_links, headings):
|
||
writer.addLink(
|
||
link['page_number'] + front_cover_pdf.getNumPages(),
|
||
heading['outline']['/Page'] + front_cover_pdf.getNumPages() + toc_pdf_num_pages + heading['page_offset_in_body'],
|
||
link['object']['/Rect'],
|
||
[0, 0, 0],
|
||
'/XYZ',
|
||
heading['outline']['/Left'],
|
||
heading['outline']['/Top'],
|
||
heading['outline']['/Zoom'])
|
||
|
||
# Add the outlines:
|
||
writer.setPageMode("/UseOutlines")
|
||
|
||
bookmarks_stack = []
|
||
for heading in headings:
|
||
parent_heading_depth, parent_bookmark = bookmarks_stack[-1] \
|
||
if bookmarks_stack else (0, None)
|
||
|
||
while bookmarks_stack and heading['depth'] <= parent_heading_depth:
|
||
bookmarks_stack.pop()
|
||
parent_heading_depth, parent_bookmark = bookmarks_stack[-1] \
|
||
if bookmarks_stack else (0, None)
|
||
|
||
bookmark = writer.addBookmark(
|
||
heading['outline']['/Title'],
|
||
heading['outline']['/Page'] + front_cover_pdf.getNumPages() + toc_pdf_num_pages + heading['page_offset_in_body'],
|
||
parent_bookmark, None, False, False,
|
||
'/XYZ',
|
||
heading['outline']['/Left'],
|
||
heading['outline']['/Top'],
|
||
heading['outline']['/Zoom']
|
||
)
|
||
|
||
if heading['depth'] >= parent_heading_depth:
|
||
# Record heading depth to handle skipped levels
|
||
bookmarks_stack.append((heading['depth'], bookmark))
|
||
|
||
# Add the page numbers:
|
||
number_of_pages = writer.getNumPages() \
|
||
- front_cover_pdf.getNumPages() \
|
||
- back_cover_pdf.getNumPages()
|
||
|
||
empty_pdf_for_page_numbers = convert_html_to_pdf(
|
||
request.env['ir.qweb']._render(
|
||
'accountant_knowledge.audit_report_empty_document',
|
||
{'number_of_pages': number_of_pages}),
|
||
footer=request.env['ir.qweb']._render(
|
||
'accountant_knowledge.audit_report_footer'))
|
||
|
||
for k in range(number_of_pages):
|
||
page = writer.getPage(k + front_cover_pdf.getNumPages())
|
||
page.mergePage(empty_pdf_for_page_numbers.getPage(k))
|
||
page.compressContentStreams()
|
||
|
||
output_stream = BytesIO()
|
||
writer.write(output_stream)
|
||
pdf_bytes = output_stream.getvalue()
|
||
|
||
# Restore the print delay:
|
||
if print_delay:
|
||
IrConfigParameterSudo.set_param('report.print_delay', print_delay)
|
||
|
||
return request.make_response(pdf_bytes, headers=[
|
||
('Content-Disposition', content_disposition(f'{root_article.name}.pdf')), # File name
|
||
('Content-Type', 'application/pdf'),
|
||
('Content-Length', len(pdf_bytes)),
|
||
])
|