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[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//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 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)), ])