From 9363239909469342e957a38b8ba91060ca44cf6c Mon Sep 17 00:00:00 2001 From: Ivan shirokikh Date: Fri, 16 Jul 2021 19:57:13 +0400 Subject: [PATCH] [IMP] render docx template to pdf and etc. --- __init__.py | 1 + __manifest__.py | 7 +- controllers/__init__.py | 1 + controllers/main.py | 120 +++++++++++++ data/assets_extension.xml | 10 -- models/ir_actions_report.py | 225 +++++++++++++++++++++---- static/src/css/mimetypes.css | 2 +- static/src/js/action_manager_report.js | 77 +++++++++ views/assets.xml | 13 ++ views/ir_actions_report_views.xml | 4 +- 10 files changed, 408 insertions(+), 52 deletions(-) create mode 100644 controllers/__init__.py create mode 100644 controllers/main.py delete mode 100755 data/assets_extension.xml create mode 100644 static/src/js/action_manager_report.js create mode 100755 views/assets.xml diff --git a/__init__.py b/__init__.py index 0650744..91c5580 100755 --- a/__init__.py +++ b/__init__.py @@ -1 +1,2 @@ +from . import controllers from . import models diff --git a/__manifest__.py b/__manifest__.py index 15bba6b..9fe281d 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { "name": "DOCX report", "summary": """ @@ -8,10 +7,10 @@ "website": "http://rydlab.ru", "category": "Technical", "version": "0.0.1", - "depends": ["base"], - "external_dependencies": {"python": ["docxtpl", "num2words"]}, + "depends": ["base", "web"], + "external_dependencies": {"python": ["docxcompose", "docxtpl"]}, "data": [ - "data/assets_extension.xml", + "views/assets.xml", "views/ir_actions_report_views.xml", ], } diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/controllers/main.py b/controllers/main.py new file mode 100644 index 0000000..91179f6 --- /dev/null +++ b/controllers/main.py @@ -0,0 +1,120 @@ +from json import dumps as json_dumps, loads as json_loads +from logging import getLogger + +from werkzeug.urls import url_decode + +from odoo.http import ( + content_disposition, + request, + route, + serialize_exception as _serialize_exception, +) +from odoo.tools import html_escape +from odoo.tools.safe_eval import safe_eval, time + +from odoo.addons.web.controllers.main import ReportController + +_logger = getLogger(__name__) + + +class DocxReportController(ReportController): + @route() + 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) + _data = dict() + 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"): + # Ignore 'lang' here, because the context in data is the one from the webclient *but* if + # the user explicitely wants to change the lang, this mechanism overwrites it. + _data["context"] = json_loads(data["context"]) + if _data["context"].get("lang") and not _data.get("force_context_lang"): + del _data["context"]["lang"] + context.update(_data["context"]) + if converter == "docx": + docx = report.with_context(context)._render_docx_docx(_docids, data=_data) + docxhttpheaders = [ + ( + "Content-Type", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + # ("Content-Length", len(docx)), + ] + return request.make_response(docx, headers=docxhttpheaders) + elif converter == "pdf" and "docx" in report.report_type: + pdf = report.with_context(context)._render_docx_pdf(_docids, data=_data) + pdfhttpheaders = [ + ( + "Content-Type", + "application/pdf", + ), + ("Content-Length", len(pdf)), + ] + return request.make_response(pdf, headers=pdfhttpheaders) + else: + return super().report_routes( + reportname, docids=docids, converter=converter, **data + ) + + @route() + def report_download(self, data, token, context=None): + requestcontent = json_loads(data) + url, type = requestcontent[0], requestcontent[1] + try: + if type in ["docx-docx", "docx-pdf"]: + converter = "docx" if type == "docx-docx" else "pdf" + extension = "docx" if type == "docx-docx" else "pdf" + + pattern = "/report/%s/" % ("docx" if type == "docx-docx" else "pdf") + reportname = url.split(pattern)[1].split("?")[0] + + docids = None + if "/" in reportname: + reportname, docids = reportname.split("/") + + if docids: + # Generic report: + response = self.report_routes( + reportname, docids=docids, converter=converter, context=context + ) + else: + # Particular report: + data = dict( + url_decode(url.split("?")[1]).items() + ) # decoding the args represented in JSON + 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, extension) + + if docids: + ids = [int(x) for x in docids.split(",")] + 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, extension) + response.headers.add( + "Content-Disposition", content_disposition(filename) + ) + response.set_cookie("fileToken", token) + return response + else: + return super().report_download(data, token, context=context) + except Exception as e: + se = _serialize_exception(e) + error = {"code": 200, "message": "Odoo Server Error", "data": se} + return request.make_response(html_escape(json_dumps(error))) diff --git a/data/assets_extension.xml b/data/assets_extension.xml deleted file mode 100755 index 51bf76c..0000000 --- a/data/assets_extension.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - diff --git a/models/ir_actions_report.py b/models/ir_actions_report.py index 67a15f3..8ace504 100644 --- a/models/ir_actions_report.py +++ b/models/ir_actions_report.py @@ -1,33 +1,128 @@ -import io +from base64 import b64decode from collections import OrderedDict -from jinja2 import Environment as Jinja2Environment +from io import BytesIO from logging import getLogger -from docxcompose.composer import Composer from docx import Document +from docxcompose.composer import Composer from docxtpl import DocxTemplate +from jinja2 import Environment as Jinja2Environment +from requests import codes as codes_request, post as post_request +from requests.exceptions import RequestException -from odoo import _, api, fields, models, SUPERUSER_ID +from odoo import _, api, fields, models from odoo.exceptions import AccessError, UserError -from odoo.sql_db import TestCursor +from odoo.http import request from odoo.tools.safe_eval import safe_eval, time -from ..utils.num2words import num2words_, num2words_currency - _logger = getLogger(__name__) class IrActionsReport(models.Model): - _inherit = "ir.actions.actions" + _inherit = "ir.actions.report" - report_name = fields.Char(required=False) + report_name = fields.Char( + compute="_compute_report_name", inverse="_inverse_report_name", store=True + ) report_type = fields.Selection( - selection_add=[("docx-docx", "DOCX")], ondelete="cascade" + selection_add=[("docx-docx", "DOCX"), ("docx-pdf", "DOCX(PDF)")], + ondelete={"docx-docx": "cascade", "docx-pdf": "cascade"}, ) report_docx_template = fields.Binary( string="Report docx template", ) + @api.depends("report_type", "model") + def _compute_report_name(self): + for record in self: + if ( + record.report_type in ["docx-docx", "docx-pdf"] + and record.model + and record.id + ): + record.report_name = "%s-docx_report+%s" % (record.model, record.id) + else: + record.report_name = False + + def _inverse_report_name(self): + pass + + def retrieve_attachment(self, record): + result = super().retrieve_attachment(record) + if result: + if self.report_type == "docx-docx": + result = ( + result.filtered( + lambda r: r.mimetype + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + or None + ) + elif self.report_type == "docx-pdf": + result = ( + result.filtered(lambda r: r.mimetype == "application/pdf") or None + ) + return result + + @api.model + def _render_docx_pdf(self, res_ids=None, data=None): + if not data: + data = {} + data.setdefault("report_type", "pdf") + + # access the report details with sudo() but evaluation context as current user + self_sudo = self.sudo() + + save_in_attachment = OrderedDict() + # Maps the streams in `save_in_attachment` back to the records they came from + stream_record = dict() + if res_ids: + Model = self.env[self_sudo.model] + record_ids = Model.browse(res_ids) + docx_record_ids = Model + if self_sudo.attachment: + for record_id in record_ids: + attachment = self_sudo.retrieve_attachment(record_id) + if attachment: + stream = self_sudo._retrieve_stream_from_attachment(attachment) + save_in_attachment[record_id.id] = stream + stream_record[stream] = record_id + if not self_sudo.attachment_use or not attachment: + docx_record_ids += record_id + else: + docx_record_ids = record_ids + res_ids = docx_record_ids.ids + + if save_in_attachment and not res_ids: + _logger.info("The PDF report has been generated from attachments.") + self._raise_on_unreadable_pdfs(save_in_attachment.values(), stream_record) + return self_sudo._post_pdf(save_in_attachment), "pdf" + + docx_content = self._render_docx(res_ids, data=data) + pdf_content = self._get_pdf_from_office(docx_content) + + if not pdf_content: + raise UserError( + _( + "Gotenberg converting service not available. The PDF can not be created." + ) + ) + + if res_ids: + self._raise_on_unreadable_pdfs(save_in_attachment.values(), stream_record) + _logger.info( + "The PDF report has been generated for model: %s, records %s." + % (self_sudo.model, str(res_ids)) + ) + return ( + self_sudo._post_pdf( + save_in_attachment, pdf_content=pdf_content, res_ids=res_ids + ), + "pdf", + ) + return pdf_content, "pdf" + + @api.model def _render_docx_docx(self, res_ids=None, data=None): if not data: data = {} @@ -60,24 +155,7 @@ class IrActionsReport(models.Model): _logger.info("The DOCS report has been generated from attachments.") return self_sudo._post_docx(save_in_attachment), "docx" - template = self.report_docx_template - template_path = template._full_path(template.store_fname) - - doc = DocxTemplate(template_path) - - jinja_env = Jinja2Environment() - - functions = { - "number2words": num2words_, - "currency2words": num2words_currency, - } - jinja_env.globals.update(**functions) - - doc.render(data, jinja_env) - - docx_content = io.BytesIO() - doc.save(docx_content) - docx_content.seek(0) + docx_content = self._render_docx(res_ids, data=data) if res_ids: _logger.info( @@ -144,7 +222,8 @@ class IrActionsReport(models.Model): else: try: result = self._merge_docx(streams) - except Exception: + except Exception as e: + _logger.exception(e) raise UserError(_("One of the documents, you try to merge is fallback")) close_streams(streams) @@ -175,9 +254,85 @@ class IrActionsReport(models.Model): return buffer def _merge_docx(self, streams): - writer = Document() - composer = Composer(writer) - for stream in streams: - reader = Document(stream) - composer.append(reader) - return composer.getvalue() + if streams: + writer = Document(streams[0]) + composer = Composer(writer) + for stream in streams[1:]: + reader = Document(stream) + composer.append(reader) + return composer.getvalue() + else: + return streams + + def _render_docx(self, docids, data=None): + if not data: + data = {} + data.setdefault("report_type", "docx") + data = self._get_rendering_context(docids, data) + return self._render_docx_template(self.report_docx_template, values=data) + + def _render_docx_template(self, template, values=None): + 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, + ) + + 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=""), + ) + + data = {key: value for key, value in values.items() if not callable(value)} + functions = {key: value for key, value in values.items() if callable(value)} + + docx_content = BytesIO() + jinja_env = Jinja2Environment() + jinja_env.globals.update(**functions) + + with BytesIO(b64decode(template)) as template_file: + doc = DocxTemplate(template_file) + doc.render(data, jinja_env) + doc.save(docx_content) + + docx_content.seek(0) + + return docx_content + + def _get_pdf_from_office(self, content_stream): + result = None + try: + response = post_request( + "http://gotenberg:8808/convert/office", + files={"file": ("converted_file.docx", content_stream.read())}, + ) + if response.status_code == codes_request.ok: + result = response.content + else: + _logger.warning( + "Gotenberg response: %s - %s" + % (response.status_code, response.content) + ) + except RequestException as e: + _logger.exception(e) + finally: + return result diff --git a/static/src/css/mimetypes.css b/static/src/css/mimetypes.css index 313896c..e7b0185 100755 --- a/static/src/css/mimetypes.css +++ b/static/src/css/mimetypes.css @@ -1,4 +1,4 @@ .o_image[data-mimetype$='msword'], .o_image[data-mimetype$='application/vnd.openxmlformats-officedocument.wordprocessingml.document'] { - background-image: url('/client_contracts/static/src/img/msword.png'); + background-image: url('/docx_report/static/src/img/msword.png'); } diff --git a/static/src/js/action_manager_report.js b/static/src/js/action_manager_report.js new file mode 100644 index 0000000..93160ce --- /dev/null +++ b/static/src/js/action_manager_report.js @@ -0,0 +1,77 @@ +odoo.define("docx_report.ReportActionManager", function (require) { + "use strict"; + + var ActionManager = require("web.ActionManager"); + var framework = require("web.framework"); + var session = require("web.session"); + + ActionManager.include({ + _downloadReport: function (url, action) { + var self = this; + var template_type = (action.report_type && action.report_type.split("-")[0]) || "qweb"; + framework.blockUI(); + return new Promise(function (resolve, reject) { + var type = template_type + "-" + url.split("/")[2]; + var blocked = !session.get_file({ + url: "/report/download", + data: { + data: JSON.stringify([url, type]), + context: JSON.stringify(Object.assign({}, action.context, session.user_context)), + }, + success: resolve, + error: (error) => { + self.call("crash_manager", "rpc_error", error); + reject(); + }, + complete: framework.unblockUI, + }); + if (blocked) { + // AAB: this check should be done in get_file service directly, + // should not be the concern of the caller (and that way, get_file + // could return a promise) + var message = _t("A popup window with your report was blocked. You " + + "may need to change your browser settings to allow " + + "popup windows for this page."); + self.do_warn(_t("Warning"), message, true); + } + }); + }, + _executeReportAction: function (action, options) { + if (action.report_type === "docx-docx") { + return this._triggerDownload(action, options, "docx"); + } else if (action.report_type === "docx-pdf") { + return this._triggerDownload(action, options, "pdf"); + } else { + return this._super.apply(this, arguments); + } + }, + _triggerDownload: function (action, options, type){ + var self = this; + var reportUrls = this._makeReportUrls(action); + return this._downloadReport(reportUrls[type], action).then(function () { + if (action.close_on_report_download) { + var closeAction = { type: "ir.actions.act_window_close" }; + return self.doAction(closeAction, _.pick(options, "on_close")); + } else { + return options.on_close(); + } + }); + }, + _makeReportUrls: function (action) { + var reportUrls = this._super.apply(this, arguments); + reportUrls.docx = "/report/docx/" + action.report_name; + if (_.isUndefined(action.data) || _.isNull(action.data) || + (_.isObject(action.data) && _.isEmpty(action.data))) { + if (action.context.active_ids) { + var activeIDsPath = "/" + action.context.active_ids.join(","); + reportUrls.docx += activeIDsPath + } + } else { + var serializedOptionsPath = "?options=" + encodeURIComponent(JSON.stringify(action.data)); + serializedOptionsPath += "&context=" + encodeURIComponent(JSON.stringify(action.context)); + reportUrls.docx += serializedOptionsPath + } + return reportUrls; + }, + }); +}); diff --git a/views/assets.xml b/views/assets.xml new file mode 100755 index 0000000..7ee77c8 --- /dev/null +++ b/views/assets.xml @@ -0,0 +1,13 @@ + + + +