From aa03e0faec71ebc1b6bb4aa1eb5fbf8f65872109 Mon Sep 17 00:00:00 2001 From: Francois Poizat Date: Wed, 5 Mar 2025 16:45:17 +0100 Subject: [PATCH 1/2] [ADD] report_playwright --- report_playwright/__init__.py | 2 + report_playwright/__manifest__.py | 22 + report_playwright/controllers/__init__.py | 1 + report_playwright/controllers/playwright.py | 109 +++++ report_playwright/models/__init__.py | 1 + report_playwright/models/ir_actions_report.py | 456 ++++++++++++++++++ .../src/js/playwright_action_service.esm.js | 61 +++ 7 files changed, 652 insertions(+) create mode 100644 report_playwright/__init__.py create mode 100644 report_playwright/__manifest__.py create mode 100644 report_playwright/controllers/__init__.py create mode 100644 report_playwright/controllers/playwright.py create mode 100644 report_playwright/models/__init__.py create mode 100644 report_playwright/models/ir_actions_report.py create mode 100644 report_playwright/static/src/js/playwright_action_service.esm.js diff --git a/report_playwright/__init__.py b/report_playwright/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/report_playwright/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/report_playwright/__manifest__.py b/report_playwright/__manifest__.py new file mode 100644 index 0000000000..3294383ae8 --- /dev/null +++ b/report_playwright/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Akretion (http://akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Playwright Report Engine", + "summary": "Reporting engine based on playwright", + "version": "18.0.1.0.1", + "category": "Reporting", + "license": "AGPL-3", + "author": "François Poizat (Akretion), Odoo Community Association (OCA)", + "website": "https://github.com/OCA/reporting-engine", + "depends": ["web"], + "external_dependencies": { + "python": ["playwright"], + }, + "assets": { + "web.assets_backend": [ + "report_playwright/static/src/js/playwright_action_service.esm.js", + ], + }, + "data": [], + "installable": True, +} diff --git a/report_playwright/controllers/__init__.py b/report_playwright/controllers/__init__.py new file mode 100644 index 0000000000..579ea86104 --- /dev/null +++ b/report_playwright/controllers/__init__.py @@ -0,0 +1 @@ +from . import playwright diff --git a/report_playwright/controllers/playwright.py b/report_playwright/controllers/playwright.py new file mode 100644 index 0000000000..902f3c2780 --- /dev/null +++ b/report_playwright/controllers/playwright.py @@ -0,0 +1,109 @@ +import json +import logging + +from werkzeug.exceptions import InternalServerError +from werkzeug.urls import url_parse + +from odoo.http import content_disposition, request, route, serialize_exception +from odoo.tools.misc import html_escape +from odoo.tools.safe_eval import safe_eval, time + +from odoo.addons.web.controllers.report import ReportController + +_logger = logging.getLogger(__name__) + + +class ReportController(ReportController): + @route() + def report_routes(self, reportname, docids=None, converter=None, **data): + report = request.env["ir.actions.report"] + context = dict(request.env.context) + + if converter != "playwright-pdf": + return super().report_routes( + reportname=reportname, docids=docids, converter=converter, **data + ) + + if docids: + docids = [int(i) for i in docids.split(",") if i.isdigit()] + 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"]) + + pdf = report.with_context(**context)._render_playwright_pdf( + reportname, docids, data=data + )[0] + pdfhttpheaders = [ + ("Content-Type", "application/pdf"), + ("Content-Length", len(pdf)), + ] + return request.make_response(pdf, headers=pdfhttpheaders) + + @route() + def report_download( + self, data, context=None, token=None + ): # pylint: disable=unused-argument + requestcontent = json.loads(data) + url, type_ = requestcontent[0], requestcontent[1] + reportname = "???" + + try: + if type_ == "playwright-pdf": + converter = "playwright-pdf" + + pattern = "/report/html/" + reportname = url.split(pattern)[1].split("?")[0] + + docids = None + if "/" in reportname: + reportname, docids = reportname.split("/") + + parsed_url = url_parse(url) + data = parsed_url.decode_query(cls=dict) + data["path"] = parsed_url.path + + if docids: + # Generic report: + response = self.report_routes( + reportname, docids=docids, converter=converter, **data + ) + else: + # Particular report: + if "context" in data: + context, data_context = json.loads(context or "{}"), json.loads( + data.pop("context") + ) + context = json.dumps({**context, **data_context}) + response = self.report_routes( + reportname, converter=converter, context=context, **data + ) + + report = request.env["ir.actions.report"]._get_report_from_name( + reportname + ) + filename = "%s.%s" % (report.name, "pdf") + + if docids: + ids = [int(x) for x in docids.split(",") if x.isdigit()] + obj = request.env[report.model].browse(ids) + if report.print_report_name and not len(obj) > 1: + report_name = safe_eval( + report.print_report_name, {"object": obj, "time": time} + ) + filename = "%s.%s" % (report_name, "pdf") + + response.headers.add( + "Content-Disposition", content_disposition(filename) + ) + + return response + else: + return super().report_download(data, context=context, token=token) + except Exception as e: + _logger.exception("Error while generating report %s", reportname) + se = serialize_exception(e) + error = {"code": 200, "message": "Odoo Server Error", "data": se} + res = request.make_response(html_escape(json.dumps(error))) + raise InternalServerError(response=res) from e diff --git a/report_playwright/models/__init__.py b/report_playwright/models/__init__.py new file mode 100644 index 0000000000..a248cf2162 --- /dev/null +++ b/report_playwright/models/__init__.py @@ -0,0 +1 @@ +from . import ir_actions_report diff --git a/report_playwright/models/ir_actions_report.py b/report_playwright/models/ir_actions_report.py new file mode 100644 index 0000000000..d612ea6523 --- /dev/null +++ b/report_playwright/models/ir_actions_report.py @@ -0,0 +1,456 @@ +import io +import logging +import os +import tempfile +import time +from urllib.parse import urlparse +from collections import OrderedDict + +from PIL import Image + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError +from odoo.http import request +from odoo.tools import config +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + +playwright_state = "install" + +try: + from playwright.sync_api import Error, sync_playwright + + with sync_playwright() as p: + try: + p.chromium.connect( + f"{os.environ.get('PLAYWRIGHT_SERVER_URL', 'ws://playwright:3000')}", + timeout=os.environ.get("PLAYWRIGHT_CONNECTIVITY_TIMEOUT", 500), + ) + _logger.info("Connected to Playwright successfuly") + except Error as e: + _logger.info(f"Cannot connect to playwright server {e.message}") + raise Exception from e +except (ModuleNotFoundError, Exception) as e: + _logger.info("You need playwright to print a pdf version of the reports.") +else: + playwright_state = "ok" + _logger.info("Will use the playwright library to print") + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + report_type = fields.Selection( + selection_add=[("playwright-pdf", "playwright")], + ondelete={"playwright-pdf": "cascade"}, + ) + + @api.model + def _build_playwright_options( + self, + paperformat_id, + landscape, + specific_paperformat_args=None, + header_content=None, + footer_content=None, + ): + options = { + "margin": {}, + "format": None, + "sandbox": False, + "landscape": False, + "cookies": [], + } + + if ( + landscape is None + and specific_paperformat_args + and specific_paperformat_args.get("data-report-landscape") + ): + landscape = specific_paperformat_args.get("data-report-landscape") + + # Passing the cookie to playwright in order to resolve internal links. + if request and request.db: + base_url = self._get_report_url() + domain = urlparse(base_url).hostname + options["cookies"] = [ + { + "name": "session_id", + "value": request.session.sid, + "domain": domain, + "httpOnly": True, + "expires": -1, + "path": "/", + } + ] + + if paperformat_id: + if paperformat_id.format and paperformat_id.format != "custom": + options["format"] = paperformat_id.format + + if specific_paperformat_args and specific_paperformat_args.get( + "data-report-margin-top" + ): + options["margin"]["top"] = str( + specific_paperformat_args["data-report-margin-top"] + ) + else: + options["margin"]["top"] = str(paperformat_id.margin_top) + + options["margin"]["left"] = str(paperformat_id.margin_left) + + if specific_paperformat_args and specific_paperformat_args.get( + "data-report-margin-bottom" + ): + options["margin"]["bottom"] = str( + specific_paperformat_args["data-report-margin-bottom"] + ) + else: + options["margin"]["bottom"] = str(paperformat_id.margin_bottom) + + options["margin"]["right"] = str(paperformat_id.margin_right) + + if not landscape and paperformat_id.orientation: + options["landscape"] = str(paperformat_id.orientation) == "Landscape" + + if "landscape" not in options: + options["landscape"] = landscape + + if header_content: + options["headerTemplate"] = header_content + + if footer_content: + options["footerTemplate"] = footer_content + + return options + + @api.model + def _run_playwright( + self, + url, + report_ref=False, + header=None, + footer=None, + landscape=False, + specific_paperformat_args=None, + ): + paperformat_id = ( + self._get_report(report_ref).get_paperformat() + if report_ref + else self.get_paperformat() + ) + + temporary_files = [] + + print_options = self._build_playwright_options( + paperformat_id, + landscape, + specific_paperformat_args=specific_paperformat_args, + header_content=header, + footer_content=footer, + ) + + prefix = "report.body.tmp." + pdf_output_fd, pdf_output_path = tempfile.mkstemp(suffix=".pdf", prefix=prefix) + os.close(pdf_output_fd) + temporary_files.append(pdf_output_path) + + try: + with sync_playwright() as p: + browser = p.chromium.connect( + f"{os.environ.get('PLAYWRIGHT_SERVER_URL', 'ws://playwright:3000')}" + ) + b_context = browser.new_context() + b_context.add_cookies(print_options["cookies"]) + page = b_context.new_page() + page.goto(url) + page.pdf( + header_template=print_options["headerTemplate"], + footer_template=print_options["headerTemplate"], + format=print_options["format"], + margin=print_options["margin"], + landscape=print_options["landscape"], + path=pdf_output_path, + ) + except Exception: + raise + + pdf_streams = [] + with open(pdf_output_path, "rb") as pdf_doc: + pdf_content = pdf_doc.read() + pdf_streams.append(pdf_content) + + for file in temporary_files: + try: + os.unlink(file) + except OSError: + _logger.error("Error when trying to remove file %s" % file) + + return pdf_streams + + @api.model + def get_playwright_state(self): + """Get the current state of wkhtmltopdf: install, ok, upgrade, workers or broken. + * install: Starting state. + * upgrade: The binary is an older version (< 0.12.0). + * ok: A binary was found with a recent version (>= 0.12.0). + * workers: Not enough workers found to perform the pdf rendering process (< 2 workers). + * broken: A binary was found but not responding. + + :return: playwright_state + """ + return playwright_state + + def _render_playwright_pdf_prepare_streams(self, report_ref, data, res_ids=None): + if not data: + data = {} + data.setdefault("report_type", "pdf") + + # access the report details with sudo() but evaluation context as current user + report_sudo = self._get_report(report_ref) + + collected_streams = OrderedDict() + + # Fetch the existing attachments from the database for later use. + # Reload the stream from the attachment in case of 'attachment_use'. + if res_ids: + records = self.env[report_sudo.model].browse(res_ids) + for record in records: + stream = None + attachment = None + if report_sudo.attachment: + attachment = report_sudo.retrieve_attachment(record) + + # Extract the stream from the attachment. + if attachment and report_sudo.attachment_use: + stream = io.BytesIO(attachment.raw) + + # Ensure the stream can be saved in Image. + if attachment.mimetype.startswith("image"): + img = Image.open(stream) + new_stream = io.BytesIO() + img.convert("RGB").save(new_stream, format="pdf") + stream.close() + stream = new_stream + + collected_streams[record.id] = { + "stream": stream, + "attachment": attachment, + } + + # Call 'wkhtmltopdf' to generate the missing streams. + res_ids_wo_stream = [ + res_id + for res_id, stream_data in collected_streams.items() + if not stream_data["stream"] + ] + is_playwright_needed = not res_ids or res_ids_wo_stream + + if is_playwright_needed: + if self.get_playwright_state() == "install": + # playwright is not installed + # the call should be catched before (cf /report/check_wkhtmltopdf) but + # if get_pdf is called manually (email template), the check could be + # bypassed + raise UserError( + _( + "Unable to find playwright on this system. The PDF can not be created." + ) + ) + + # Disable the debug mode in the PDF rendering in order to not split + # the assets bundle + # into separated files to load. This is done because of an issue in wkhtmltopdf + # failing to load the CSS/Javascript resources in time. + # Without this, the header/footer of the reports randomly disappear + # because the resources files are not loaded in time. + # https://github.com/wkhtmltopdf/wkhtmltopdf/issues/2083 + additional_context = {"debug": False} + + # As the assets are generated during the same transaction as the rendering of the + # templates calling them, there is a scenario where the assets are unreachable: when + # you make a request to read the assets while the transaction creating + # them is not done. + # Indeed, when you make an asset request, the controller has to read the + # `ir.attachment` + # table. + # This scenario happens when you want to print a PDF report for the first + # time, as the + # assets are not in cache and must be generated. To workaround this issue, + # we manually + # commit the writes in the `ir.attachment` table. It is done thanks + # to a key in the context. + if ( + not config["test_enable"] + and "commit_assetsbundle" not in self.env.context + ): + additional_context["commit_assetsbundle"] = True + + html = self.with_context(**additional_context)._render_qweb_html( + report_ref, res_ids_wo_stream, data=data + )[0] + url = self._get_report_url() + data["path"] + + ( + bodies, + html_ids, + header, + footer, + specific_paperformat_args, + ) = self.with_context(**additional_context)._prepare_html( + html, report_model=report_sudo.model + ) + + if report_sudo.attachment and set(res_ids_wo_stream) != set(html_ids): + raise UserError( + _( + "The report's template %r is wrong, " + "please contact your " + "administrator. \n\n" + "Can not separate file to save as attachment because the report's " + "template does not contains the" + " attributes 'data-oe-model' and 'data-oe-id' on the div with " + "'article' classname.", + self.name, + ) + ) + + pdf_streams = self._run_playwright( + url, + report_ref=report_ref, + header=header, + footer=footer, + landscape=self._context.get("landscape"), + specific_paperformat_args=specific_paperformat_args, + ) + + # Printing a PDF report without any records. The content could be returned directly. + if not res_ids: + return { + False: { + "stream": io.BytesIO(pdf_streams[0]), + "attachment": None, + } + } + + # Split the pdf for each record using the PDF outlines. + + # Only one record: append the whole PDF. + if len(res_ids_wo_stream) == 1: + collected_streams[res_ids_wo_stream[0]]["stream"] = pdf_streams[0] + return collected_streams + + # In case of multiple docs, we need to split the pdf according the records. + # To do so, we split the pdf based on top outlines computed by wkhtmltopdf. + # An outline is a html tag found on the document. To retrieve this table, + # we look on the pdf structure using pypdf to compute the outlines_pages from + # the top level heading in /Outlines. + html_ids_wo_none = [x for x in html_ids if x] + if len(res_ids_wo_stream) > 1 and set(res_ids_wo_stream) == set( + html_ids_wo_none + ): + for i in range(len(res_ids)): + collected_streams[res_ids[i]]["stream"] = pdf_streams[i] + + return collected_streams + + collected_streams[False] = {"stream": pdf_streams[0], "attachment": None} + + return collected_streams + + @api.model + def _render_playwright_pdf(self, report_ref, res_ids, data=None): + if not data: + data = {} + if isinstance(res_ids, int): + res_ids = [res_ids] + data.setdefault("report_type", "pdf") + # In case of test environment without enough workers to perform calls to wkhtmltopdf, + # fallback to render_html. + if (config["test_enable"] or config["test_file"]) and not self.env.context.get( + "force_report_rendering" + ): + return self._render_qweb_html(report_ref, res_ids, data=data) + + collected_streams = self._render_playwright_pdf_prepare_streams( + report_ref, data, res_ids=res_ids + ) + + # access the report details with sudo() but keep evaluation context as current user + report_sudo = self._get_report(report_ref) + + # Generate the ir.attachment if needed. + if report_sudo.attachment: + attachment_vals_list = [] + for res_id, stream_data in collected_streams.items(): + # An attachment already exists. + if stream_data["attachment"]: + continue + + # if res_id is false + # we are unable to fetch the record, it won't be saved as + # we can't split the documents unambiguously + if not res_id: + _logger.warning( + "These documents were not saved as an attachment " + "because the template of %s doesn't " + "have any headers seperating different " + "instances of it. If you want it saved," + "please print the documents separately", + report_sudo.report_name, + ) + continue + record = self.env[report_sudo.model].browse(res_id) + attachment_name = safe_eval( + report_sudo.attachment, {"object": record, "time": time} + ) + + # Unable to compute a name for the attachment. + if not attachment_name: + continue + + attachment_vals_list.append( + { + "name": attachment_name, + "raw": stream_data["stream"].getvalue(), + "res_model": report_sudo.model, + "res_id": record.id, + "type": "binary", + } + ) + + if attachment_vals_list: + attachment_names = ", ".join(x["name"] for x in attachment_vals_list) + try: + self.env["ir.attachment"].create(attachment_vals_list) + except AccessError: + _logger.info( + "Cannot save PDF report %r attachments for user %r", + attachment_names, + self.env.user.display_name, + ) + else: + _logger.info( + "The PDF documents %r are now saved in the database", + attachment_names, + ) + + # Merge all streams together for a single record. + streams_to_merge = [ + x["stream"] for x in collected_streams.values() if x["stream"] + ] + if len(streams_to_merge) == 1: + pdf_content = streams_to_merge[0] + else: + with self._merge_pdfs(streams_to_merge) as pdf_merged_stream: + pdf_content = pdf_merged_stream + + if res_ids: + _logger.info( + "The PDF report has been generated for model: %s, records %s.", + report_sudo.model, + str(res_ids), + ) + + return pdf_content, "pdf" diff --git a/report_playwright/static/src/js/playwright_action_service.esm.js b/report_playwright/static/src/js/playwright_action_service.esm.js new file mode 100644 index 0000000000..0d34945053 --- /dev/null +++ b/report_playwright/static/src/js/playwright_action_service.esm.js @@ -0,0 +1,61 @@ +/** @odoo-module **/ + +import {download} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; +import {user} from "@web/core/user"; + +function _getReportUrl(action, type, env) { + let url = `/report/${type}/${action.report_name}`; + const actionContext = action.context || {}; + if (action.data && JSON.stringify(action.data) !== "{}") { + // Build a query string with `action.data` (it's the place where reports + // using a wizard to customize the output traditionally put their options) + const options = encodeURIComponent(JSON.stringify(action.data)); + const context = encodeURIComponent(JSON.stringify(actionContext)); + url += `?options=${options}&context=${context}`; + } else { + if (actionContext.active_ids) { + url += `/${actionContext.active_ids.join(",")}`; + } + if (type === "html") { + const context = encodeURIComponent(JSON.stringify(user.context)); + url += `?context=${context}`; + } + } + return url; +} + +async function _triggerDownload(env, action, options, type) { + const url = _getReportUrl(action, type, env); + env.services.ui.block(); + try { + await download({ + url: "/report/download", + data: { + data: JSON.stringify([url, action.report_type]), + context: JSON.stringify(user.context), + }, + }); + } finally { + env.services.ui.unblock(); + } + const onClose = options.onClose; + if (action.close_on_report_download) { + return env.services.action.doAction( + {type: "ir.actions.act_window_close"}, + {onClose} + ); + } else if (onClose) { + onClose(); + } +} + +registry + .category("ir.actions.report handlers") + .add("playwright_handler", async function (action, options, env) { + if (action.report_type === "playwright-pdf") { + await _triggerDownload(env, action, options, "html"); + return Promise.resolve(true); + } + return Promise.resolve(false); + }); From 0bacc27379b5b9ec3103fa8e8869623edabd2371 Mon Sep 17 00:00:00 2001 From: Francois Poizat Date: Tue, 2 Dec 2025 16:40:08 +0100 Subject: [PATCH 2/2] [FIX] attachment generation from non url sources --- report_playwright/models/ir_actions_report.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/report_playwright/models/ir_actions_report.py b/report_playwright/models/ir_actions_report.py index d612ea6523..719d51d95a 100644 --- a/report_playwright/models/ir_actions_report.py +++ b/report_playwright/models/ir_actions_report.py @@ -290,6 +290,13 @@ def _render_playwright_pdf_prepare_streams(self, report_ref, data, res_ids=None) html = self.with_context(**additional_context)._render_qweb_html( report_ref, res_ids_wo_stream, data=data )[0] + if "path" not in data: + report_xml_id = ( + report_ref.xml_id if hasattr(report_ref, "xml_id") else report_ref + ) + data["path"] = ( + f"/report/html/{report_xml_id}/{','.join([str(r) for r in res_ids])}" + ) url = self._get_report_url() + data["path"] (