Public release from ruodoo-project: 19.0 - 2026-05-10 21:19:01 UTC
This commit is contained in:
1
docx_report_generation/models/__init__.py
Normal file
1
docx_report_generation/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import ir_actions_report
|
||||
478
docx_report_generation/models/ir_actions_report.py
Normal file
478
docx_report_generation/models/ir_actions_report.py
Normal file
@ -0,0 +1,478 @@
|
||||
from base64 import b64decode
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import OrderedDict
|
||||
from io import BytesIO
|
||||
from logging import getLogger
|
||||
|
||||
from docx import Document
|
||||
from docxcompose.composer import Composer
|
||||
from docxtpl import DocxTemplate
|
||||
from requests import codes as codes_request, post as post_request
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools.safe_eval import safe_eval, time
|
||||
|
||||
try:
|
||||
from odoo.addons.gotenberg.service.utils import (
|
||||
get_auth, # noqa
|
||||
convert_pdf_from_office_url, # noqa
|
||||
check_gotenberg_installed, # noqa
|
||||
)
|
||||
|
||||
gotenberg_imported = True
|
||||
except ImportError:
|
||||
gotenberg_imported = False
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
|
||||
class IrActionsReport(models.Model):
|
||||
_inherit = "ir.actions.report"
|
||||
|
||||
report_name = fields.Char(
|
||||
compute="_compute_report_name",
|
||||
inverse="_inverse_report_name",
|
||||
store=True,
|
||||
required=False,
|
||||
)
|
||||
report_type = fields.Selection(
|
||||
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):
|
||||
"""TODO: write this method"""
|
||||
pass
|
||||
|
||||
def retrieve_attachment(self, record):
|
||||
"""
|
||||
Searc for existing report file in record's attachments by fields:
|
||||
1. name
|
||||
2. res_model
|
||||
3. res_id
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Prepares the data for report file rendering, calls for the render method
|
||||
and handle rendering result.
|
||||
"""
|
||||
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 and self_sudo.attachment_use:
|
||||
# stream = self_sudo._retrieve_stream_from_attachment(attachment)
|
||||
stream = BytesIO(attachment.raw)
|
||||
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 attachment.")
|
||||
# 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 gotenberg_imported and check_gotenberg_installed()
|
||||
else None
|
||||
)
|
||||
|
||||
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)
|
||||
# saving pdf in attachment.
|
||||
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):
|
||||
"""
|
||||
Prepares the data for report file rendering, calls for the render method
|
||||
and handle rendering result.
|
||||
"""
|
||||
if not data:
|
||||
data = {}
|
||||
data.setdefault("report_type", "docx")
|
||||
|
||||
# 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)
|
||||
stream = BytesIO(attachment.raw)
|
||||
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 DOCS report has been generated from attachment.")
|
||||
return self_sudo._post_docx(save_in_attachment), "docx"
|
||||
|
||||
docx_contents = []
|
||||
for record_id in res_ids:
|
||||
docx_content = self._render_docx([record_id], data=data)
|
||||
docx_contents.append(docx_content)
|
||||
|
||||
if res_ids:
|
||||
_logger.info(
|
||||
"The DOCS report has been generated for model: %s, records %s."
|
||||
% (self_sudo.model, str(res_ids))
|
||||
)
|
||||
return (
|
||||
self_sudo._post_docx(
|
||||
save_in_attachment, docx_contents=docx_contents, res_ids=res_ids
|
||||
),
|
||||
"docx",
|
||||
)
|
||||
return docx_contents, "docx"
|
||||
|
||||
def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None):
|
||||
"""
|
||||
Adds pdf file in record's attachments.
|
||||
TODO: For now bunch generation is not supported.
|
||||
2 execution ways:
|
||||
- save_in_attachment and not res_ids - when get reports from attachments
|
||||
- res_ids and not save_in_attachment - when generate report.
|
||||
"""
|
||||
self_sudo = self.sudo()
|
||||
attachment_vals_list = []
|
||||
if save_in_attachment:
|
||||
# here get streams from save_in_attachment, make pdf file and return it
|
||||
# bunch generation here is already realized.
|
||||
reports_data = list(save_in_attachment.values())
|
||||
if len(reports_data) == 1:
|
||||
# If only one report, no need to merge files. Returns as is.
|
||||
return reports_data[0].getvalue()
|
||||
else:
|
||||
return self._merge_pdfs(reports_data)
|
||||
for res_id in res_ids:
|
||||
record = self.env[self_sudo.model].browse(res_id)
|
||||
if not self_sudo.attachment:
|
||||
attachment_name = False
|
||||
else:
|
||||
attachment_name = safe_eval(
|
||||
self_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": pdf_content, # stream_data['stream'].getvalue(),
|
||||
"res_model": self_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,
|
||||
)
|
||||
return pdf_content
|
||||
|
||||
def _post_docx(self, save_in_attachment, docx_contents=None, res_ids=None):
|
||||
"""
|
||||
Adds generated file in attachments.
|
||||
"""
|
||||
|
||||
def close_streams(streams):
|
||||
for stream in streams:
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(save_in_attachment) == 1 and not docx_contents:
|
||||
return list(save_in_attachment.values())[0].getvalue()
|
||||
|
||||
streams = []
|
||||
if docx_contents:
|
||||
# Build a record_map mapping id -> record
|
||||
record_map = {
|
||||
r.id: r
|
||||
for r in self.env[self.model].browse(
|
||||
[res_id for res_id in res_ids if res_id]
|
||||
)
|
||||
}
|
||||
# If no value in attachment or no record specified, only append the whole docx.
|
||||
if not record_map or not self.attachment:
|
||||
streams.extend(docx_contents)
|
||||
else:
|
||||
for res_id, docx_content in zip(res_ids, docx_contents):
|
||||
if res_id in record_map and not res_id in save_in_attachment:
|
||||
new_stream = self._postprocess_docx_report(
|
||||
record_map[res_id], docx_content
|
||||
)
|
||||
# If the buffer has been modified, mark the old buffer to be closed as well.
|
||||
if new_stream and new_stream != docx_content:
|
||||
close_streams([docx_content])
|
||||
docx_content = new_stream
|
||||
streams.append(docx_content)
|
||||
if self.attachment_use:
|
||||
for stream in save_in_attachment.values():
|
||||
streams.append(stream)
|
||||
if len(streams) == 1:
|
||||
result = streams[0].getvalue()
|
||||
else:
|
||||
try:
|
||||
merged_stream = self._merge_docx(streams)
|
||||
result = merged_stream.getvalue()
|
||||
except Exception as e:
|
||||
_logger.exception(e)
|
||||
raise UserError(
|
||||
_("One of the documents you try to merge caused failure.")
|
||||
)
|
||||
|
||||
close_streams(streams)
|
||||
return result
|
||||
|
||||
def _postprocess_docx_report(self, record, buffer):
|
||||
"""
|
||||
Creates the record in the "ir.attachment" model.
|
||||
"""
|
||||
attachment_name = safe_eval(self.attachment, {"object": record, "time": time})
|
||||
if not attachment_name:
|
||||
return None
|
||||
attachment_vals = {
|
||||
"name": attachment_name,
|
||||
"raw": buffer.getvalue(),
|
||||
"res_model": self.model,
|
||||
"res_id": record.id,
|
||||
"type": "binary",
|
||||
}
|
||||
try:
|
||||
self.env["ir.attachment"].create(attachment_vals)
|
||||
except AccessError:
|
||||
_logger.info(
|
||||
"Cannot save DOCX report %r as attachment", attachment_vals["name"]
|
||||
)
|
||||
else:
|
||||
_logger.info(
|
||||
"The DOCX document %s is now saved in the database",
|
||||
attachment_vals["name"],
|
||||
)
|
||||
return buffer
|
||||
|
||||
@staticmethod
|
||||
def _merge_docx(streams):
|
||||
"""
|
||||
Joins several docx files into one with page breaks between them.
|
||||
"""
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
merged_document = Document()
|
||||
composer = Composer(merged_document)
|
||||
|
||||
for stream in streams:
|
||||
document = Document(stream)
|
||||
|
||||
if composer.doc.paragraphs:
|
||||
composer.doc.add_page_break()
|
||||
|
||||
composer.append(document)
|
||||
|
||||
merged_stream = BytesIO()
|
||||
merged_document.save(merged_stream)
|
||||
merged_stream.seek(0)
|
||||
|
||||
return merged_stream
|
||||
|
||||
def _render_docx(self, docids: list, data: dict = None):
|
||||
"""
|
||||
Receive the data for rendering and calls for it.
|
||||
|
||||
docids: list of record's ids for which report is generated.
|
||||
data: dict, conains "context", "report_type".
|
||||
"""
|
||||
if not data:
|
||||
data = {}
|
||||
data.setdefault("report_type", "docx")
|
||||
data = self._get_rendering_context(
|
||||
self, docids, data
|
||||
) # self contains current record of ir.actions.report model.
|
||||
return self._render_docx_template(self.report_docx_template, values=data)
|
||||
|
||||
def _render_docx_template(self, template: bytes, values: dict = None):
|
||||
"""
|
||||
docx file rendering itself.
|
||||
"""
|
||||
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(
|
||||
record=values["docs"],
|
||||
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=""),
|
||||
)
|
||||
|
||||
record_to_render = values["docs"]
|
||||
docs = {
|
||||
key: record_to_render[key]
|
||||
for key in record_to_render._fields.keys()
|
||||
if not isinstance(record_to_render[key], str)
|
||||
}
|
||||
docs.update(
|
||||
{
|
||||
key: self._parse_markup(record_to_render[key])
|
||||
for key in record_to_render._fields.keys()
|
||||
if isinstance(record_to_render[key], str)
|
||||
}
|
||||
)
|
||||
values["docs"] = docs
|
||||
|
||||
docx_content = BytesIO()
|
||||
with BytesIO(b64decode(template)) as template_file:
|
||||
doc = DocxTemplate(template_file)
|
||||
doc.render(values)
|
||||
doc.save(docx_content)
|
||||
docx_content.seek(0)
|
||||
return docx_content
|
||||
|
||||
@staticmethod
|
||||
def _parse_markup(markup_data: str):
|
||||
"""
|
||||
Extracts data from field of Html type and returns them in text format,
|
||||
without html tags.
|
||||
"""
|
||||
soup = BeautifulSoup(markup_data)
|
||||
data_arr = list(soup.strings)
|
||||
return "\n".join(data_arr)
|
||||
|
||||
@staticmethod
|
||||
def _get_pdf_from_office(content_stream):
|
||||
"""
|
||||
Converting docx into pdf with Gotenberg service.
|
||||
"""
|
||||
result = None
|
||||
url = convert_pdf_from_office_url()
|
||||
auth = get_auth()
|
||||
try:
|
||||
response = post_request(
|
||||
url,
|
||||
files={"file": ("converted_file.docx", content_stream.read())},
|
||||
auth=auth,
|
||||
)
|
||||
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
|
||||
Reference in New Issue
Block a user