Initial commit: OCA Report packages (45 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:05 +02:00
commit 2f4db400df
2543 changed files with 469120 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from . import py3o_template
from . import ir_actions_report
from . import py3o_report

View file

@ -0,0 +1,139 @@
# Copyright 2018 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import html
import logging
import time
from base64 import b64decode
from odoo.tools import mail, misc
logger = logging.getLogger(__name__)
try:
from genshi.core import Markup
except ImportError:
logger.debug("Cannot import py3o.template")
def format_multiline_value(value):
if value:
return Markup(
html.escape(value)
.replace("\n", "<text:line-break/>")
.replace("\t", "<text:s/><text:s/><text:s/><text:s/>")
)
return ""
def display_address(address_record, without_company=False):
return address_record._display_address(without_company=without_company)
class Py3oParserContext(object):
def __init__(self, env):
self._env = env
self.localcontext = {
"user": self._env.user,
"lang": self._env.lang,
# Odoo default format methods
"o_format_lang": self._format_lang,
# prefixes with o_ to avoid nameclash with default method provided
# by py3o.template
"o_format_date": self._format_date,
"o_format_datetime": self._format_datetime,
# give access to the time lib
"time": time,
# keeps methods from report_sxw to ease migration
"display_address": display_address,
"formatLang": self._old_format_lang,
"format_multiline_value": format_multiline_value,
"html_sanitize": mail.html2plaintext,
"b64decode": b64decode,
}
def _format_lang(
self,
value,
lang_code=False,
digits=None,
grouping=True,
monetary=False,
dp=False,
currency_obj=False,
no_break_space=True,
):
env = self._env
if lang_code:
context = dict(env.context, lang=lang_code)
env = env(context=context)
formatted_value = misc.formatLang(
env,
value,
digits=digits,
grouping=grouping,
monetary=monetary,
dp=dp,
currency_obj=currency_obj,
)
if currency_obj and currency_obj.symbol and no_break_space:
parts = []
if currency_obj.position == "after":
parts = formatted_value.rsplit(" ", 1)
elif currency_obj and currency_obj.position == "before":
parts = formatted_value.split(" ", 1)
if parts:
formatted_value = "\N{NO-BREAK SPACE}".join(parts)
return formatted_value
def _format_date(self, value, lang_code=False, date_format=False):
return misc.format_date(
self._env, value, lang_code=lang_code, date_format=date_format
)
def _format_datetime(self, value, tz=False, dt_format="medium", lang_code=False):
return misc.format_datetime(
self._env, value, tz=tz, dt_format=dt_format, lang_code=lang_code
)
def _old_format_lang(
self,
value,
digits=None,
date=False,
date_time=False,
grouping=True,
monetary=False,
dp=False,
currency_obj=False,
):
"""
:param value: The value to format
:param digits: Number of digits to display by default
:param date: True if value must be formatted as a date (default False)
:param date_time: True if value must be formatted as a datetime
(default False)
:param grouping: If value is float and grouping is True, the value will
be formatted with the appropriate separators between
figures according to the current lang specifications
:param monetary: If value is float and monetary is True and grouping is
True the value will be formatted according to the
monetary format defined for the current lang
:param dp: Decimal precision
:param currency_obj: If provided the currency symbol will be added to
value at position defined by the currency object
:return: The formatted value
"""
if not date and not date_time:
return self._format_lang(
value,
digits=digits,
grouping=grouping,
monetary=monetary,
dp=dp,
currency_obj=currency_obj,
no_break_space=True,
)
return self._format_date(value)

View file

@ -0,0 +1,196 @@
# Copyright 2013 XCG Consulting (http://odoo.consulting)
# Copyright 2018 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.misc import find_in_path
from odoo.tools.safe_eval import safe_eval, time
logger = logging.getLogger(__name__)
try:
from py3o.formats import Formats
except ImportError:
logger.debug("Cannot import py3o.formats")
PY3O_CONVERSION_COMMAND_PARAMETER = "py3o.conversion_command"
class IrActionsReport(models.Model):
"""Inherit from ir.actions.report to allow customizing the template
file. The user cam chose a template from a list.
The list is configurable in the configuration tab, see py3o_template.py
"""
_inherit = "ir.actions.report"
@api.constrains("py3o_filetype", "report_type")
def _check_py3o_filetype(self):
for report in self:
if report.report_type == "py3o" and not report.py3o_filetype:
raise ValidationError(
_("Field 'Output Format' is required for Py3O report")
)
@api.model
def _get_py3o_filetypes(self):
formats = Formats()
names = formats.get_known_format_names()
selections = []
for name in names:
description = name
if formats.get_format(name).native:
description = description + " " + _("(Native)")
selections.append((name, description))
return selections
report_type = fields.Selection(
selection_add=[("py3o", "py3o")],
ondelete={
"py3o": "cascade",
},
)
py3o_filetype = fields.Selection(
selection="_get_py3o_filetypes", string="Output Format"
)
is_py3o_native_format = fields.Boolean(compute="_compute_is_py3o_native_format")
py3o_template_id = fields.Many2one("py3o.template", "Template")
module = fields.Char(help="The implementer module that provides this report")
py3o_template_fallback = fields.Char(
"Fallback",
size=128,
help=(
"If the user does not provide a template this will be used "
"it should be a relative path to root of YOUR module "
"or an absolute path on your server."
),
)
py3o_multi_in_one = fields.Boolean(
string="Multiple Records in a Single Report",
help="If you execute a report on several records, "
"by default Odoo will generate a ZIP file that contains as many "
"files as selected records. If you enable this option, Odoo will "
"generate instead a single report for the selected records.",
)
lo_bin_path = fields.Char(
string="Path to the libreoffice runtime", compute="_compute_lo_bin_path"
)
is_py3o_report_not_available = fields.Boolean(
compute="_compute_py3o_report_not_available"
)
msg_py3o_report_not_available = fields.Char(
compute="_compute_py3o_report_not_available"
)
@api.model
def _register_hook(self):
self._validate_reports()
@api.model
def _validate_reports(self):
"""Check if the existing py3o reports should work with the current
installation.
This method log a warning message into the logs for each report
that should not work.
"""
for report in self.search([("report_type", "=", "py3o")]):
if report.is_py3o_report_not_available:
logger.warning(report.msg_py3o_report_not_available)
@api.model
def _get_lo_bin(self):
lo_bin = (
self.env["ir.config_parameter"]
.sudo()
.get_param(PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice")
)
try:
lo_bin = find_in_path(lo_bin)
except IOError:
lo_bin = None
return lo_bin
@api.depends("report_type", "py3o_filetype")
def _compute_is_py3o_native_format(self):
fmt = Formats()
for rec in self:
rec.is_py3o_native_format = False
if not rec.report_type == "py3o" or not rec.py3o_filetype:
continue
filetype = rec.py3o_filetype
rec.is_py3o_native_format = fmt.get_format(filetype).native
def _compute_lo_bin_path(self):
lo_bin = self._get_lo_bin()
for rec in self:
rec.lo_bin_path = lo_bin
@api.depends("lo_bin_path", "is_py3o_native_format", "report_type")
def _compute_py3o_report_not_available(self):
for rec in self:
rec.is_py3o_report_not_available = False
rec.msg_py3o_report_not_available = ""
if not rec.report_type == "py3o":
continue
if not rec.is_py3o_native_format and not rec.lo_bin_path:
rec.is_py3o_report_not_available = True
rec.msg_py3o_report_not_available = (
_(
"The libreoffice runtime is required to genereate the "
"py3o report '%s' but is not found into the bin path. You "
"must install the libreoffice runtime on the server. If "
"the runtime is already installed and is not found by "
"Odoo, you can provide the full path to the runtime by "
"setting the key 'py3o.conversion_command' into the "
"configuration parameters."
)
% rec.name
)
@api.model
def get_from_report_name(self, report_name, report_type):
return self.search(
[("report_name", "=", report_name), ("report_type", "=", report_type)]
)
@api.model
def _render_py3o(self, report_ref, res_ids, data=None):
report = self._get_report(report_ref)
if report.report_type != "py3o":
raise RuntimeError(
"py3o rendition is only available on py3o report.\n"
"(current: '{}', expected 'py3o'".format(report.report_type)
)
return (
self.env["py3o.report"]
.create({"ir_actions_report_id": report.id})
.create_report(res_ids, data)
)
def gen_report_download_filename(self, res_ids, data):
"""Override this function to change the name of the downloaded report"""
self.ensure_one()
report = self.get_from_report_name(self.report_name, self.report_type)
if report.print_report_name and not len(res_ids) > 1:
obj = self.env[self.model].browse(res_ids)
return safe_eval(report.print_report_name, {"object": obj, "time": time})
return "{}.{}".format(self.name, self.py3o_filetype)
def _get_attachments(self, res_ids):
"""Return the report already generated for the given res_ids"""
self.ensure_one()
save_in_attachment = {}
if res_ids:
# Dispatch the records by ones having an attachment
Model = self.env[self.model]
record_ids = Model.browse(res_ids)
if self.attachment:
for record_id in record_ids:
attachment_id = self.retrieve_attachment(record_id)
if attachment_id:
save_in_attachment[record_id.id] = attachment_id
return save_in_attachment

View file

@ -0,0 +1,402 @@
# Copyright 2013 XCG Consulting (http://odoo.consulting)
# Copyright 2016 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import base64
import logging
import os
import subprocess
import sys
import tempfile
import warnings
from base64 import b64decode
from contextlib import closing
from io import BytesIO
from zipfile import ZIP_DEFLATED, ZipFile
import pkg_resources
from odoo import _, api, fields, models, tools
from odoo.exceptions import AccessError
from odoo.tools.safe_eval import safe_eval, time
from ._py3o_parser_context import Py3oParserContext
logger = logging.getLogger(__name__)
try:
# workaround for https://github.com/edgewall/genshi/issues/15
# that makes runbot build red because of the DeprecationWarning
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
from py3o.template import Template
from py3o import formats
except ImportError:
logger.debug("Cannot import py3o.template")
try:
from py3o.formats import Formats, UnkownFormatException
except ImportError:
logger.debug("Cannot import py3o.formats")
try:
from PyPDF2 import PdfFileReader, PdfFileWriter
except ImportError:
logger.debug("Cannot import PyPDF2")
_extender_functions = {}
class TemplateNotFound(Exception):
pass
def py3o_report_extender(report_xml_id=None):
"""
A decorator to define function to extend the context sent to a template.
This will be called at the creation of the report.
The following arguments will be passed to it:
- ir_report: report instance
- localcontext: The context that will be passed to the report engine
If no report_xml_id is given the extender is registered for all py3o
reports
Idea copied from CampToCamp report_webkit module.
:param report_xml_id: xml id of the report
:return: a decorated class
"""
global _extender_functions
def fct1(fct):
_extender_functions.setdefault(report_xml_id, []).append(fct)
return fct
return fct1
@py3o_report_extender()
def default_extend(report_xml, context):
context["report_xml"] = report_xml
class Py3oReport(models.TransientModel):
_name = "py3o.report"
_description = "Report Py30"
ir_actions_report_id = fields.Many2one(
comodel_name="ir.actions.report", required=True
)
def _is_valid_template_path(self, path):
"""Check if the path is a trusted path for py3o templates."""
real_path = os.path.realpath(path)
root_path = tools.config.get_misc("report_py3o", "root_tmpl_path")
if not root_path:
logger.warning(
"You must provide a root template path into odoo.cfg to be "
"able to use py3o template configured with an absolute path "
"%s",
real_path,
)
return False
is_valid = real_path.startswith(root_path + os.path.sep)
if not is_valid:
logger.warning(
"Py3o template path is not valid. %s is not a child of root " "path %s",
real_path,
root_path,
)
return is_valid
def _is_valid_template_filename(self, filename):
"""Check if the filename can be used as py3o template"""
if filename and os.path.isfile(filename):
fname, ext = os.path.splitext(filename)
ext = ext.replace(".", "")
try:
fformat = Formats().get_format(ext)
if fformat and fformat.native:
return True
except UnkownFormatException:
logger.warning("Invalid py3o template %s", filename, exc_info=1)
logger.warning("%s is not a valid Py3o template filename", filename)
return False
def _get_template_from_path(self, tmpl_name):
"""Return the template from the path to root of the module if specied
or an absolute path on your server
"""
if not tmpl_name:
return None
report_xml = self.ir_actions_report_id
flbk_filename = None
if report_xml.module:
# if the default is defined
flbk_filename = pkg_resources.resource_filename(
"odoo.addons.%s" % report_xml.module, tmpl_name
)
elif self._is_valid_template_path(tmpl_name):
flbk_filename = os.path.realpath(tmpl_name)
if self._is_valid_template_filename(flbk_filename):
with open(flbk_filename, "rb") as tmpl:
return tmpl.read()
return None
def _get_template_fallback(self, model_instance):
"""
Return the template referenced in the report definition
:return:
"""
self.ensure_one()
report_xml = self.ir_actions_report_id
return self._get_template_from_path(report_xml.py3o_template_fallback)
def get_template(self, model_instance):
"""private helper to fetch the template data either from the database
or from the default template file provided by the implementer.
ATM this method takes a report definition recordset
to try and fetch the report template from database. If not found it
will fallback to the template file referenced in the report definition.
@returns: string or buffer containing the template data
@raises: TemplateNotFound which is a subclass of
odoo.exceptions.DeferredException
"""
self.ensure_one()
report_xml = self.ir_actions_report_id
if report_xml.py3o_template_id.py3o_template_data:
# if a user gave a report template
tmpl_data = b64decode(report_xml.py3o_template_id.py3o_template_data)
else:
tmpl_data = self._get_template_fallback(model_instance)
if tmpl_data is None:
# if for any reason the template is not found
raise TemplateNotFound(_("No template found. Aborting."), sys.exc_info())
return tmpl_data
def _extend_parser_context(self, context, report_xml):
# add default extenders
for fct in _extender_functions.get(None, []):
fct(report_xml, context)
# add extenders for registered on the template
xml_id = report_xml.get_external_id().get(report_xml.id)
if xml_id in _extender_functions:
for fct in _extender_functions[xml_id]:
fct(report_xml, context)
def _get_parser_context(self, model_instance, data):
report_xml = self.ir_actions_report_id
context = Py3oParserContext(self.env).localcontext
context.update(
report_xml._get_rendering_context(report_xml, model_instance.ids, data)
)
context["objects"] = model_instance
self._extend_parser_context(context, report_xml)
return context
def _postprocess_report(self, model_instance, result_path):
if len(model_instance) == 1 and self.ir_actions_report_id.attachment:
with open(result_path, "rb") as f:
# we do all the generation process using files to avoid memory
# consumption...
# ... but odoo wants the whole data in memory anyways :)
buffer = BytesIO(f.read())
attachment_name = safe_eval(
self.ir_actions_report_id.attachment,
{"object": model_instance, "time": time},
)
if attachment_name:
attachment_vals = {
"name": attachment_name,
"res_model": self.ir_actions_report_id.model,
"res_id": model_instance.id,
"raw": buffer.getvalue(),
}
try:
attach = self.env["ir.attachment"].create(attachment_vals)
except AccessError:
logger.info(
"Cannot save PDF report %s as attachment",
attachment_vals["name"],
)
else:
logger.info(
"PDF document %s saved as attachment ID %d",
attachment_vals["name"],
attach.id,
)
return result_path
def _create_single_report(self, model_instance, data):
"""This function to generate our py3o report"""
self.ensure_one()
result_fd, result_path = tempfile.mkstemp(
suffix=".ods", prefix="p3o.report.tmp."
)
tmpl_data = self.get_template(model_instance)
in_stream = BytesIO(tmpl_data)
with closing(os.fdopen(result_fd, "wb+")) as out_stream:
template = Template(in_stream, out_stream, escape_false=True)
localcontext = self._get_parser_context(model_instance, data)
template.render(localcontext)
out_stream.seek(0)
tmpl_data = out_stream.read()
if self.env.context.get("report_py3o_skip_conversion"):
return result_path
result_path = self._convert_single_report(result_path, model_instance, data)
return self._postprocess_report(model_instance, result_path)
def _convert_single_report(self, result_path, model_instance, data):
"""Run a command to convert to our target format"""
if not self.ir_actions_report_id.is_py3o_native_format:
with tempfile.TemporaryDirectory() as tmp_user_installation:
command = self._convert_single_report_cmd(
result_path,
model_instance,
data,
user_installation=tmp_user_installation,
)
logger.debug("Running command %s", command)
output = subprocess.check_output(
command, cwd=os.path.dirname(result_path)
)
logger.debug("Output was %s", output)
self._cleanup_tempfiles([result_path])
result_path, result_filename = os.path.split(result_path)
result_path = os.path.join(
result_path,
"%s.%s"
% (
os.path.splitext(result_filename)[0],
self.ir_actions_report_id.py3o_filetype,
),
)
return result_path
def _convert_single_report_cmd(
self, result_path, model_instance, data, user_installation=None
):
"""Return a command list suitable for use in subprocess.call"""
lo_bin = self.ir_actions_report_id.lo_bin_path
if not lo_bin:
raise RuntimeError(
_(
"Libreoffice runtime not available. "
"Please contact your administrator."
)
)
cmd = [
lo_bin,
"--headless",
"--convert-to",
self.ir_actions_report_id.py3o_filetype,
result_path,
]
if user_installation:
cmd.append("-env:UserInstallation=file:%s" % user_installation)
return cmd
def _get_or_create_single_report(
self, model_instance, data, existing_reports_attachment
):
self.ensure_one()
attachment = existing_reports_attachment.get(model_instance.id)
if attachment and self.ir_actions_report_id.attachment_use:
content = base64.b64decode(attachment.datas)
fd, report_file = tempfile.mkstemp(
"." + self.ir_actions_report_id.py3o_filetype
)
os.close(fd)
with open(report_file, "wb") as f:
f.write(content)
return report_file
return self._create_single_report(model_instance, data)
def _zip_results(self, reports_path):
self.ensure_one()
fd, result_path = tempfile.mkstemp(suffix="zip", prefix="py3o-zip-result")
os.close(fd)
with ZipFile(result_path, "w", ZIP_DEFLATED) as zf:
for report_instance, report in reports_path.items():
fname = self.ir_actions_report_id.gen_report_download_filename(
report_instance.ids, {}
)
zf.write(report, fname)
return result_path
@api.model
def _merge_pdf(self, reports_path):
"""Merge PDF files into one.
:param reports_path: list of path of pdf files
:returns: path of the merged pdf
"""
writer = PdfFileWriter()
for path in reports_path:
reader = PdfFileReader(path)
writer.appendPagesFromReader(reader)
merged_file_fd, merged_file_path = tempfile.mkstemp(
suffix=".pdf", prefix="report.merged.tmp."
)
with closing(os.fdopen(merged_file_fd, "wb")) as merged_file:
writer.write(merged_file)
return merged_file_path
def _merge_results(self, reports_path):
self.ensure_one()
filetype = self.ir_actions_report_id.py3o_filetype
path_list = list(reports_path.values())
if not reports_path:
return False, False
if len(reports_path) == 1:
return path_list[0], filetype
if filetype == formats.FORMAT_PDF:
return self._merge_pdf(path_list), formats.FORMAT_PDF
else:
return self._zip_results(reports_path), "zip"
@api.model
def _cleanup_tempfiles(self, temporary_files):
# Manual cleanup of the temporary files
for temporary_file in temporary_files:
try:
os.unlink(temporary_file)
except OSError:
logger.error("Error when trying to remove file %s" % temporary_file)
def create_report(self, res_ids, data):
"""Override this function to handle our py3o report"""
model_instances = self.env[self.ir_actions_report_id.model].browse(res_ids)
reports_path = {}
if len(res_ids) > 1 and self.ir_actions_report_id.py3o_multi_in_one:
reports_path[model_instances] = self._create_single_report(
model_instances, data
)
else:
existing_reports_attachment = self.ir_actions_report_id._get_attachments(
res_ids
)
for model_instance in model_instances:
reports_path[model_instance] = self._get_or_create_single_report(
model_instance, data, existing_reports_attachment
)
result_path, filetype = self._merge_results(reports_path)
cleanup_path = list(reports_path.values())
cleanup_path.append(result_path)
# Here is a little joke about Odoo
# we do all the generation process using files to avoid memory
# consumption...
# ... but odoo wants the whole data in memory anyways :)
with open(result_path, "r+b") as fd:
res = fd.read()
self._cleanup_tempfiles(set(cleanup_path))
return res, filetype

View file

@ -0,0 +1,24 @@
# Copyright 2013 XCG Consulting (http://odoo.consulting)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class Py3oTemplate(models.Model):
_name = "py3o.template"
_description = "Py3o template"
name = fields.Char(required=True)
py3o_template_data = fields.Binary("LibreOffice Template")
filetype = fields.Selection(
selection=[
("odt", "ODF Text Document"),
("ods", "ODF Spreadsheet"),
("odp", "ODF Presentation"),
("fodt", "ODF Text Document (Flat)"),
("fods", "ODF Spreadsheet (Flat)"),
("fodp", "ODF Presentation (Flat)"),
],
string="LibreOffice Template File Type",
required=True,
default="odt",
)