Initial commit: Odoo 18.0-20251222 extra-addons
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
tocmo0nlord
2026-03-13 20:43:25 +00:00
parent 36e847a7df
commit adbe430761
9472 changed files with 1265727 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:
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 OSError:
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"
f"(current: '{report.report_type}', expected 'py3o'"
)
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 f"{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,397 @@
# 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 importlib.resources import files
from io import BytesIO
from zipfile import ZIP_DEFLATED, ZipFile
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 = files(f"odoo.addons.{report_xml.module}").joinpath(
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,
f"{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(f"-env:UserInstallation=file:{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(f"Error when trying to remove file {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",
)