[IMP] render docx template to pdf and etc.

This commit is contained in:
Ivan shirokikh 2021-07-16 19:57:13 +04:00
parent 38d49cac61
commit 9363239909
10 changed files with 408 additions and 52 deletions

View File

@ -1 +1,2 @@
from . import controllers
from . import models

View File

@ -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",
],
}

1
controllers/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import main

120
controllers/main.py Normal file
View File

@ -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)))

View File

@ -1,10 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data>
<template id="assets_backend" name="im assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/client_contracts/static/src/css/mimetypes.css"/>
</xpath>
</template>
</data>
</odoo>

View File

@ -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()
if streams:
writer = Document(streams[0])
composer = Composer(writer)
for stream in streams:
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

View File

@ -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');
}

View File

@ -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;
},
});
});

13
views/assets.xml Executable file
View File

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<template id="assets_backend" name="docx_report_assets" inherit_id="web.assets_backend">
<xpath expr="link[last()]" position="after">
<link rel="stylesheet" type="text/css" href="/docx_report/static/src/css/mimetypes.css"/>
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/docx_report/static/src/js/action_manager_report.js" />
</xpath>
</template>
</odoo>

View File

@ -7,10 +7,10 @@
<field name="inherit_id" ref="base.act_report_xml_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='report_name']" position="attributes">
<attribute name="attrs">{'required': [('report_type', 'not in', ['docx-docx'])]}</attribute>
<attribute name="attrs">{'readonly': [('report_type', 'in', ['docx-docx', 'docx-pdf'])], 'required': [('report_type', 'not in', ['docx-docx', 'docx-pdf'])]}</attribute>
</xpath>
<xpath expr="//field[@name='report_name']" position="after">
<field name="report_docx_template" attrs="{'required': [('report_type', 'in', ['docx-docx'])]}"/>
<field name="report_docx_template" attrs="{'required': [('report_type', 'in', ['docx-docx', 'docx-pdf'])]}"/>
</xpath>
</field>
</record>