Browse Source

[IMP] report_py3o, report_py3o_fusion_server: black, isort

pull/347/head
Laurent Mignon (ACSONE) 5 years ago
parent
commit
e644a32572
  1. 2
      .isort.cfg
  2. 42
      report_py3o/__manifest__.py
  3. 79
      report_py3o/controllers/main.py
  4. 2
      report_py3o/demo/report_py3o.xml
  5. 91
      report_py3o/models/_py3o_parser_context.py
  6. 105
      report_py3o/models/ir_actions_report.py
  7. 166
      report_py3o/models/py3o_report.py
  8. 19
      report_py3o/models/py3o_template.py
  9. 162
      report_py3o/tests/test_report_py3o.py
  10. 40
      report_py3o_fusion_server/__manifest__.py
  11. 64
      report_py3o_fusion_server/models/ir_actions_report.py
  12. 464
      report_py3o_fusion_server/models/py3o_pdf_options.py
  13. 75
      report_py3o_fusion_server/models/py3o_report.py
  14. 19
      report_py3o_fusion_server/models/py3o_server.py
  15. 61
      report_py3o_fusion_server/tests/test_report_py3o_fusion_server.py

2
.isort.cfg

@ -9,4 +9,4 @@ line_length=88
known_odoo=odoo known_odoo=odoo
known_odoo_addons=odoo.addons known_odoo_addons=odoo.addons
sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER
known_third_party=
known_third_party=PyPDF2,mock,openerp,pkg_resources,requests,werkzeug

42
report_py3o/__manifest__.py

@ -1,29 +1,23 @@
# Copyright 2013 XCG Consulting (http://odoo.consulting) # Copyright 2013 XCG Consulting (http://odoo.consulting)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ {
'name': 'Py3o Report Engine',
'summary': 'Reporting engine based on Libreoffice (ODT -> ODT, '
'ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)',
'version': '12.0.2.0.2',
'category': 'Reporting',
'license': 'AGPL-3',
'author': 'XCG Consulting,'
'ACSONE SA/NV,'
'Odoo Community Association (OCA)',
'website': 'http://odoo.consulting/',
'depends': ['web'],
'external_dependencies': {
'python': ['py3o.template',
'py3o.formats',
'PyPDF2']
},
'data': [
'security/ir.model.access.csv',
'views/menu.xml',
'views/py3o_template.xml',
'views/ir_actions_report.xml',
'views/report_py3o.xml',
'demo/report_py3o.xml',
"name": "Py3o Report Engine",
"summary": "Reporting engine based on Libreoffice (ODT -> ODT, "
"ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)",
"version": "12.0.2.0.2",
"category": "Reporting",
"license": "AGPL-3",
"author": "XCG Consulting," "ACSONE SA/NV," "Odoo Community Association (OCA)",
"website": "http://odoo.consulting/",
"depends": ["web"],
"external_dependencies": {"python": ["py3o.template", "py3o.formats", "PyPDF2"]},
"data": [
"security/ir.model.access.csv",
"views/menu.xml",
"views/py3o_template.xml",
"views/ir_actions_report.xml",
"views/report_py3o.xml",
"demo/report_py3o.xml",
], ],
'installable': True,
"installable": True,
} }

79
report_py3o/controllers/main.py

@ -2,58 +2,57 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import json import json
import mimetypes import mimetypes
from werkzeug import exceptions, url_decode from werkzeug import exceptions, url_decode
from odoo.http import route, request
from odoo.http import request, route
from odoo.tools import html_escape
from odoo.addons.web.controllers import main from odoo.addons.web.controllers import main
from odoo.addons.web.controllers.main import (
_serialize_exception,
content_disposition
)
from odoo.tools import html_escape
from odoo.addons.web.controllers.main import _serialize_exception, content_disposition
class ReportController(main.ReportController): class ReportController(main.ReportController):
@route() @route()
def report_routes(self, reportname, docids=None, converter=None, **data): def report_routes(self, reportname, docids=None, converter=None, **data):
if converter != 'py3o':
if converter != "py3o":
return super(ReportController, self).report_routes( return super(ReportController, self).report_routes(
reportname=reportname, docids=docids, converter=converter,
**data)
reportname=reportname, docids=docids, converter=converter, **data
)
context = dict(request.env.context) context = dict(request.env.context)
if docids: 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'):
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 # Ignore 'lang' here, because the context in data is the
# one from the webclient *but* if the user explicitely wants to # one from the webclient *but* if the user explicitely wants to
# change the lang, this mechanism overwrites it. # change the lang, this mechanism overwrites it.
data['context'] = json.loads(data['context'])
if data['context'].get('lang'):
del data['context']['lang']
context.update(data['context'])
data["context"] = json.loads(data["context"])
if data["context"].get("lang"):
del data["context"]["lang"]
context.update(data["context"])
ir_action = request.env['ir.actions.report']
ir_action = request.env["ir.actions.report"]
action_py3o_report = ir_action.get_from_report_name( action_py3o_report = ir_action.get_from_report_name(
reportname, "py3o").with_context(context)
reportname, "py3o"
).with_context(context)
if not action_py3o_report: if not action_py3o_report:
raise exceptions.HTTPException( raise exceptions.HTTPException(
description='Py3o action report not found for report_name '
'%s' % reportname)
description="Py3o action report not found for report_name "
"%s" % reportname
)
res, filetype = action_py3o_report.render(docids, data) res, filetype = action_py3o_report.render(docids, data)
filename = action_py3o_report.gen_report_download_filename(
docids, data)
filename = action_py3o_report.gen_report_download_filename(docids, data)
if not filename.endswith(filetype): if not filename.endswith(filetype):
filename = "{}.{}".format(filename, filetype) filename = "{}.{}".format(filename, filetype)
content_type = mimetypes.guess_type("x." + filetype)[0] content_type = mimetypes.guess_type("x." + filetype)[0]
http_headers = [('Content-Type', content_type),
('Content-Length', len(res)),
('Content-Disposition', content_disposition(filename))
]
http_headers = [
("Content-Type", content_type),
("Content-Length", len(res)),
("Content-Disposition", content_disposition(filename)),
]
return request.make_response(res, headers=http_headers) return request.make_response(res, headers=http_headers)
@route() @route()
@ -67,31 +66,29 @@ class ReportController(main.ReportController):
""" """
requestcontent = json.loads(data) requestcontent = json.loads(data)
url, report_type = requestcontent[0], requestcontent[1] url, report_type = requestcontent[0], requestcontent[1]
if 'py3o' not in report_type:
if "py3o" not in report_type:
return super(ReportController, self).report_download(data, token) return super(ReportController, self).report_download(data, token)
try: try:
reportname = url.split('/report/py3o/')[1].split('?')[0]
reportname = url.split("/report/py3o/")[1].split("?")[0]
docids = None docids = None
if '/' in reportname:
reportname, docids = reportname.split('/')
if "/" in reportname:
reportname, docids = reportname.split("/")
if docids: if docids:
# Generic report: # Generic report:
response = self.report_routes( response = self.report_routes(
reportname, docids=docids, converter='py3o')
reportname, docids=docids, converter="py3o"
)
else: else:
# Particular report: # Particular report:
# decoding the args represented in JSON # decoding the args represented in JSON
data = list(url_decode(url.split('?')[1]).items())
data = list(url_decode(url.split("?")[1]).items())
response = self.report_routes( response = self.report_routes(
reportname, converter='py3o', **dict(data))
response.set_cookie('fileToken', token)
reportname, converter="py3o", **dict(data)
)
response.set_cookie("fileToken", token)
return response return response
except Exception as e: except Exception as e:
se = _serialize_exception(e) se = _serialize_exception(e)
error = {
'code': 200,
'message': "Odoo Server Error",
'data': se
}
error = {"code": 200, "message": "Odoo Server Error", "data": se}
return request.make_response(html_escape(json.dumps(error))) return request.make_response(html_escape(json.dumps(error)))

2
report_py3o/demo/report_py3o.xml

@ -16,5 +16,5 @@
<field name="binding_model_id" ref="base.model_res_users" /> <field name="binding_model_id" ref="base.model_res_users" />
<field name="binding_type">report</field> <field name="binding_type">report</field>
</record> </record>
</odoo> </odoo>

91
report_py3o/models/_py3o_parser_context.py

@ -2,24 +2,27 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import html import html
import time
import logging import logging
import time
from base64 import b64decode from base64 import b64decode
from odoo.tools import misc, mail
from odoo.tools import mail, misc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
from genshi.core import Markup from genshi.core import Markup
except ImportError: except ImportError:
logger.debug('Cannot import py3o.template')
logger.debug("Cannot import py3o.template")
def format_multiline_value(value): def format_multiline_value(value):
if value: if value:
return Markup(html.escape(value).replace('\n', '<text:line-break/>').
replace('\t', '<text:s/><text:s/><text:s/><text:s/>'))
return Markup(
html.escape(value)
.replace("\n", "<text:line-break/>")
.replace("\t", "<text:s/><text:s/><text:s/><text:s/>")
)
return "" return ""
@ -32,38 +35,52 @@ class Py3oParserContext(object):
self._env = env self._env = env
self.localcontext = { self.localcontext = {
'user': self._env.user,
'lang': self._env.lang,
"user": self._env.user,
"lang": self._env.lang,
# Odoo default format methods # Odoo default format methods
'o_format_lang': self._format_lang,
"o_format_lang": self._format_lang,
# prefixes with o_ to avoid nameclash with default method provided # prefixes with o_ to avoid nameclash with default method provided
# by py3o.template # by py3o.template
'o_format_date': self._format_date,
"o_format_date": self._format_date,
# give access to the time lib # give access to the time lib
'time': time,
"time": time,
# keeps methods from report_sxw to ease migration # 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,
"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):
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 env = self._env
if lang_code: if lang_code:
context = dict(env.context, lang=lang_code) context = dict(env.context, lang=lang_code)
env = env(context=context) env = env(context=context)
formatted_value = misc.formatLang( formatted_value = misc.formatLang(
env, value, digits=digits, grouping=grouping,
monetary=monetary, dp=dp, currency_obj=currency_obj)
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: if currency_obj and currency_obj.symbol and no_break_space:
parts = [] parts = []
if currency_obj.position == 'after':
if currency_obj.position == "after":
parts = formatted_value.rsplit(" ", 1) parts = formatted_value.rsplit(" ", 1)
elif currency_obj and currency_obj.position == 'before':
elif currency_obj and currency_obj.position == "before":
parts = formatted_value.split(" ", 1) parts = formatted_value.split(" ", 1)
if parts: if parts:
formatted_value = "\N{NO-BREAK SPACE}".join(parts) formatted_value = "\N{NO-BREAK SPACE}".join(parts)
@ -71,11 +88,20 @@ class Py3oParserContext(object):
def _format_date(self, value, lang_code=False, date_format=False): def _format_date(self, value, lang_code=False, date_format=False):
return misc.format_date( return misc.format_date(
self._env, value, lang_code=lang_code, date_format=date_format)
self._env, value, lang_code=lang_code, date_format=date_format
)
def _old_format_lang(self, value, digits=None, date=False, date_time=False,
grouping=True, monetary=False, dp=False,
currency_obj=False):
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 value: The value to format
:param digits: Number of digits to display by default :param digits: Number of digits to display by default
@ -95,8 +121,13 @@ class Py3oParserContext(object):
""" """
if not date and not date_time: if not date and not date_time:
return self._format_lang( return self._format_lang(
value, digits=digits, grouping=grouping,
monetary=monetary, dp=dp, currency_obj=currency_obj,
no_break_space=True)
value,
digits=digits,
grouping=grouping,
monetary=monetary,
dp=dp,
currency_obj=currency_obj,
no_break_space=True,
)
return self._format_date(self._env, value) return self._format_date(self._env, value)

105
report_py3o/models/ir_actions_report.py

@ -3,18 +3,18 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging import logging
import time import time
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.tools.misc import find_in_path from odoo.tools.misc import find_in_path
from odoo.tools.safe_eval import safe_eval from odoo.tools.safe_eval import safe_eval
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
from py3o.formats import Formats from py3o.formats import Formats
except ImportError: except ImportError:
logger.debug('Cannot import py3o.formats')
logger.debug("Cannot import py3o.formats")
PY3O_CONVERSION_COMMAND_PARAMETER = "py3o.conversion_command" PY3O_CONVERSION_COMMAND_PARAMETER = "py3o.conversion_command"
@ -25,15 +25,16 @@ class IrActionsReport(models.Model):
The list is configurable in the configuration tab, see py3o_template.py The list is configurable in the configuration tab, see py3o_template.py
""" """
_inherit = 'ir.actions.report'
_inherit = "ir.actions.report"
@api.multi @api.multi
@api.constrains("py3o_filetype", "report_type") @api.constrains("py3o_filetype", "report_type")
def _check_py3o_filetype(self): def _check_py3o_filetype(self):
for report in self: for report in self:
if report.report_type == "py3o" and not report.py3o_filetype: if report.report_type == "py3o" and not report.py3o_filetype:
raise ValidationError(_(
"Field 'Output Format' is required for Py3O report"))
raise ValidationError(
_("Field 'Output Format' is required for Py3O report")
)
@api.model @api.model
def _get_py3o_filetypes(self): def _get_py3o_filetypes(self):
@ -47,21 +48,15 @@ class IrActionsReport(models.Model):
selections.append((name, description)) selections.append((name, description))
return selections return selections
report_type = fields.Selection(
selection_add=[("py3o", "py3o")]
)
report_type = fields.Selection(selection_add=[("py3o", "py3o")])
py3o_filetype = fields.Selection( py3o_filetype = fields.Selection(
selection="_get_py3o_filetypes",
string="Output Format")
is_py3o_native_format = fields.Boolean(
compute='_compute_is_py3o_native_format'
selection="_get_py3o_filetypes", string="Output Format"
) )
py3o_template_id = fields.Many2one(
'py3o.template',
"Template")
is_py3o_native_format = fields.Boolean(compute="_compute_is_py3o_native_format")
py3o_template_id = fields.Many2one("py3o.template", "Template")
module = fields.Char( module = fields.Char(
"Module",
help="The implementer module that provides this report")
"Module", help="The implementer module that provides this report"
)
py3o_template_fallback = fields.Char( py3o_template_fallback = fields.Char(
"Fallback", "Fallback",
size=128, size=128,
@ -69,24 +64,25 @@ class IrActionsReport(models.Model):
"If the user does not provide a template this will be used " "If the user does not provide a template this will be used "
"it should be a relative path to root of YOUR module " "it should be a relative path to root of YOUR module "
"or an absolute path on your server." "or an absolute path on your server."
))
report_type = fields.Selection(selection_add=[('py3o', "Py3o")])
),
)
report_type = fields.Selection(selection_add=[("py3o", "Py3o")])
py3o_multi_in_one = fields.Boolean( py3o_multi_in_one = fields.Boolean(
string='Multiple Records in a Single Report',
string="Multiple Records in a Single Report",
help="If you execute a report on several records, " help="If you execute a report on several records, "
"by default Odoo will generate a ZIP file that contains as many " "by default Odoo will generate a ZIP file that contains as many "
"files as selected records. If you enable this option, Odoo will " "files as selected records. If you enable this option, Odoo will "
"generate instead a single report for the selected records.")
"generate instead a single report for the selected records.",
)
lo_bin_path = fields.Char( lo_bin_path = fields.Char(
string="Path to the libreoffice runtime",
compute="_compute_lo_bin_path"
)
string="Path to the libreoffice runtime", compute="_compute_lo_bin_path"
)
is_py3o_report_not_available = fields.Boolean( is_py3o_report_not_available = fields.Boolean(
compute='_compute_py3o_report_not_available'
)
compute="_compute_py3o_report_not_available"
)
msg_py3o_report_not_available = fields.Char( msg_py3o_report_not_available = fields.Char(
compute='_compute_py3o_report_not_available'
)
compute="_compute_py3o_report_not_available"
)
@api.model @api.model
def _register_hook(self): def _register_hook(self):
@ -106,8 +102,10 @@ class IrActionsReport(models.Model):
@api.model @api.model
def _get_lo_bin(self): def _get_lo_bin(self):
lo_bin = self.env['ir.config_parameter'].sudo().get_param(
PY3O_CONVERSION_COMMAND_PARAMETER, 'libreoffice',
lo_bin = (
self.env["ir.config_parameter"]
.sudo()
.get_param(PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice")
) )
try: try:
lo_bin = find_in_path(lo_bin) lo_bin = find_in_path(lo_bin)
@ -118,12 +116,12 @@ class IrActionsReport(models.Model):
@api.depends("report_type", "py3o_filetype") @api.depends("report_type", "py3o_filetype")
@api.multi @api.multi
def _compute_is_py3o_native_format(self): def _compute_is_py3o_native_format(self):
format = Formats()
fmt = Formats()
for rec in self: for rec in self:
if not rec.report_type == "py3o": if not rec.report_type == "py3o":
continue continue
filetype = rec.py3o_filetype filetype = rec.py3o_filetype
rec.is_py3o_native_format = format.get_format(filetype).native
rec.is_py3o_native_format = fmt.get_format(filetype).native
@api.multi @api.multi
def _compute_lo_bin_path(self): def _compute_lo_bin_path(self):
@ -139,21 +137,24 @@ class IrActionsReport(models.Model):
continue continue
if not rec.is_py3o_native_format and not rec.lo_bin_path: if not rec.is_py3o_native_format and not rec.lo_bin_path:
rec.is_py3o_report_not_available = True 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
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 @api.model
def get_from_report_name(self, report_name, report_type): def get_from_report_name(self, report_name, report_type):
return self.search( return self.search(
[("report_name", "=", report_name),
("report_type", "=", report_type)])
[("report_name", "=", report_name), ("report_type", "=", report_type)]
)
@api.multi @api.multi
def render_py3o(self, res_ids, data): def render_py3o(self, res_ids, data):
@ -161,10 +162,13 @@ class IrActionsReport(models.Model):
if self.report_type != "py3o": if self.report_type != "py3o":
raise RuntimeError( raise RuntimeError(
"py3o rendition is only available on py3o report.\n" "py3o rendition is only available on py3o report.\n"
"(current: '{}', expected 'py3o'".format(self.report_type))
return self.env['py3o.report'].create({
'ir_actions_report_id': self.id
}).create_report(res_ids, data)
"(current: '{}', expected 'py3o'".format(self.report_type)
)
return (
self.env["py3o.report"]
.create({"ir_actions_report_id": self.id})
.create_report(res_ids, data)
)
@api.multi @api.multi
def gen_report_download_filename(self, res_ids, data): def gen_report_download_filename(self, res_ids, data):
@ -174,9 +178,8 @@ class IrActionsReport(models.Model):
report = self.get_from_report_name(self.report_name, self.report_type) report = self.get_from_report_name(self.report_name, self.report_type)
if report.print_report_name and not len(res_ids) > 1: if report.print_report_name and not len(res_ids) > 1:
obj = self.env[self.model].browse(res_ids) obj = self.env[self.model].browse(res_ids)
return safe_eval(report.print_report_name,
{'object': obj, 'time': time})
return "%s.%s" % (self.name, self.py3o_filetype)
return safe_eval(report.print_report_name, {"object": obj, "time": time})
return "{}.{}".format(self.name, self.py3o_filetype)
@api.multi @api.multi
def _get_attachments(self, res_ids): def _get_attachments(self, res_ids):

166
report_py3o/models/py3o_report.py

@ -2,19 +2,20 @@
# Copyright 2016 ACSONE SA/NV # Copyright 2016 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import base64 import base64
from base64 import b64decode
from io import BytesIO
import logging import logging
import os import os
from contextlib import closing
import subprocess import subprocess
import pkg_resources
import sys import sys
import tempfile import tempfile
from zipfile import ZipFile, ZIP_DEFLATED
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 import api, fields, models, tools, _
from ._py3o_parser_context import Py3oParserContext from ._py3o_parser_context import Py3oParserContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,15 +24,15 @@ try:
from py3o.template import Template from py3o.template import Template
from py3o import formats from py3o import formats
except ImportError: except ImportError:
logger.debug('Cannot import py3o.template')
logger.debug("Cannot import py3o.template")
try: try:
from py3o.formats import Formats, UnkownFormatException from py3o.formats import Formats, UnkownFormatException
except ImportError: except ImportError:
logger.debug('Cannot import py3o.formats')
logger.debug("Cannot import py3o.formats")
try: try:
from PyPDF2 import PdfFileWriter, PdfFileReader from PyPDF2 import PdfFileWriter, PdfFileReader
except ImportError: except ImportError:
logger.debug('Cannot import PyPDF2')
logger.debug("Cannot import PyPDF2")
_extender_functions = {} _extender_functions = {}
@ -59,12 +60,13 @@ def py3o_report_extender(report_xml_id=None):
def fct1(fct): def fct1(fct):
_extender_functions.setdefault(report_xml_id, []).append(fct) _extender_functions.setdefault(report_xml_id, []).append(fct)
return fct return fct
return fct1 return fct1
@py3o_report_extender() @py3o_report_extender()
def default_extend(report_xml, context): def default_extend(report_xml, context):
context['report_xml'] = report_xml
context["report_xml"] = report_xml
class Py3oReport(models.TransientModel): class Py3oReport(models.TransientModel):
@ -72,8 +74,7 @@ class Py3oReport(models.TransientModel):
_description = "Report Py30" _description = "Report Py30"
ir_actions_report_id = fields.Many2one( ir_actions_report_id = fields.Many2one(
comodel_name="ir.actions.report",
required=True
comodel_name="ir.actions.report", required=True
) )
@api.multi @api.multi
@ -81,18 +82,22 @@ class Py3oReport(models.TransientModel):
""" Check if the path is a trusted path for py3o templates. """ Check if the path is a trusted path for py3o templates.
""" """
real_path = os.path.realpath(path) real_path = os.path.realpath(path)
root_path = tools.config.get_misc('report_py3o', 'root_tmpl_path')
root_path = tools.config.get_misc("report_py3o", "root_tmpl_path")
if not root_path: if not root_path:
logger.warning( logger.warning(
"You must provide a root template path into odoo.cfg to be " "You must provide a root template path into odoo.cfg to be "
"able to use py3o template configured with an absolute path " "able to use py3o template configured with an absolute path "
"%s", real_path)
"%s",
real_path,
)
return False return False
is_valid = real_path.startswith(root_path + os.path.sep) is_valid = real_path.startswith(root_path + os.path.sep)
if not is_valid: if not is_valid:
logger.warning( logger.warning(
"Py3o template path is not valid. %s is not a child of root "
"path %s", real_path, root_path)
"Py3o template path is not valid. %s is not a child of root " "path %s",
real_path,
root_path,
)
return is_valid return is_valid
@api.multi @api.multi
@ -101,16 +106,14 @@ class Py3oReport(models.TransientModel):
""" """
if filename and os.path.isfile(filename): if filename and os.path.isfile(filename):
fname, ext = os.path.splitext(filename) fname, ext = os.path.splitext(filename)
ext = ext.replace('.', '')
ext = ext.replace(".", "")
try: try:
fformat = Formats().get_format(ext) fformat = Formats().get_format(ext)
if fformat and fformat.native: if fformat and fformat.native:
return True return True
except UnkownFormatException: except UnkownFormatException:
logger.warning("Invalid py3o template %s", filename,
exc_info=1)
logger.warning(
'%s is not a valid Py3o template filename', filename)
logger.warning("Invalid py3o template %s", filename, exc_info=1)
logger.warning("%s is not a valid Py3o template filename", filename)
return False return False
@api.multi @api.multi
@ -125,13 +128,12 @@ class Py3oReport(models.TransientModel):
if report_xml.module: if report_xml.module:
# if the default is defined # if the default is defined
flbk_filename = pkg_resources.resource_filename( flbk_filename = pkg_resources.resource_filename(
"odoo.addons.%s" % report_xml.module,
tmpl_name,
"odoo.addons.%s" % report_xml.module, tmpl_name
) )
elif self._is_valid_template_path(tmpl_name): elif self._is_valid_template_path(tmpl_name):
flbk_filename = os.path.realpath(tmpl_name) flbk_filename = os.path.realpath(tmpl_name)
if self._is_valid_template_filename(flbk_filename): if self._is_valid_template_filename(flbk_filename):
with open(flbk_filename, 'rb') as tmpl:
with open(flbk_filename, "rb") as tmpl:
return tmpl.read() return tmpl.read()
return None return None
@ -163,19 +165,14 @@ class Py3oReport(models.TransientModel):
report_xml = self.ir_actions_report_id report_xml = self.ir_actions_report_id
if report_xml.py3o_template_id.py3o_template_data: if report_xml.py3o_template_id.py3o_template_data:
# if a user gave a report template # if a user gave a report template
tmpl_data = b64decode(
report_xml.py3o_template_id.py3o_template_data
)
tmpl_data = b64decode(report_xml.py3o_template_id.py3o_template_data)
else: else:
tmpl_data = self._get_template_fallback(model_instance) tmpl_data = self._get_template_fallback(model_instance)
if tmpl_data is None: if tmpl_data is None:
# if for any reason the template is not found # if for any reason the template is not found
raise TemplateNotFound(
_('No template found. Aborting.'),
sys.exc_info(),
)
raise TemplateNotFound(_("No template found. Aborting."), sys.exc_info())
return tmpl_data return tmpl_data
@ -194,23 +191,20 @@ class Py3oReport(models.TransientModel):
def _get_parser_context(self, model_instance, data): def _get_parser_context(self, model_instance, data):
report_xml = self.ir_actions_report_id report_xml = self.ir_actions_report_id
context = Py3oParserContext(self.env).localcontext context = Py3oParserContext(self.env).localcontext
context.update(
report_xml._get_rendering_context(model_instance.ids, data)
)
context['objects'] = model_instance
context.update(report_xml._get_rendering_context(model_instance.ids, data))
context["objects"] = model_instance
self._extend_parser_context(context, report_xml) self._extend_parser_context(context, report_xml)
return context return context
@api.multi @api.multi
def _postprocess_report(self, model_instance, result_path): def _postprocess_report(self, model_instance, result_path):
if len(model_instance) == 1 and self.ir_actions_report_id.attachment: if len(model_instance) == 1 and self.ir_actions_report_id.attachment:
with open(result_path, 'rb') as f:
with open(result_path, "rb") as f:
# we do all the generation process using files to avoid memory # we do all the generation process using files to avoid memory
# consumption... # consumption...
# ... but odoo wants the whole data in memory anyways :) # ... but odoo wants the whole data in memory anyways :)
buffer = BytesIO(f.read()) buffer = BytesIO(f.read())
self.ir_actions_report_id.postprocess_pdf_report(
model_instance, buffer)
self.ir_actions_report_id.postprocess_pdf_report(model_instance, buffer)
return result_path return result_path
@api.multi @api.multi
@ -219,23 +213,22 @@ class Py3oReport(models.TransientModel):
""" """
self.ensure_one() self.ensure_one()
result_fd, result_path = tempfile.mkstemp( result_fd, result_path = tempfile.mkstemp(
suffix='.ods', prefix='p3o.report.tmp.')
suffix=".ods", prefix="p3o.report.tmp."
)
tmpl_data = self.get_template(model_instance) tmpl_data = self.get_template(model_instance)
in_stream = BytesIO(tmpl_data) in_stream = BytesIO(tmpl_data)
with closing(os.fdopen(result_fd, 'wb+')) as out_stream:
with closing(os.fdopen(result_fd, "wb+")) as out_stream:
template = Template(in_stream, out_stream, escape_false=True) template = Template(in_stream, out_stream, escape_false=True)
localcontext = self._get_parser_context(model_instance, data) localcontext = self._get_parser_context(model_instance, data)
template.render(localcontext) template.render(localcontext)
out_stream.seek(0) out_stream.seek(0)
tmpl_data = out_stream.read() tmpl_data = out_stream.read()
if self.env.context.get('report_py3o_skip_conversion'):
if self.env.context.get("report_py3o_skip_conversion"):
return result_path return result_path
result_path = self._convert_single_report(
result_path, model_instance, data
)
result_path = self._convert_single_report(result_path, model_instance, data)
return self._postprocess_report(model_instance, result_path) return self._postprocess_report(model_instance, result_path)
@ -243,21 +236,19 @@ class Py3oReport(models.TransientModel):
def _convert_single_report(self, result_path, model_instance, data): def _convert_single_report(self, result_path, model_instance, data):
"""Run a command to convert to our target format""" """Run a command to convert to our target format"""
if not self.ir_actions_report_id.is_py3o_native_format: if not self.ir_actions_report_id.is_py3o_native_format:
command = self._convert_single_report_cmd(
result_path, model_instance, data,
)
logger.debug('Running command %s', command)
output = subprocess.check_output(
command, cwd=os.path.dirname(result_path),
)
logger.debug('Output was %s', output)
command = self._convert_single_report_cmd(result_path, model_instance, data)
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]) self._cleanup_tempfiles([result_path])
result_path, result_filename = os.path.split(result_path) result_path, result_filename = os.path.split(result_path)
result_path = os.path.join( result_path = os.path.join(
result_path, '%s.%s' % (
result_path,
"%s.%s"
% (
os.path.splitext(result_filename)[0], os.path.splitext(result_filename)[0],
self.ir_actions_report_id.py3o_filetype
)
self.ir_actions_report_id.py3o_filetype,
),
) )
return result_path return result_path
@ -267,43 +258,42 @@ class Py3oReport(models.TransientModel):
lo_bin = self.ir_actions_report_id.lo_bin_path lo_bin = self.ir_actions_report_id.lo_bin_path
if not lo_bin: if not lo_bin:
raise RuntimeError( raise RuntimeError(
_("Libreoffice runtime not available. "
"Please contact your administrator.")
_(
"Libreoffice runtime not available. "
"Please contact your administrator."
)
) )
return [ return [
lo_bin, lo_bin,
'--headless',
'--convert-to',
"--headless",
"--convert-to",
self.ir_actions_report_id.py3o_filetype, self.ir_actions_report_id.py3o_filetype,
result_path, result_path,
] ]
@api.multi @api.multi
def _get_or_create_single_report(self, model_instance, data,
existing_reports_attachment):
def _get_or_create_single_report(
self, model_instance, data, existing_reports_attachment
):
self.ensure_one() self.ensure_one()
attachment = existing_reports_attachment.get(
model_instance.id)
attachment = existing_reports_attachment.get(model_instance.id)
if attachment and self.ir_actions_report_id.attachment_use: if attachment and self.ir_actions_report_id.attachment_use:
content = base64.decodestring(attachment.datas) content = base64.decodestring(attachment.datas)
report_file = tempfile.mktemp(
"." + self.ir_actions_report_id.py3o_filetype)
report_file = tempfile.mktemp("." + self.ir_actions_report_id.py3o_filetype)
with open(report_file, "wb") as f: with open(report_file, "wb") as f:
f.write(content) f.write(content)
return report_file return report_file
return self._create_single_report(
model_instance, data)
return self._create_single_report(model_instance, data)
@api.multi @api.multi
def _zip_results(self, reports_path): def _zip_results(self, reports_path):
self.ensure_one() self.ensure_one()
zfname_prefix = self.ir_actions_report_id.name zfname_prefix = self.ir_actions_report_id.name
result_path = tempfile.mktemp(suffix="zip", prefix='py3o-zip-result')
with ZipFile(result_path, 'w', ZIP_DEFLATED) as zf:
result_path = tempfile.mktemp(suffix="zip", prefix="py3o-zip-result")
with ZipFile(result_path, "w", ZIP_DEFLATED) as zf:
cpt = 0 cpt = 0
for report in reports_path: for report in reports_path:
fname = "%s_%d.%s" % (
zfname_prefix, cpt, report.split('.')[-1])
fname = "%s_%d.%s" % (zfname_prefix, cpt, report.split(".")[-1])
zf.write(report, fname) zf.write(report, fname)
cpt += 1 cpt += 1
@ -321,8 +311,9 @@ class Py3oReport(models.TransientModel):
reader = PdfFileReader(path) reader = PdfFileReader(path)
writer.appendPagesFromReader(reader) writer.appendPagesFromReader(reader)
merged_file_fd, merged_file_path = tempfile.mkstemp( 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:
suffix=".pdf", prefix="report.merged.tmp."
)
with closing(os.fdopen(merged_file_fd, "wb")) as merged_file:
writer.write(merged_file) writer.write(merged_file)
return merged_file_path return merged_file_path
@ -337,7 +328,7 @@ class Py3oReport(models.TransientModel):
if filetype == formats.FORMAT_PDF: if filetype == formats.FORMAT_PDF:
return self._merge_pdf(reports_path), formats.FORMAT_PDF return self._merge_pdf(reports_path), formats.FORMAT_PDF
else: else:
return self._zip_results(reports_path), 'zip'
return self._zip_results(reports_path), "zip"
@api.model @api.model
def _cleanup_tempfiles(self, temporary_files): def _cleanup_tempfiles(self, temporary_files):
@ -346,29 +337,26 @@ class Py3oReport(models.TransientModel):
try: try:
os.unlink(temporary_file) os.unlink(temporary_file)
except (OSError, IOError): except (OSError, IOError):
logger.error(
'Error when trying to remove file %s' % temporary_file)
logger.error("Error when trying to remove file %s" % temporary_file)
@api.multi @api.multi
def create_report(self, res_ids, data): def create_report(self, res_ids, data):
""" Override this function to handle our py3o report """ Override this function to handle our py3o report
""" """
model_instances = self.env[self.ir_actions_report_id.model].browse(
res_ids)
model_instances = self.env[self.ir_actions_report_id.model].browse(res_ids)
reports_path = [] reports_path = []
if (
len(res_ids) > 1 and
self.ir_actions_report_id.py3o_multi_in_one):
reports_path.append(
self._create_single_report(
model_instances, data))
if len(res_ids) > 1 and self.ir_actions_report_id.py3o_multi_in_one:
reports_path.append(self._create_single_report(model_instances, data))
else: else:
existing_reports_attachment = \
self.ir_actions_report_id._get_attachments(res_ids)
existing_reports_attachment = self.ir_actions_report_id._get_attachments(
res_ids
)
for model_instance in model_instances: for model_instance in model_instances:
reports_path.append( reports_path.append(
self._get_or_create_single_report( self._get_or_create_single_report(
model_instance, data, existing_reports_attachment))
model_instance, data, existing_reports_attachment
)
)
result_path, filetype = self._merge_results(reports_path) result_path, filetype = self._merge_results(reports_path)
reports_path.append(result_path) reports_path.append(result_path)
@ -378,7 +366,7 @@ class Py3oReport(models.TransientModel):
# consumption... # consumption...
# ... but odoo wants the whole data in memory anyways :) # ... but odoo wants the whole data in memory anyways :)
with open(result_path, 'r+b') as fd:
with open(result_path, "r+b") as fd:
res = fd.read() res = fd.read()
self._cleanup_tempfiles(set(reports_path)) self._cleanup_tempfiles(set(reports_path))
return res, filetype return res, filetype

19
report_py3o/models/py3o_template.py

@ -4,20 +4,21 @@ from odoo import fields, models
class Py3oTemplate(models.Model): class Py3oTemplate(models.Model):
_name = 'py3o.template'
_description = 'Py3o template'
_name = "py3o.template"
_description = "Py3o template"
name = fields.Char(required=True) name = fields.Char(required=True)
py3o_template_data = fields.Binary("LibreOffice Template") py3o_template_data = fields.Binary("LibreOffice Template")
filetype = fields.Selection( filetype = fields.Selection(
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)"),
("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", string="LibreOffice Template File Type",
required=True, required=True,
default='odt')
default="odt",
)

162
report_py3o/tests/test_report_py3o.py

@ -2,39 +2,40 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).).
import base64 import base64
from base64 import b64decode
import mock
import logging
import os import os
import pkg_resources
import shutil import shutil
import tempfile import tempfile
from base64 import b64decode, b64encode
from contextlib import contextmanager from contextlib import contextmanager
import mock
import pkg_resources
from PyPDF2 import PdfFileWriter
from PyPDF2.pdf import PageObject
from odoo import tools from odoo import tools
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
from odoo.addons.base.tests.test_mimetypes import PNG from odoo.addons.base.tests.test_mimetypes import PNG
from ..models._py3o_parser_context import format_multiline_value
from ..models.ir_actions_report import PY3O_CONVERSION_COMMAND_PARAMETER from ..models.ir_actions_report import PY3O_CONVERSION_COMMAND_PARAMETER
from ..models.py3o_report import TemplateNotFound from ..models.py3o_report import TemplateNotFound
from ..models._py3o_parser_context import format_multiline_value
from base64 import b64encode
from PyPDF2 import PdfFileWriter
from PyPDF2.pdf import PageObject
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
from genshi.core import Markup from genshi.core import Markup
except ImportError: except ImportError:
logger.debug('Cannot import genshi.core')
logger.debug("Cannot import genshi.core")
@contextmanager @contextmanager
def temporary_copy(path): def temporary_copy(path):
filname, ext = os.path.splitext(path) filname, ext = os.path.splitext(path)
tmp_filename = tempfile.mktemp(suffix='.' + ext)
tmp_filename = tempfile.mktemp(suffix="." + ext)
try: try:
shutil.copy2(path, tmp_filename) shutil.copy2(path, tmp_filename)
yield tmp_filename yield tmp_filename
@ -43,36 +44,35 @@ def temporary_copy(path):
class TestReportPy3o(TransactionCase): class TestReportPy3o(TransactionCase):
def setUp(self): def setUp(self):
super(TestReportPy3o, self).setUp() super(TestReportPy3o, self).setUp()
self.env.user.image = PNG self.env.user.image = PNG
self.report = self.env.ref("report_py3o.res_users_report_py3o") self.report = self.env.ref("report_py3o.res_users_report_py3o")
self.py3o_report = self.env['py3o.report'].create({
'ir_actions_report_id': self.report.id})
self.py3o_report = self.env["py3o.report"].create(
{"ir_actions_report_id": self.report.id}
)
def test_required_py3_filetype(self): def test_required_py3_filetype(self):
self.assertEqual(self.report.report_type, "py3o") self.assertEqual(self.report.report_type, "py3o")
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
self.report.py3o_filetype = False self.report.py3o_filetype = False
self.assertEqual( self.assertEqual(
e.exception.name,
"Field 'Output Format' is required for Py3O report")
e.exception.name, "Field 'Output Format' is required for Py3O report"
)
def _render_patched(self, result_text='test result', call_count=1):
py3o_report = self.env['py3o.report']
py3o_report_obj = py3o_report.create({
"ir_actions_report_id": self.report.id
})
def _render_patched(self, result_text="test result", call_count=1):
py3o_report = self.env["py3o.report"]
py3o_report_obj = py3o_report.create({"ir_actions_report_id": self.report.id})
with mock.patch.object( with mock.patch.object(
py3o_report.__class__, '_create_single_report') as patched_pdf:
result = tempfile.mktemp('.txt')
with open(result, 'w') as fp:
py3o_report.__class__, "_create_single_report"
) as patched_pdf:
result = tempfile.mktemp(".txt")
with open(result, "w") as fp:
fp.write(result_text) fp.write(result_text)
patched_pdf.side_effect = lambda record, data:\
py3o_report_obj._postprocess_report(
record, result
) or result
patched_pdf.side_effect = (
lambda record, data: py3o_report_obj._postprocess_report(record, result)
or result
)
# test the call the the create method inside our custom parser # test the call the the create method inside our custom parser
self.report.render(self.env.user.ids) self.report.render(self.env.user.ids)
self.assertEqual(call_count, patched_pdf.call_count) self.assertEqual(call_count, patched_pdf.call_count)
@ -85,35 +85,35 @@ class TestReportPy3o(TransactionCase):
def test_reports_merge_zip(self): def test_reports_merge_zip(self):
self.report.py3o_filetype = "odt" self.report.py3o_filetype = "odt"
users = self.env['res.users'].search([])
users = self.env["res.users"].search([])
self.assertTrue(len(users) > 0) self.assertTrue(len(users) > 0)
py3o_report = self.env['py3o.report']
py3o_report = self.env["py3o.report"]
_zip_results = self.py3o_report._zip_results _zip_results = self.py3o_report._zip_results
with mock.patch.object( with mock.patch.object(
py3o_report.__class__, '_zip_results') as patched_zip_results:
py3o_report.__class__, "_zip_results"
) as patched_zip_results:
patched_zip_results.side_effect = _zip_results patched_zip_results.side_effect = _zip_results
content, filetype = self.report.render(users.ids) content, filetype = self.report.render(users.ids)
self.assertEqual(1, patched_zip_results.call_count) self.assertEqual(1, patched_zip_results.call_count)
self.assertEqual(filetype, 'zip')
self.assertEqual(filetype, "zip")
def test_reports_merge_pdf(self): def test_reports_merge_pdf(self):
reports_path = [] reports_path = []
for i in range(0, 3):
result = tempfile.mktemp('.txt')
for _i in range(0, 3):
result = tempfile.mktemp(".txt")
writer = PdfFileWriter() writer = PdfFileWriter()
writer.addPage(PageObject.createBlankPage(width=100, height=100)) writer.addPage(PageObject.createBlankPage(width=100, height=100))
with open(result, 'wb') as fp:
with open(result, "wb") as fp:
writer.write(fp) writer.write(fp)
reports_path.append(result) reports_path.append(result)
res = self.py3o_report._merge_pdf(reports_path) res = self.py3o_report._merge_pdf(reports_path)
self.assertTrue(res) self.assertTrue(res)
def test_report_load_from_attachment(self): def test_report_load_from_attachment(self):
self.report.write({"attachment_use": True,
"attachment": "'my_saved_report'"})
attachments = self.env['ir.attachment'].search([])
self.report.write({"attachment_use": True, "attachment": "'my_saved_report'"})
attachments = self.env["ir.attachment"].search([])
self._render_patched() self._render_patched()
new_attachments = self.env['ir.attachment'].search([])
new_attachments = self.env["ir.attachment"].search([])
created_attachement = new_attachments - attachments created_attachement = new_attachments - attachments
self.assertEqual(1, len(created_attachement)) self.assertEqual(1, len(created_attachement))
content = b64decode(created_attachement.datas) content = b64decode(created_attachement.datas)
@ -123,7 +123,7 @@ class TestReportPy3o(TransactionCase):
# generated document # generated document
created_attachement.datas = base64.encodestring(b"new content") created_attachement.datas = base64.encodestring(b"new content")
res = self.report.render(self.env.user.ids) res = self.report.render(self.env.user.ids)
self.assertEqual((b'new content', self.report.py3o_filetype), res)
self.assertEqual((b"new content", self.report.py3o_filetype), res)
def test_report_post_process(self): def test_report_post_process(self):
""" """
@ -131,24 +131,24 @@ class TestReportPy3o(TransactionCase):
generated report into an ir.attachment if requested. generated report into an ir.attachment if requested.
""" """
self.report.attachment = "object.name + '.txt'" self.report.attachment = "object.name + '.txt'"
ir_attachment = self.env['ir.attachment']
attachements = ir_attachment.search([(1, '=', 1)])
ir_attachment = self.env["ir.attachment"]
attachements = ir_attachment.search([(1, "=", 1)])
self._render_patched() self._render_patched()
attachements = ir_attachment.search([(1, '=', 1)]) - attachements
attachements = ir_attachment.search([(1, "=", 1)]) - attachements
self.assertEqual(1, len(attachements.ids)) self.assertEqual(1, len(attachements.ids))
self.assertEqual(self.env.user.name + '.txt', attachements.name)
self.assertEqual(self.env.user.name + ".txt", attachements.name)
self.assertEqual(self.env.user._name, attachements.res_model) self.assertEqual(self.env.user._name, attachements.res_model)
self.assertEqual(self.env.user.id, attachements.res_id) self.assertEqual(self.env.user.id, attachements.res_id)
self.assertEqual(b'test result', b64decode(attachements.datas))
self.assertEqual(b"test result", b64decode(attachements.datas))
@tools.misc.mute_logger('odoo.addons.report_py3o.models.py3o_report')
@tools.misc.mute_logger("odoo.addons.report_py3o.models.py3o_report")
def test_report_template_configs(self): def test_report_template_configs(self):
# the demo template is specified with a relative path in in the module # the demo template is specified with a relative path in in the module
# path # path
tmpl_name = self.report.py3o_template_fallback tmpl_name = self.report.py3o_template_fallback
flbk_filename = pkg_resources.resource_filename( flbk_filename = pkg_resources.resource_filename(
"odoo.addons.%s" % self.report.module,
tmpl_name)
"odoo.addons.%s" % self.report.module, tmpl_name
)
self.assertTrue(os.path.exists(flbk_filename)) self.assertTrue(os.path.exists(flbk_filename))
res = self.report.render(self.env.user.ids) res = self.report.render(self.env.user.ids)
self.assertTrue(res) self.assertTrue(res)
@ -164,61 +164,63 @@ class TestReportPy3o(TransactionCase):
self.report.render(self.env.user.ids) self.report.render(self.env.user.ids)
with temporary_copy(flbk_filename) as tmp_filename: with temporary_copy(flbk_filename) as tmp_filename:
self.report.py3o_template_fallback = tmp_filename self.report.py3o_template_fallback = tmp_filename
tools.config.misc['report_py3o'] = {
'root_tmpl_path': os.path.dirname(tmp_filename)}
tools.config.misc["report_py3o"] = {
"root_tmpl_path": os.path.dirname(tmp_filename)
}
res = self.report.render(self.env.user.ids) res = self.report.render(self.env.user.ids)
self.assertTrue(res) self.assertTrue(res)
# the tempalte can also be provided as a binary field # the tempalte can also be provided as a binary field
self.report.py3o_template_fallback = False self.report.py3o_template_fallback = False
with open(flbk_filename, 'rb') as tmpl_file:
with open(flbk_filename, "rb") as tmpl_file:
tmpl_data = b64encode(tmpl_file.read()) tmpl_data = b64encode(tmpl_file.read())
py3o_template = self.env['py3o.template'].create({
'name': 'test_template',
'py3o_template_data': tmpl_data,
'filetype': 'odt'})
py3o_template = self.env["py3o.template"].create(
{
"name": "test_template",
"py3o_template_data": tmpl_data,
"filetype": "odt",
}
)
self.report.py3o_template_id = py3o_template self.report.py3o_template_id = py3o_template
self.report.py3o_template_fallback = flbk_filename self.report.py3o_template_fallback = flbk_filename
res = self.report.render(self.env.user.ids) res = self.report.render(self.env.user.ids)
self.assertTrue(res) self.assertTrue(res)
@tools.misc.mute_logger('odoo.addons.report_py3o.models.py3o_report')
@tools.misc.mute_logger("odoo.addons.report_py3o.models.py3o_report")
def test_report_template_fallback_validity(self): def test_report_template_fallback_validity(self):
tmpl_name = self.report.py3o_template_fallback tmpl_name = self.report.py3o_template_fallback
flbk_filename = pkg_resources.resource_filename( flbk_filename = pkg_resources.resource_filename(
"odoo.addons.%s" % self.report.module,
tmpl_name)
"odoo.addons.%s" % self.report.module, tmpl_name
)
# an exising file in a native format is a valid template if it's # an exising file in a native format is a valid template if it's
self.assertTrue(self.py3o_report._get_template_from_path(
tmpl_name))
self.assertTrue(self.py3o_report._get_template_from_path(tmpl_name))
self.report.module = None self.report.module = None
# a directory is not a valid template.. # a directory is not a valid template..
self.assertFalse(self.py3o_report._get_template_from_path('/etc/'))
self.assertFalse(self.py3o_report._get_template_from_path('.'))
self.assertFalse(self.py3o_report._get_template_from_path("/etc/"))
self.assertFalse(self.py3o_report._get_template_from_path("."))
# an vaild template outside the root_tmpl_path is not a valid template # an vaild template outside the root_tmpl_path is not a valid template
# path # path
# located in trusted directory # located in trusted directory
self.report.py3o_template_fallback = flbk_filename self.report.py3o_template_fallback = flbk_filename
self.assertFalse(self.py3o_report._get_template_from_path(
flbk_filename))
self.assertFalse(self.py3o_report._get_template_from_path(flbk_filename))
with temporary_copy(flbk_filename) as tmp_filename: with temporary_copy(flbk_filename) as tmp_filename:
self.assertTrue(self.py3o_report._get_template_from_path(
tmp_filename))
self.assertTrue(self.py3o_report._get_template_from_path(tmp_filename))
# check security # check security
self.assertFalse(self.py3o_report._get_template_from_path(
'rm -rf . & %s' % flbk_filename))
self.assertFalse(
self.py3o_report._get_template_from_path("rm -rf . & %s" % flbk_filename)
)
# a file in a non native LibreOffice format is not a valid template # a file in a non native LibreOffice format is not a valid template
with tempfile.NamedTemporaryFile(suffix='.toto')as f:
self.assertFalse(self.py3o_report._get_template_from_path(
f.name))
with tempfile.NamedTemporaryFile(suffix=".toto") as f:
self.assertFalse(self.py3o_report._get_template_from_path(f.name))
# non exising files are not valid template # non exising files are not valid template
self.assertFalse(self.py3o_report._get_template_from_path(
'/etc/test.odt'))
self.assertFalse(self.py3o_report._get_template_from_path("/etc/test.odt"))
def test_escape_html_characters_format_multiline_value(self): def test_escape_html_characters_format_multiline_value(self):
self.assertEqual(Markup('&lt;&gt;<text:line-break/>&amp;test;'),
format_multiline_value('<>\n&test;'))
self.assertEqual(
Markup("&lt;&gt;<text:line-break/>&amp;test;"),
format_multiline_value("<>\n&test;"),
)
def test_py3o_report_availability(self): def test_py3o_report_availability(self):
# This test could fails if libreoffice is not available on the server # This test could fails if libreoffice is not available on the server
@ -229,8 +231,9 @@ class TestReportPy3o(TransactionCase):
self.assertFalse(self.report.msg_py3o_report_not_available) self.assertFalse(self.report.msg_py3o_report_not_available)
# specify a wrong lo bin path # specify a wrong lo bin path
self.env['ir.config_parameter'].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "/wrong_path")
self.env["ir.config_parameter"].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "/wrong_path"
)
self.report.refresh() self.report.refresh()
# no bin path available but the report is still available since # no bin path available but the report is still available since
# the output is into native format # the output is into native format
@ -249,8 +252,9 @@ class TestReportPy3o(TransactionCase):
self.report.render(self.env.user.ids) self.report.render(self.env.user.ids)
# if we reset the wrong path, everything should work # if we reset the wrong path, everything should work
self.env['ir.config_parameter'].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice")
self.env["ir.config_parameter"].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice"
)
self.report.refresh() self.report.refresh()
self.assertTrue(self.report.lo_bin_path) self.assertTrue(self.report.lo_bin_path)
self.assertFalse(self.report.is_py3o_native_format) self.assertFalse(self.report.is_py3o_native_format)

40
report_py3o_fusion_server/__manifest__.py

@ -1,31 +1,21 @@
# Copyright 2017 Therp BV <http://therp.nl> # Copyright 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{ {
'name': 'Py3o Report Engine - Fusion server support',
'summary': 'Let the fusion server handle format conversion.',
'version': '12.0.1.0.0',
'category': 'Reporting',
'license': 'AGPL-3',
'author': 'XCG Consulting,'
'ACSONE SA/NV,'
'Odoo Community Association (OCA)',
'website': 'https://github.com/OCA/reporting-engine',
'depends': ['report_py3o'],
'external_dependencies': {
'python': [
'py3o.template',
'py3o.formats',
],
},
'demo': [
"demo/report_py3o.xml",
"demo/py3o_pdf_options.xml",
],
'data': [
"name": "Py3o Report Engine - Fusion server support",
"summary": "Let the fusion server handle format conversion.",
"version": "12.0.1.0.0",
"category": "Reporting",
"license": "AGPL-3",
"author": "XCG Consulting," "ACSONE SA/NV," "Odoo Community Association (OCA)",
"website": "https://github.com/OCA/reporting-engine",
"depends": ["report_py3o"],
"external_dependencies": {"python": ["py3o.template", "py3o.formats"]},
"demo": ["demo/report_py3o.xml", "demo/py3o_pdf_options.xml"],
"data": [
"views/ir_actions_report.xml", "views/ir_actions_report.xml",
'security/ir.model.access.csv',
'views/py3o_server.xml',
'views/py3o_pdf_options.xml',
"security/ir.model.access.csv",
"views/py3o_server.xml",
"views/py3o_pdf_options.xml",
], ],
'installable': True,
"installable": True,
} }

64
report_py3o_fusion_server/models/ir_actions_report.py

@ -2,14 +2,16 @@
# © 2017 Therp BV <http://therp.nl> # © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging import logging
from openerp import _, api, fields, models from openerp import _, api, fields, models
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class IrActionsReport(models.Model): class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
_inherit = "ir.actions.report"
@api.multi @api.multi
@api.constrains("py3o_is_local_fusion", "py3o_server_id") @api.constrains("py3o_is_local_fusion", "py3o_server_id")
@ -17,40 +19,52 @@ class IrActionsReport(models.Model):
for report in self: for report in self:
if report.report_type != "py3o": if report.report_type != "py3o":
continue continue
if (not report.py3o_is_local_fusion and not report.py3o_server_id):
raise ValidationError(_(
"You can not use remote fusion without Fusion server. "
"Please specify a Fusion Server"))
if not report.py3o_is_local_fusion and not report.py3o_server_id:
raise ValidationError(
_(
"You can not use remote fusion without Fusion server. "
"Please specify a Fusion Server"
)
)
py3o_is_local_fusion = fields.Boolean( py3o_is_local_fusion = fields.Boolean(
"Local Fusion", "Local Fusion",
help="Native formats will be processed without a server. " help="Native formats will be processed without a server. "
"You must use this mode if you call methods on your model into "
"the template.",
default=True)
py3o_server_id = fields.Many2one(
"py3o.server",
"Fusion Server")
"You must use this mode if you call methods on your model into "
"the template.",
default=True,
)
py3o_server_id = fields.Many2one("py3o.server", "Fusion Server")
pdf_options_id = fields.Many2one( pdf_options_id = fields.Many2one(
'py3o.pdf.options', string='PDF Options', ondelete='restrict',
"py3o.pdf.options",
string="PDF Options",
ondelete="restrict",
help="PDF options can be set per report, but also per Py3o Server. " help="PDF options can be set per report, but also per Py3o Server. "
"If both are defined, the options on the report are used.")
"If both are defined, the options on the report are used.",
)
@api.depends("lo_bin_path", "is_py3o_native_format", "report_type",
"py3o_server_id")
@api.depends(
"lo_bin_path", "is_py3o_native_format", "report_type", "py3o_server_id"
)
@api.multi @api.multi
def _compute_py3o_report_not_available(self): def _compute_py3o_report_not_available(self):
for rec in self: for rec in self:
if not rec.report_type == "py3o": if not rec.report_type == "py3o":
continue continue
if (not rec.is_py3o_native_format and
not rec.lo_bin_path and not rec.py3o_server_id):
if (
not rec.is_py3o_native_format
and not rec.lo_bin_path
and not rec.py3o_server_id
):
rec.is_py3o_report_not_available = True rec.is_py3o_report_not_available = True
rec.msg_py3o_report_not_available = _(
"A fusion server or a libreoffice runtime are required "
"to genereate the py3o report '%s'. If the libreoffice"
"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
rec.msg_py3o_report_not_available = (
_(
"A fusion server or a libreoffice runtime are required "
"to genereate the py3o report '%s'. If the libreoffice"
"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
)

464
report_py3o_fusion_server/models/py3o_pdf_options.py

@ -2,223 +2,283 @@
# @author: Alexis de Lattre <alexis.delattre@akretion.com> # @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
import logging import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Py3oPdfOptions(models.Model): class Py3oPdfOptions(models.Model):
_name = 'py3o.pdf.options'
_description = 'Define PDF export options for Libreoffice'
_name = "py3o.pdf.options"
_description = "Define PDF export options for Libreoffice"
name = fields.Char(required=True) name = fields.Char(required=True)
# GENERAL TAB # GENERAL TAB
# UseLosslessCompression (bool) # UseLosslessCompression (bool)
image_compression = fields.Selection([
('lossless', 'Lossless Compression'),
('jpeg', 'JPEG Compression'),
], string='Image Compression', default='jpeg')
image_compression = fields.Selection(
[("lossless", "Lossless Compression"), ("jpeg", "JPEG Compression")],
string="Image Compression",
default="jpeg",
)
# Quality (int) # Quality (int)
image_jpeg_quality = fields.Integer( image_jpeg_quality = fields.Integer(
string='Image JPEG Quality', default=90,
help="Enter a percentage between 0 and 100.")
string="Image JPEG Quality",
default=90,
help="Enter a percentage between 0 and 100.",
)
# ReduceImageResolution (bool) and MaxImageResolution (int) # ReduceImageResolution (bool) and MaxImageResolution (int)
image_reduce_resolution = fields.Selection([
('none', 'Disable'),
('75', '75 DPI'),
('150', '150 DPI'),
('300', '300 DPI'),
('600', '600 DPI'),
('1200', '1200 DPI'),
], string='Reduce Image Resolution', default='300')
watermark = fields.Boolean('Sign With Watermark')
image_reduce_resolution = fields.Selection(
[
("none", "Disable"),
("75", "75 DPI"),
("150", "150 DPI"),
("300", "300 DPI"),
("600", "600 DPI"),
("1200", "1200 DPI"),
],
string="Reduce Image Resolution",
default="300",
)
watermark = fields.Boolean("Sign With Watermark")
# Watermark (string) # Watermark (string)
watermark_text = fields.Char('WaterMark Text')
watermark_text = fields.Char("WaterMark Text")
# UseTaggedPDF (bool) # UseTaggedPDF (bool)
tagged_pdf = fields.Boolean('Tagged PDF (add document structure)')
tagged_pdf = fields.Boolean("Tagged PDF (add document structure)")
# SelectPdfVersion (int) # SelectPdfVersion (int)
# 0 = PDF 1.4 (default selection). # 0 = PDF 1.4 (default selection).
# 1 = PDF/A-1 (ISO 19005-1:2005) # 1 = PDF/A-1 (ISO 19005-1:2005)
pdfa = fields.Boolean( pdfa = fields.Boolean(
'Archive PDF/A-1a (ISO 19005-1)',
"Archive PDF/A-1a (ISO 19005-1)",
help="If you enable this option, you will not be able to " help="If you enable this option, you will not be able to "
"password-protect the document or apply other security settings.")
"password-protect the document or apply other security settings.",
)
# ExportFormFields (bool) # ExportFormFields (bool)
pdf_form = fields.Boolean('Create PDF Form', default=True)
pdf_form = fields.Boolean("Create PDF Form", default=True)
# FormsType (int) # FormsType (int)
pdf_form_format = fields.Selection([
('0', 'FDF'),
('1', 'PDF'),
('2', 'HTML'),
('3', 'XML'),
], string='Submit Format', default='0')
pdf_form_format = fields.Selection(
[("0", "FDF"), ("1", "PDF"), ("2", "HTML"), ("3", "XML")],
string="Submit Format",
default="0",
)
# AllowDuplicateFieldNames (bool) # AllowDuplicateFieldNames (bool)
pdf_form_allow_duplicate = fields.Boolean('Allow Duplicate Field Names')
pdf_form_allow_duplicate = fields.Boolean("Allow Duplicate Field Names")
# ExportBookmarks (bool) # ExportBookmarks (bool)
export_bookmarks = fields.Boolean('Export Bookmarks', default=True)
export_bookmarks = fields.Boolean("Export Bookmarks", default=True)
# ExportPlaceholders (bool) # ExportPlaceholders (bool)
export_placeholders = fields.Boolean('Export Placeholders', default=True)
export_placeholders = fields.Boolean("Export Placeholders", default=True)
# ExportNotes (bool) # ExportNotes (bool)
export_comments = fields.Boolean('Export Comments')
export_comments = fields.Boolean("Export Comments")
# ExportHiddenSlides (bool) ?? # ExportHiddenSlides (bool) ??
export_hidden_slides = fields.Boolean(
'Export Automatically Insered Blank Pages')
export_hidden_slides = fields.Boolean("Export Automatically Insered Blank Pages")
# Doesn't make sense to have the option "View PDF after export" ! :) # Doesn't make sense to have the option "View PDF after export" ! :)
# INITIAL VIEW TAB # INITIAL VIEW TAB
# InitialView (int) # InitialView (int)
initial_view = fields.Selection([
('0', 'Page Only'),
('1', 'Bookmarks and Page'),
('2', 'Thumbnails and Page'),
], string='Panes', default='0')
initial_view = fields.Selection(
[("0", "Page Only"), ("1", "Bookmarks and Page"), ("2", "Thumbnails and Page")],
string="Panes",
default="0",
)
# InitialPage (int) # InitialPage (int)
initial_page = fields.Integer(string='Initial Page', default=1)
initial_page = fields.Integer(string="Initial Page", default=1)
# Magnification (int) # Magnification (int)
magnification = fields.Selection([
('0', 'Default'),
('1', 'Fit in Window'),
('2', 'Fit Width'),
('3', 'Fit Visible'),
('4', 'Zoom'),
], string='Magnification', default='0')
magnification = fields.Selection(
[
("0", "Default"),
("1", "Fit in Window"),
("2", "Fit Width"),
("3", "Fit Visible"),
("4", "Zoom"),
],
string="Magnification",
default="0",
)
# Zoom (int) # Zoom (int)
zoom = fields.Integer( zoom = fields.Integer(
string='Zoom Factor', default=100,
help='Possible values: from 50 to 1600')
string="Zoom Factor", default=100, help="Possible values: from 50 to 1600"
)
# PageLayout (int) # PageLayout (int)
page_layout = fields.Selection([
('0', 'Default'),
('1', 'Single Page'),
('2', 'Continuous'),
('3', 'Continuous Facing'),
], string='Page Layout', default='0')
page_layout = fields.Selection(
[
("0", "Default"),
("1", "Single Page"),
("2", "Continuous"),
("3", "Continuous Facing"),
],
string="Page Layout",
default="0",
)
# USER INTERFACE TAB # USER INTERFACE TAB
# ResizeWindowToInitialPage (bool) # ResizeWindowToInitialPage (bool)
resize_windows_initial_page = fields.Boolean( resize_windows_initial_page = fields.Boolean(
string='Resize Windows to Initial Page')
string="Resize Windows to Initial Page"
)
# CenterWindow (bool) # CenterWindow (bool)
center_window = fields.Boolean(string='Center Window on Screen')
center_window = fields.Boolean(string="Center Window on Screen")
# OpenInFullScreenMode (bool) # OpenInFullScreenMode (bool)
open_fullscreen = fields.Boolean(string='Open in Full Screen Mode')
open_fullscreen = fields.Boolean(string="Open in Full Screen Mode")
# DisplayPDFDocumentTitle (bool) # DisplayPDFDocumentTitle (bool)
display_document_title = fields.Boolean(string='Display Document Title')
display_document_title = fields.Boolean(string="Display Document Title")
# HideViewerMenubar (bool) # HideViewerMenubar (bool)
hide_menubar = fields.Boolean(string='Hide Menubar')
hide_menubar = fields.Boolean(string="Hide Menubar")
# HideViewerToolbar (bool) # HideViewerToolbar (bool)
hide_toolbar = fields.Boolean(string='Hide Toolbar')
hide_toolbar = fields.Boolean(string="Hide Toolbar")
# HideViewerWindowControls (bool) # HideViewerWindowControls (bool)
hide_window_controls = fields.Boolean(string='Hide Windows Controls')
hide_window_controls = fields.Boolean(string="Hide Windows Controls")
# OpenBookmarkLevels (int) -1 = all (default) from 1 to 10 # OpenBookmarkLevels (int) -1 = all (default) from 1 to 10
open_bookmark_levels = fields.Selection([
('-1', 'All Levels'),
('1', '1'),
('2', '2'),
('3', '3'),
('4', '4'),
('5', '5'),
('6', '6'),
('7', '7'),
('8', '8'),
('9', '9'),
('10', '10'),
], default='-1', string='Visible Bookmark Levels')
open_bookmark_levels = fields.Selection(
[
("-1", "All Levels"),
("1", "1"),
("2", "2"),
("3", "3"),
("4", "4"),
("5", "5"),
("6", "6"),
("7", "7"),
("8", "8"),
("9", "9"),
("10", "10"),
],
default="-1",
string="Visible Bookmark Levels",
)
# LINKS TAB # LINKS TAB
# ExportBookmarksToPDFDestination (bool) # ExportBookmarksToPDFDestination (bool)
export_bookmarks_named_dest = fields.Boolean( export_bookmarks_named_dest = fields.Boolean(
string='Export Bookmarks as Named Destinations')
string="Export Bookmarks as Named Destinations"
)
# ConvertOOoTargetToPDFTarget (bool) # ConvertOOoTargetToPDFTarget (bool)
convert_doc_ref_to_pdf_target = fields.Boolean( convert_doc_ref_to_pdf_target = fields.Boolean(
string='Convert Document References to PDF Targets')
string="Convert Document References to PDF Targets"
)
# ExportLinksRelativeFsys (bool) # ExportLinksRelativeFsys (bool)
export_filesystem_urls = fields.Boolean(
string='Export URLs Relative to Filesystem')
export_filesystem_urls = fields.Boolean(string="Export URLs Relative to Filesystem")
# PDFViewSelection -> mnDefaultLinkAction (int) # PDFViewSelection -> mnDefaultLinkAction (int)
cross_doc_link_action = fields.Selection([
('0', 'Default'),
('1', 'Open with PDF Reader Application'),
('2', 'Open with Internet Browser'),
], string='Cross-document Links', default='0')
cross_doc_link_action = fields.Selection(
[
("0", "Default"),
("1", "Open with PDF Reader Application"),
("2", "Open with Internet Browser"),
],
string="Cross-document Links",
default="0",
)
# SECURITY TAB # SECURITY TAB
# EncryptFile (bool) # EncryptFile (bool)
encrypt = fields.Boolean('Encrypt')
encrypt = fields.Boolean("Encrypt")
# DocumentOpenPassword (char) # DocumentOpenPassword (char)
document_password = fields.Char(string='Document Password')
document_password = fields.Char(string="Document Password")
# RestrictPermissions (bool) # RestrictPermissions (bool)
restrict_permissions = fields.Boolean('Restrict Permissions')
restrict_permissions = fields.Boolean("Restrict Permissions")
# PermissionPassword (char) # PermissionPassword (char)
permission_password = fields.Char(string='Permission Password')
permission_password = fields.Char(string="Permission Password")
# TODO PreparedPasswords + PreparedPermissionPassword # TODO PreparedPasswords + PreparedPermissionPassword
# I don't see those fields in the LO interface ! # I don't see those fields in the LO interface !
# But they are used in the LO code... # But they are used in the LO code...
# Printing (int) # Printing (int)
printing = fields.Selection([
('0', 'Not Permitted'),
('1', 'Low Resolution (150 dpi)'),
('2', 'High Resolution'),
], string='Printing', default='2')
printing = fields.Selection(
[
("0", "Not Permitted"),
("1", "Low Resolution (150 dpi)"),
("2", "High Resolution"),
],
string="Printing",
default="2",
)
# Changes (int) # Changes (int)
changes = fields.Selection([
('0', 'Not Permitted'),
('1', 'Inserting, Deleting and Rotating Pages'),
('2', 'Filling in Form Fields'),
('3', 'Commenting, Filling in Form Fields'),
('4', 'Any Except Extracting Pages'),
], string='Changes', default='4')
changes = fields.Selection(
[
("0", "Not Permitted"),
("1", "Inserting, Deleting and Rotating Pages"),
("2", "Filling in Form Fields"),
("3", "Commenting, Filling in Form Fields"),
("4", "Any Except Extracting Pages"),
],
string="Changes",
default="4",
)
# EnableCopyingOfContent (bool) # EnableCopyingOfContent (bool)
content_copying_allowed = fields.Boolean( content_copying_allowed = fields.Boolean(
string='Enable Copying of Content', default=True)
string="Enable Copying of Content", default=True
)
# EnableTextAccessForAccessibilityTools (bool) # EnableTextAccessForAccessibilityTools (bool)
text_access_accessibility_tools_allowed = fields.Boolean( text_access_accessibility_tools_allowed = fields.Boolean(
string='Enable Text Access for Accessibility Tools', default=True)
# DIGITAL SIGNATURE TAB
# This will be possible but not easy
# Because the certificate parameter is a pointer to a certificate
# already registered in LO
# On Linux LO reuses the Mozilla certificate store (on Windows the
# one from Windows)
# But there seems to be some possibilities to send this certificate via API
# It seems you can add temporary certificates during runtime:
# https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1security_1_1XCertificateContainer.html
# Here is an API to retrieve the known certificates:
# https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1xml_1_1crypto_1_1XSecurityEnvironment.html
# Thanks to 'samuel_m' on libreoffice-dev IRC chan for pointing me to this
string="Enable Text Access for Accessibility Tools", default=True
)
"""
DIGITAL SIGNATURE TAB
This will be possible but not easy
Because the certificate parameter is a pointer to a certificate
already registered in LO
On Linux LO reuses the Mozilla certificate store (on Windows the
one from Windows)
But there seems to be some possibilities to send this certificate via API
It seems you can add temporary certificates during runtime:
https://api.libreoffice.org/docs/idl/ref/
interfacecom_1_1sun_1_1star_1_1security_1_1XCertificateContainer.html
Here is an API to retrieve the known certificates:
https://api.libreoffice.org/docs/idl/ref/
interfacecom_1_1sun_1_1star_1_1xml_1_1crypto_1_1XSecurityEnvironment.html
Thanks to 'samuel_m' on libreoffice-dev IRC chan for pointing me to this
"""
@api.constrains( @api.constrains(
'image_jpeg_quality', 'initial_page', 'pdfa',
'cross_doc_link_action', 'magnification', 'zoom')
"image_jpeg_quality",
"initial_page",
"pdfa",
"cross_doc_link_action",
"magnification",
"zoom",
)
def check_pdf_options(self): def check_pdf_options(self):
for opt in self: for opt in self:
if opt.image_jpeg_quality > 100 or opt.image_jpeg_quality < 1: if opt.image_jpeg_quality > 100 or opt.image_jpeg_quality < 1:
raise ValidationError(_(
"The parameter Image JPEG Quality must be between 1 %%"
" and 100 %% (current value: %s %%)")
% opt.image_jpeg_quality)
raise ValidationError(
_(
"The parameter Image JPEG Quality must be between 1 %%"
" and 100 %% (current value: %s %%)"
)
% opt.image_jpeg_quality
)
if opt.initial_page < 1: if opt.initial_page < 1:
raise ValidationError(_(
"The initial page parameter must be strictly positive "
"(current value: %d)") % opt.initial_page)
if opt.pdfa and opt.cross_doc_link_action == '1':
raise ValidationError(_(
"The PDF/A option is not compatible with "
"'Cross-document Links' = "
"'Open with PDF Reader Application'."))
if opt.magnification == '4' and (opt.zoom < 50 or opt.zoom > 1600):
raise ValidationError(_(
"The value of the zoom factor must be between 50 and 1600 "
"(current value: %d)") % opt.zoom)
raise ValidationError(
_(
"The initial page parameter must be strictly positive "
"(current value: %d)"
)
% opt.initial_page
)
if opt.pdfa and opt.cross_doc_link_action == "1":
raise ValidationError(
_(
"The PDF/A option is not compatible with "
"'Cross-document Links' = "
"'Open with PDF Reader Application'."
)
)
if opt.magnification == "4" and (opt.zoom < 50 or opt.zoom > 1600):
raise ValidationError(
_(
"The value of the zoom factor must be between 50 and 1600 "
"(current value: %d)"
)
% opt.zoom
)
@api.onchange('encrypt')
@api.onchange("encrypt")
def encrypt_change(self): def encrypt_change(self):
if not self.encrypt: if not self.encrypt:
self.document_password = False self.document_password = False
@api.onchange('restrict_permissions')
@api.onchange("restrict_permissions")
def restrict_permissions_change(self): def restrict_permissions_change(self):
if not self.restrict_permissions: if not self.restrict_permissions:
self.permission_password = False self.permission_password = False
@api.onchange('pdfa')
@api.onchange("pdfa")
def pdfa_change(self): def pdfa_change(self):
if self.pdfa: if self.pdfa:
self.pdf_form = False self.pdf_form = False
@ -229,87 +289,97 @@ class Py3oPdfOptions(models.Model):
self.ensure_one() self.ensure_one()
options = {} options = {}
# GENERAL TAB # GENERAL TAB
if self.image_compression == 'lossless':
options['UseLosslessCompression'] = True
if self.image_compression == "lossless":
options["UseLosslessCompression"] = True
else: else:
options['UseLosslessCompression'] = False
options['Quality'] = self.image_jpeg_quality
if self.image_reduce_resolution != 'none':
options['ReduceImageResolution'] = True
options['MaxImageResolution'] = int(self.image_reduce_resolution)
options["UseLosslessCompression"] = False
options["Quality"] = self.image_jpeg_quality
if self.image_reduce_resolution != "none":
options["ReduceImageResolution"] = True
options["MaxImageResolution"] = int(self.image_reduce_resolution)
else: else:
options['ReduceImageResolution'] = False
options["ReduceImageResolution"] = False
if self.watermark and self.watermark_text: if self.watermark and self.watermark_text:
options['Watermark'] = self.watermark_text
options["Watermark"] = self.watermark_text
if self.pdfa: if self.pdfa:
options['SelectPdfVersion'] = 1
options['UseTaggedPDF'] = self.tagged_pdf
options["SelectPdfVersion"] = 1
options["UseTaggedPDF"] = self.tagged_pdf
else: else:
options['SelectPdfVersion'] = 0
options["SelectPdfVersion"] = 0
if self.pdf_form and self.pdf_form_format and not self.pdfa: if self.pdf_form and self.pdf_form_format and not self.pdfa:
options['ExportFormFields'] = True
options['FormsType'] = int(self.pdf_form_format)
options['AllowDuplicateFieldNames'] = self.pdf_form_allow_duplicate
options["ExportFormFields"] = True
options["FormsType"] = int(self.pdf_form_format)
options["AllowDuplicateFieldNames"] = self.pdf_form_allow_duplicate
else: else:
options['ExportFormFields'] = False
options["ExportFormFields"] = False
options.update({
'ExportBookmarks': self.export_bookmarks,
'ExportPlaceholders': self.export_placeholders,
'ExportNotes': self.export_comments,
'ExportHiddenSlides': self.export_hidden_slides,
})
options.update(
{
"ExportBookmarks": self.export_bookmarks,
"ExportPlaceholders": self.export_placeholders,
"ExportNotes": self.export_comments,
"ExportHiddenSlides": self.export_hidden_slides,
}
)
# INITIAL VIEW TAB # INITIAL VIEW TAB
options.update({
'InitialView': int(self.initial_view),
'InitialPage': self.initial_page,
'Magnification': int(self.magnification),
'PageLayout': int(self.page_layout),
})
options.update(
{
"InitialView": int(self.initial_view),
"InitialPage": self.initial_page,
"Magnification": int(self.magnification),
"PageLayout": int(self.page_layout),
}
)
if self.magnification == '4':
options['Zoom'] = self.zoom
if self.magnification == "4":
options["Zoom"] = self.zoom
# USER INTERFACE TAB # USER INTERFACE TAB
options.update({
'ResizeWindowToInitialPage': self.resize_windows_initial_page,
'CenterWindow': self.center_window,
'OpenInFullScreenMode': self.open_fullscreen,
'DisplayPDFDocumentTitle': self.display_document_title,
'HideViewerMenubar': self.hide_menubar,
'HideViewerToolbar': self.hide_toolbar,
'HideViewerWindowControls': self.hide_window_controls,
})
options.update(
{
"ResizeWindowToInitialPage": self.resize_windows_initial_page,
"CenterWindow": self.center_window,
"OpenInFullScreenMode": self.open_fullscreen,
"DisplayPDFDocumentTitle": self.display_document_title,
"HideViewerMenubar": self.hide_menubar,
"HideViewerToolbar": self.hide_toolbar,
"HideViewerWindowControls": self.hide_window_controls,
}
)
if self.open_bookmark_levels: if self.open_bookmark_levels:
options['OpenBookmarkLevels'] = int(self.open_bookmark_levels)
options["OpenBookmarkLevels"] = int(self.open_bookmark_levels)
# LINKS TAB # LINKS TAB
options.update({
'ExportBookmarksToPDFDestination':
self.export_bookmarks_named_dest,
'ConvertOOoTargetToPDFTarget': self.convert_doc_ref_to_pdf_target,
'ExportLinksRelativeFsys': self.export_filesystem_urls,
'PDFViewSelection': int(self.cross_doc_link_action),
})
options.update(
{
"ExportBookmarksToPDFDestination": self.export_bookmarks_named_dest,
"ConvertOOoTargetToPDFTarget": self.convert_doc_ref_to_pdf_target,
"ExportLinksRelativeFsys": self.export_filesystem_urls,
"PDFViewSelection": int(self.cross_doc_link_action),
}
)
# SECURITY TAB # SECURITY TAB
if not self.pdfa: if not self.pdfa:
if self.encrypt and self.document_password: if self.encrypt and self.document_password:
options['EncryptFile'] = True
options['DocumentOpenPassword'] = self.document_password
options["EncryptFile"] = True
options["DocumentOpenPassword"] = self.document_password
if self.restrict_permissions and self.permission_password: if self.restrict_permissions and self.permission_password:
options.update({
'RestrictPermissions': True,
'PermissionPassword': self.permission_password,
'Printing': int(self.printing),
'Changes': int(self.changes),
'EnableCopyingOfContent': self.content_copying_allowed,
'EnableTextAccessForAccessibilityTools':
self.text_access_accessibility_tools_allowed,
})
# fmt: off
options.update(
{
"RestrictPermissions": True,
"PermissionPassword": self.permission_password,
"Printing": int(self.printing),
"Changes": int(self.changes),
"EnableCopyingOfContent": self.content_copying_allowed,
"EnableTextAccessForAccessibilityTools":
self.text_access_accessibility_tools_allowed,
}
)
# fmt: on
logger.debug(
'Py3o PDF options ID %s converted to %s', self.id, options)
logger.debug("Py3o PDF options ID %s converted to %s", self.id, options)
return options return options

75
report_py3o_fusion_server/models/py3o_report.py

@ -5,13 +5,14 @@
import json import json
import logging import logging
import os import os
import requests
import tempfile import tempfile
from datetime import datetime
from contextlib import closing from contextlib import closing
from datetime import datetime
from io import BytesIO
import requests
from openerp import _, api, models from openerp import _, api, models
from openerp.exceptions import UserError from openerp.exceptions import UserError
from io import BytesIO
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,11 +20,11 @@ try:
from py3o.template import Template from py3o.template import Template
from py3o.template.helpers import Py3oConvertor from py3o.template.helpers import Py3oConvertor
except ImportError: except ImportError:
logger.debug('Cannot import py3o.template')
logger.debug("Cannot import py3o.template")
class Py3oReport(models.TransientModel): class Py3oReport(models.TransientModel):
_inherit = 'py3o.report'
_inherit = "py3o.report"
@api.multi @api.multi
def _create_single_report(self, model_instance, data): def _create_single_report(self, model_instance, data):
@ -33,40 +34,32 @@ class Py3oReport(models.TransientModel):
report_xml = self.ir_actions_report_id report_xml = self.ir_actions_report_id
filetype = report_xml.py3o_filetype filetype = report_xml.py3o_filetype
if not report_xml.py3o_server_id: if not report_xml.py3o_server_id:
return super(Py3oReport, self)._create_single_report(
model_instance, data,
)
return super(Py3oReport, self)._create_single_report(model_instance, data)
elif report_xml.py3o_is_local_fusion: elif report_xml.py3o_is_local_fusion:
result_path = super( result_path = super(
Py3oReport, self.with_context(
report_py3o_skip_conversion=True,
)
)._create_single_report(
model_instance, data
)
with closing(open(result_path, 'rb')) as out_stream:
Py3oReport, self.with_context(report_py3o_skip_conversion=True)
)._create_single_report(model_instance, data)
with closing(open(result_path, "rb")) as out_stream:
tmpl_data = out_stream.read() tmpl_data = out_stream.read()
datadict = {} datadict = {}
else: else:
result_fd, result_path = tempfile.mkstemp( result_fd, result_path = tempfile.mkstemp(
suffix='.' + filetype, prefix='p3o.report.tmp.')
suffix="." + filetype, prefix="p3o.report.tmp."
)
tmpl_data = self.get_template(model_instance) tmpl_data = self.get_template(model_instance)
in_stream = BytesIO(tmpl_data) in_stream = BytesIO(tmpl_data)
with closing(os.fdopen(result_fd, 'wb+')) as out_stream:
with closing(os.fdopen(result_fd, "wb+")) as out_stream:
template = Template(in_stream, out_stream, escape_false=True) template = Template(in_stream, out_stream, escape_false=True)
localcontext = self._get_parser_context(model_instance, data) localcontext = self._get_parser_context(model_instance, data)
expressions = template.get_all_user_python_expression() expressions = template.get_all_user_python_expression()
py_expression = template.convert_py3o_to_python_ast(
expressions)
py_expression = template.convert_py3o_to_python_ast(expressions)
convertor = Py3oConvertor() convertor = Py3oConvertor()
data_struct = convertor(py_expression) data_struct = convertor(py_expression)
datadict = data_struct.render(localcontext) datadict = data_struct.render(localcontext)
# Call py3o.server to render the template in the desired format # Call py3o.server to render the template in the desired format
files = {
'tmpl_file': tmpl_data,
}
files = {"tmpl_file": tmpl_data}
fields = { fields = {
"targetformat": filetype, "targetformat": filetype,
"datadict": json.dumps(datadict), "datadict": json.dumps(datadict),
@ -74,37 +67,41 @@ class Py3oReport(models.TransientModel):
"escape_false": "on", "escape_false": "on",
} }
if report_xml.py3o_is_local_fusion: if report_xml.py3o_is_local_fusion:
fields['skipfusion'] = '1'
fields["skipfusion"] = "1"
url = report_xml.py3o_server_id.url url = report_xml.py3o_server_id.url
logger.info( logger.info(
'Connecting to %s to convert report %s to %s',
url, report_xml.report_name, filetype)
if filetype == 'pdf':
options = report_xml.pdf_options_id or\
report_xml.py3o_server_id.pdf_options_id
"Connecting to %s to convert report %s to %s",
url,
report_xml.report_name,
filetype,
)
if filetype == "pdf":
options = (
report_xml.pdf_options_id or report_xml.py3o_server_id.pdf_options_id
)
if options: if options:
pdf_options_dict = options.odoo2libreoffice_options() pdf_options_dict = options.odoo2libreoffice_options()
fields['pdf_options'] = json.dumps(pdf_options_dict)
logger.debug('PDF Export options: %s', pdf_options_dict)
fields["pdf_options"] = json.dumps(pdf_options_dict)
logger.debug("PDF Export options: %s", pdf_options_dict)
start_chrono = datetime.now() start_chrono = datetime.now()
r = requests.post(url, data=fields, files=files) r = requests.post(url, data=fields, files=files)
if r.status_code != 200: if r.status_code != 200:
# server says we have an issue... let's tell that to enduser # server says we have an issue... let's tell that to enduser
logger.error('Py3o fusion server error: %s', r.text)
raise UserError(
_('Fusion server error %s') % r.text,
)
logger.error("Py3o fusion server error: %s", r.text)
raise UserError(_("Fusion server error %s") % r.text)
chunk_size = 1024 chunk_size = 1024
with open(result_path, 'w+b') as fd:
with open(result_path, "w+b") as fd:
for chunk in r.iter_content(chunk_size): for chunk in r.iter_content(chunk_size):
fd.write(chunk) fd.write(chunk)
end_chrono = datetime.now() end_chrono = datetime.now()
convert_seconds = (end_chrono - start_chrono).total_seconds() convert_seconds = (end_chrono - start_chrono).total_seconds()
logger.info( logger.info(
'Report %s converted to %s in %s seconds',
report_xml.report_name, filetype, convert_seconds)
"Report %s converted to %s in %s seconds",
report_xml.report_name,
filetype,
convert_seconds,
)
if len(model_instance) == 1: if len(model_instance) == 1:
self._postprocess_report(
model_instance, result_path)
self._postprocess_report(model_instance, result_path)
return result_path return result_path

19
report_py3o_fusion_server/models/py3o_server.py

@ -4,16 +4,21 @@ from odoo import fields, models
class Py3oServer(models.Model): class Py3oServer(models.Model):
_name = 'py3o.server'
_description = 'Py3o server'
_rec_name = 'url'
_name = "py3o.server"
_description = "Py3o server"
_rec_name = "url"
url = fields.Char( url = fields.Char(
"Py3o Fusion Server URL", required=True,
"Py3o Fusion Server URL",
required=True,
help="If your Py3o Fusion server is on the same machine and runs " help="If your Py3o Fusion server is on the same machine and runs "
"on the default port, the URL is http://localhost:8765/form")
"on the default port, the URL is http://localhost:8765/form",
)
is_active = fields.Boolean("Active", default=True) is_active = fields.Boolean("Active", default=True)
pdf_options_id = fields.Many2one( pdf_options_id = fields.Many2one(
'py3o.pdf.options', string='PDF Options', ondelete='restrict',
"py3o.pdf.options",
string="PDF Options",
ondelete="restrict",
help="PDF options can be set per Py3o Server but also per report. " help="PDF options can be set per Py3o Server but also per report. "
"If both are defined, the options on the report are used.")
"If both are defined, the options on the report are used.",
)

61
report_py3o_fusion_server/tests/test_report_py3o_fusion_server.py

@ -1,62 +1,55 @@
# Copyright 2017 Therp BV <http://therp.nl> # Copyright 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import mock import mock
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.addons.report_py3o.models.ir_actions_report import \
PY3O_CONVERSION_COMMAND_PARAMETER
from odoo.addons.report_py3o.models.ir_actions_report import (
PY3O_CONVERSION_COMMAND_PARAMETER,
)
from odoo.addons.report_py3o.tests import test_report_py3o from odoo.addons.report_py3o.tests import test_report_py3o
@mock.patch( @mock.patch(
'requests.post', mock.Mock(
"requests.post",
mock.Mock(
return_value=mock.Mock( return_value=mock.Mock(
status_code=200,
iter_content=mock.Mock(return_value=[b'test_result']),
status_code=200, iter_content=mock.Mock(return_value=[b"test_result"])
) )
)
),
) )
class TestReportPy3oFusionServer(test_report_py3o.TestReportPy3o): class TestReportPy3oFusionServer(test_report_py3o.TestReportPy3o):
def setUp(self): def setUp(self):
super(TestReportPy3oFusionServer, self).setUp() super(TestReportPy3oFusionServer, self).setUp()
py3o_server = self.env['py3o.server'].create({"url": "http://dummy"})
py3o_server = self.env["py3o.server"].create({"url": "http://dummy"})
# check the call to the fusion server # check the call to the fusion server
self.report.write({
"py3o_server_id": py3o_server.id,
"py3o_filetype": 'pdf',
})
self.report.write({"py3o_server_id": py3o_server.id, "py3o_filetype": "pdf"})
self.py3o_server = py3o_server self.py3o_server = py3o_server
def test_no_local_fusion_without_fusion_server(self): def test_no_local_fusion_without_fusion_server(self):
self.assertTrue(self.report.py3o_is_local_fusion) self.assertTrue(self.report.py3o_is_local_fusion)
# Fusion server is only required if not local... # Fusion server is only required if not local...
self.report.write({
"py3o_server_id": None,
"py3o_is_local_fusion": True,
})
self.report.write({
"py3o_server_id": self.py3o_server.id,
"py3o_is_local_fusion": True,
})
self.report.write({
"py3o_server_id": self.py3o_server.id,
"py3o_is_local_fusion": False,
})
self.report.write({"py3o_server_id": None, "py3o_is_local_fusion": True})
self.report.write(
{"py3o_server_id": self.py3o_server.id, "py3o_is_local_fusion": True}
)
self.report.write(
{"py3o_server_id": self.py3o_server.id, "py3o_is_local_fusion": False}
)
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
self.report.write({
"py3o_server_id": None,
"py3o_is_local_fusion": False,
})
self.report.write({"py3o_server_id": None, "py3o_is_local_fusion": False})
self.assertEqual( self.assertEqual(
e.exception.name, e.exception.name,
"You can not use remote fusion without Fusion server. " "You can not use remote fusion without Fusion server. "
"Please specify a Fusion Server")
"Please specify a Fusion Server",
)
def test_reports_no_local_fusion(self): def test_reports_no_local_fusion(self):
self.report.py3o_is_local_fusion = False self.report.py3o_is_local_fusion = False
self.test_reports() self.test_reports()
def test_odoo2libreoffice_options(self): def test_odoo2libreoffice_options(self):
for options in self.env['py3o.pdf.options'].search([]):
for options in self.env["py3o.pdf.options"].search([]):
options_dict = options.odoo2libreoffice_options() options_dict = options.odoo2libreoffice_options()
self.assertIsInstance(options_dict, dict) self.assertIsInstance(options_dict, dict)
@ -73,8 +66,9 @@ class TestReportPy3oFusionServer(test_report_py3o.TestReportPy3o):
self.assertFalse(self.report.msg_py3o_report_not_available) self.assertFalse(self.report.msg_py3o_report_not_available)
# specify a wrong lo bin path and a non native format. # specify a wrong lo bin path and a non native format.
self.env['ir.config_parameter'].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "/wrong_path")
self.env["ir.config_parameter"].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "/wrong_path"
)
self.report.py3o_filetype = "pdf" self.report.py3o_filetype = "pdf"
self.report.refresh() self.report.refresh()
# no native and no bin path, everything is still OK since a fusion # no native and no bin path, everything is still OK since a fusion
@ -91,8 +85,9 @@ class TestReportPy3oFusionServer(test_report_py3o.TestReportPy3o):
self.assertTrue(self.report.msg_py3o_report_not_available) self.assertTrue(self.report.msg_py3o_report_not_available)
# if we set a libreffice runtime, the report is available again # if we set a libreffice runtime, the report is available again
self.env['ir.config_parameter'].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice")
self.env["ir.config_parameter"].set_param(
PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice"
)
self.report.refresh() self.report.refresh()
self.assertTrue(self.report.lo_bin_path) self.assertTrue(self.report.lo_bin_path)
self.assertFalse(self.report.is_py3o_report_not_available) self.assertFalse(self.report.is_py3o_report_not_available)

Loading…
Cancel
Save