# HG changeset patch # User Houzefa Abbasbhay <houzefa.abba@xcg-consulting.fr> # Date 1675178229 -3600 # Tue Jan 31 16:17:09 2023 +0100 # Branch 15.0 # Node ID 81ae7213f0cd37e1e90235f732c751ad607d49e8 # Parent 9b1520b0e1c3da9efe39cb1ee94a9e6b954ccf40 Migrate to Odoo 15.0 & latest xcg/templates/odoo_module. diff --git a/.editorconfig b/.editorconfig --- a/.editorconfig +++ b/.editorconfig @@ -11,7 +11,7 @@ indent_size = 2 # Do not configure editor for libs -[{*/static/{lib,src/lib}/**}] +[*/static/{lib,src/lib}/**] charset = unset end_of_line = unset indent_size = unset diff --git a/.flake8 b/.flake8 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,6 @@ [flake8] +# Some leniancy for docstrings black puts in single lines (add 3 ", 79+3=82). +max-line-length = 82 per-file-ignores= __init__.py:F401 + __manifest__.py:B018 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,3 @@ include: - project: xcg/ci-templates - file: /odoo/13.0/gitlab-ci.yaml + file: /odoo/15.0/gitlab-ci.yaml diff --git a/.hgconf b/.hgconf --- a/.hgconf +++ b/.hgconf @@ -1,5 +1,5 @@ [converter] pulluri = https://orus.io/xcg/odoo-modules/converter layout = ../converter -track = 13.0 +track = 15.0 expand = diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -2,6 +2,11 @@ Changelog ========= +15.0.1.0.0 +---------- + +* Migrate to Odoo 15.0. + 13.0.3.4.0 ---------- diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -21,10 +21,10 @@ { "name": "Redner", "license": "AGPL-3", - "version": "13.0.3.4.0", + "version": "15.0.1.0.0", "category": "Reporting", "author": "XCG Consulting", - "website": "https://odoo.consulting/", + "website": "https://orbeet.io/", # converter: https://orus.io/xcg/odoo-modules/converter "depends": ["converter", "mail", "web"], "data": [ @@ -32,9 +32,13 @@ "views/redner_template.xml", "views/mail_template.xml", "views/ir_actions_report.xml", - "views/assets.xml", "views/menu.xml", ], + "assets": { + "web.assets_backend": [ + "redner/static/src/js/redner_report_action.esm.js" + ], + }, "installable": True, # These dependencies are in the "requirements" file. "external_dependencies": {"python": ["requests_unixsocket"]}, diff --git a/controllers/main.py b/controllers/main.py --- a/controllers/main.py +++ b/controllers/main.py @@ -21,7 +21,8 @@ import json import mimetypes -from werkzeug import exceptions, url_decode +from werkzeug import exceptions +from werkzeug.urls import url_decode from odoo.http import request, route from odoo.tools import html_escape @@ -34,6 +35,10 @@ class ReportController(main.ReportController): + """Add redner report downloads within report controllers. + Much of this code comes from OCA modules, the latest one being report_xlsx. + """ + @route() def report_routes(self, reportname, docids=None, converter=None, **data): if converter != "redner": @@ -67,7 +72,7 @@ description="Redner action report not found for report_name " "%s" % reportname ) - res, filetype = action_redner_report.render(docids, data) + res, filetype = action_redner_report._render(docids, data) filename = action_redner_report.gen_report_download_filename( docids, data ) @@ -82,17 +87,18 @@ return request.make_response(res, headers=http_headers) @route() - def report_download(self, data, token): - """This function is used by 'redneractionmanager.js' in order to - trigger the download of a redner/controller report. + def report_download(self, data, context=None): + """This function is used by 'action_manager_report.js' in order to + trigger the download of a pdf/controller report. + :param data: a javascript array JSON.stringified containg report - internal url ([0]) and type [1] - :returns: Response with a filetoken cookie and an attachment header + internal url ([0]) and type [1] + :returns: Response with an attachment header """ requestcontent = json.loads(data) url, report_type = requestcontent[0], requestcontent[1] if "redner" not in report_type: - return super().report_download(data, token) + return super().report_download(data, context=context) try: reportname = url.split("/report/redner/")[1].split("?")[0] docids = None @@ -102,16 +108,23 @@ if docids: # Generic report: response = self.report_routes( - reportname, docids=docids, converter="redner" + reportname, + docids=docids, + converter="redner", + context=context, ) else: # Particular report: # decoding the args represented in JSON data = list(url_decode(url.split("?")[1]).items()) + if "context" in data: + context, data_context = json.loads( + context or "{}" + ), json.loads(data.pop("context")) + context = json.dumps({**context, **data_context}) response = self.report_routes( - reportname, converter="redner", **dict(data) + reportname, converter="redner", context=context, **data ) - response.set_cookie("fileToken", token) return response except Exception as e: se = _serialize_exception(e) diff --git a/doc/autotodo.py b/doc/autotodo.py --- a/doc/autotodo.py +++ b/doc/autotodo.py @@ -22,6 +22,7 @@ import os import os.path import sys +from collections.abc import Mapping def main(): @@ -33,13 +34,14 @@ exts = sys.argv[2].split(",") tags = sys.argv[3].split(",") todolist = {tag: [] for tag in tags} + path_file_length: Mapping[str, int] = {} for root, _dirs, files in os.walk(folder): - scan_folder((exts, tags, todolist), root, files) - create_autotodo(folder, todolist) + scan_folder((exts, tags, todolist, path_file_length), root, files) + create_autotodo(folder, todolist, path_file_length) -def write_info(f, infos, folder): +def write_info(f, infos, folder, path_file_length: Mapping[str, int]): # Check sphinx version for lineno-start support import sphinx @@ -52,7 +54,7 @@ for i in infos: path = i[0] line = i[1] - lines = (line - 3, line + 4) + lines = (line - 3, min(line + 4, path_file_length[path])) class_name = ":class:`%s`" % os.path.basename( os.path.splitext(path)[0] ) @@ -71,7 +73,7 @@ path, lines[0], lines[1], - line, + 4, ) ) if lineno_start: @@ -79,33 +81,35 @@ f.write("\n") -def create_autotodo(folder, todolist): +def create_autotodo(folder, todolist, path_file_length: Mapping[str, int]): with open("autotodo", "w+") as f: for tag, info in list(todolist.items()): f.write("%s\n%s\n\n" % (tag, "=" * len(tag))) - write_info(f, info, folder) + write_info(f, info, folder, path_file_length) def scan_folder(data_tuple, dirname, names): - (exts, tags, res) = data_tuple - file_info = {} + (exts, tags, res, path_file_length) = data_tuple for name in names: (root, ext) = os.path.splitext(name) if ext in exts: - file_info = scan_file(os.path.join(dirname, name), tags) + path = os.path.join(dirname, name) + file_info, length = scan_file(path, tags) + path_file_length[path] = length for tag, info in list(file_info.items()): if info: res[tag].extend(info) -def scan_file(filename, tags): +def scan_file(filename, tags) -> tuple[dict[str, tuple[str, int, str]], int]: res = {tag: [] for tag in tags} + line_num: int = 0 with open(filename, "r") as f: for line_num, line in enumerate(f): for tag in tags: if tag in line: res[tag].append((filename, line_num, line[:-1].strip())) - return res + return res, line_num if __name__ == "__main__": diff --git a/doc/conf.py b/doc/conf.py --- a/doc/conf.py +++ b/doc/conf.py @@ -12,9 +12,9 @@ import os import sys -import odoo +from odoo_scripts.config import Configuration -from odoo_scripts.config import Configuration +import odoo # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -33,6 +33,7 @@ "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.graphviz", + "sphinx.ext.viewcode", "sphinxodoo.ext.autodoc", ] @@ -60,7 +61,7 @@ # General information about the project. project = d["name"] -copyright = "2021 XCG Consulting" +copyright = "2021, 2022 XCG Consulting" author = d["author"] module_nospace = project.replace(" ", "") module_description = d.get("summary", "") @@ -150,7 +151,7 @@ # odoo-sphinx-autodoc # -# sphinxodoo_addons: List of addons name to load (if empty, no addon will be +# sphinxodoo_addons : List of addons name to load (if empty, no addon will be # loaded) this_module = os.path.basename( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -171,7 +172,7 @@ c.read(setup_path) if c.has_section("odoo_scripts"): # reload with odoo_scripts - c = Configuration(setup_path) + c = Configuration(os.path.dirname(setup_path)) else: c = None if not c: @@ -180,7 +181,7 @@ else: directory = None -sphinxodoo_addons_path = [] +sphinxodoo_addons_path = list() if c: addon_dirs = set(os.path.dirname(path) for path in c.modules) diff --git a/doc/index.rst b/doc/index.rst --- a/doc/index.rst +++ b/doc/index.rst @@ -3,7 +3,7 @@ Contents: .. toctree:: - :maxdepth: 2 + :maxdepth: 4 modules NEWS diff --git a/doc/modules.rst b/doc/modules.rst new file mode 100644 --- /dev/null +++ b/doc/modules.rst @@ -0,0 +1,7 @@ +odoo_module +=========== + +.. toctree:: + :maxdepth: 4 + + odoo_module diff --git a/models/ir_actions_report.py b/models/ir_actions_report.py --- a/models/ir_actions_report.py +++ b/models/ir_actions_report.py @@ -20,11 +20,10 @@ import base64 import logging -import time from odoo import _, api, fields, models from odoo.exceptions import AccessError, ValidationError -from odoo.tools.safe_eval import safe_eval +from odoo.tools.safe_eval import safe_eval, time from ..utils.formats import Formats @@ -72,7 +71,9 @@ selections.append((name, description)) return selections - report_type = fields.Selection(selection_add=[("redner", "redner")]) + report_type = fields.Selection( + selection_add=[("redner", "redner")], ondelete={"redner": "cascade"} + ) redner_multi_in_one = fields.Boolean( string="Multiple Records in a Single Redner Report", @@ -173,7 +174,8 @@ ] ) - def render_redner(self, res_ids, data): + def _render_redner(self, res_ids, data=None): + """Called by ``_render``, method name dynamically built.""" self.ensure_one() if self.report_type != "redner": raise RuntimeError( @@ -183,12 +185,11 @@ return ( self.env["redner.report"] .create({"ir_actions_report_id": self.id}) - .create_report(res_ids, data) + .create_report(res_ids, data or {}) ) def gen_report_download_filename(self, res_ids, data): - """Override this function to change the name of the downloaded report - """ + """Override this function to change the name of the downloaded report""" self.ensure_one() report = self.get_from_report_name(self.report_name, self.report_type) @@ -236,7 +237,7 @@ attachment_vals = { "name": attachment_name, - "datas": base64.encodestring(buffer.getvalue()), + "datas": base64.b64encode(buffer.getvalue()), "res_model": self.model, "res_id": record.id, "type": "binary", diff --git a/models/mail_template.py b/models/mail_template.py --- a/models/mail_template.py +++ b/models/mail_template.py @@ -110,10 +110,10 @@ return values - def generate_email(self, res_ids, fields=None): + def generate_email(self, res_ids, fields): self.ensure_one() - results = super().generate_email(res_ids, fields=fields) + results = super().generate_email(res_ids, fields) if not self.is_redner_template: return results @@ -130,7 +130,6 @@ return self._patch_email_values(results, res_ids[0]) def render_variable_hook(self, variables): - """Override to add additional variables in mail "render template" func - """ + """Override to add additional variables in mail "render template" func""" variables.update({"image": lambda value: image(value)}) return super().render_variable_hook(variables) diff --git a/models/redner_template.py b/models/redner_template.py --- a/models/redner_template.py +++ b/models/redner_template.py @@ -27,6 +27,8 @@ logger = logging.getLogger(__name__) +_redner = None + class RednerTemplate(models.Model): @@ -91,25 +93,24 @@ template_data = fields.Binary("Libreoffice Template") - _redner = None - @property def redner(self): """Try to avoid Redner instance to be over created""" - if self._redner is None: + global _redner + if _redner is None: # Bypass security rules when reading these configuration params. By # default, only some administrators have access to that model. config_model = self.env["ir.config_parameter"].sudo() - self._redner = Redner( + _redner = Redner( config_model.get_param("redner.api_key"), config_model.get_param("redner.server_url"), config_model.get_param("redner.account"), int(config_model.get_param("redner.timeout", default="20")), ) - return self._redner + return _redner @api.model def create(self, vals): diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,19 @@ +[tool.isort] +line_length = 79 +py_version = 310 +profile = "black" +known_odoo = ['odoo'] +known_odoo_addons = ['odoo.addons'] +sections = [ + 'FUTURE', + 'STDLIB', + 'THIRDPARTY', + 'ODOO', + 'ODOO_ADDONS', + 'FIRSTPARTY', + 'LOCALFOLDER' +] + [tool.black] line-length = 79 -target = 3.8 +target = 3.10 diff --git a/redner.py b/redner.py --- a/redner.py +++ b/redner.py @@ -23,12 +23,11 @@ from urllib.parse import quote import requests +import requests_unixsocket from odoo import _ from odoo.exceptions import ValidationError -import requests_unixsocket - _logger = logging.getLogger(__name__) REDNER_API_PATH = "api/v1/" diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -1,3 +1,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_redner_report,access_redner_report,model_redner_report,,1,1,1,1 access_redner_template,access_redner_template,model_redner_template,,1,1,1,1 -access_redner_substitution,access_redner_substitution,model_redner_substitution,,1,1,1,1 \ No newline at end of file +access_redner_substitution,access_redner_substitution,model_redner_substitution,,1,1,1,1 diff --git a/static/src/js/redneractionmanager.js b/static/src/js/redner_report_action.esm.js rename from static/src/js/redneractionmanager.js rename to static/src/js/redner_report_action.esm.js --- a/static/src/js/redneractionmanager.js +++ b/static/src/js/redner_report_action.esm.js @@ -1,3 +1,4 @@ +/** @odoo-module **/ /* Redner Odoo module Copyright (C) 2016 XCG Consulting <https://xcg-consulting.fr> @@ -16,43 +17,60 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -odoo.define("redner.report", function (require) { - "use strict"; +/* Add a report handler to download redner reports. + * Adapted from OCA's report_xlsx module: + * https://github.com/OCA/reporting-engine/blob/15.0/report_xlsx/static/src/js/report/action_manager_report.esm.js + */ - var ActionManager = require("web.ActionManager"); +import {download} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; - ActionManager.include({ - _executeReportAction: function (action, options) { - // Redner reports - if (action.report_type === "redner") { - console.log(action.report_type); - return this._triggerDownload(action, options, "redner"); +registry + .category("ir.actions.report handlers") + .add("redner_handler", async function (action, options, env) { + if (action.report_type === "redner") { + const type = action.report_type; + let url = `/report/${type}/${action.report_name}`; + const actionContext = action.context || {}; + if (action.data && JSON.stringify(action.data) !== "{}") { + // Build a query string with `action.data` (it's the place where reports + // using a wizard to customize the output traditionally put their options) + const action_options = encodeURIComponent(JSON.stringify(action.data)); + const context = encodeURIComponent(JSON.stringify(actionContext)); + url += `?options=${action_options}&context=${context}`; + } else { + if (actionContext.active_ids) { + url += `/${actionContext.active_ids.join(",")}`; + } + if (type === "redner") { + const context = encodeURIComponent( + JSON.stringify(env.services.user.context) + ); + url += `?context=${context}`; + } } - return this._super.apply(this, arguments); - }, - _makeReportUrls: function (action) { - var reportUrls = this._super.apply(this, arguments); - reportUrls.redner = "/report/redner/" + action.report_name; - // We may have to build a query string with `action.data`. It's the place - // were report's using a wizard to customize the output traditionally put - // their options. - if ( - _.isUndefined(action.data) || - _.isNull(action.data) || - (_.isObject(action.data) && _.isEmpty(action.data)) - ) { - if (action.context.active_ids) { - var activeIDsPath = "/" + action.context.active_ids.join(","); - reportUrls.redner += activeIDsPath; - } - } else { - var serializedOptionsPath = - "?options=" + encodeURIComponent(JSON.stringify(action.data)); - serializedOptionsPath += - "&context=" + encodeURIComponent(JSON.stringify(action.context)); - reportUrls.redner += serializedOptionsPath; + env.services.ui.block(); + try { + await download({ + url: "/report/download", + data: { + data: JSON.stringify([url, action.report_type]), + context: JSON.stringify(env.services.user.context), + }, + }); + } finally { + env.services.ui.unblock(); } - return reportUrls; - }, + const onClose = options.onClose; + if (action.close_on_report_download) { + return env.services.action.doAction( + {type: "ir.actions.act_window_close"}, + {onClose} + ); + } else if (onClose) { + onClose(); + } + return Promise.resolve(true); + } + return Promise.resolve(false); }); -}); diff --git a/tests/test_ir_actions_report.py b/tests/test_ir_actions_report.py --- a/tests/test_ir_actions_report.py +++ b/tests/test_ir_actions_report.py @@ -72,7 +72,7 @@ json=lambda: [{"body": base64.b64encode(b"test-rendered-report")}], ) demo_user = self.env.ref("base.user_demo") - render_ret = self.report.render([demo_user.id]) + render_ret = self.report._render([demo_user.id]) requests_post_mock.assert_called_once_with( "https://test-redner-url/api/v1/render", json={ diff --git a/utils/formats.py b/utils/formats.py --- a/utils/formats.py +++ b/utils/formats.py @@ -37,14 +37,14 @@ class Format: """A format representation that contains: - a name we use in our applications - an ODF name (like: 'MS Word 2003 XML') which is the name you must - use as a filter to call a renderserver or a LibreOffice server - a mimetype that corresponds to the mimetype of the produced file - if you ask LibreOffice to convert to the corresponding format - and a simple flag that indicates if the format is Native or if it is - produced by calling a LibreOffice filter to convert the native - document to an "external format" + a name we use in our applications + an ODF name (like: 'MS Word 2003 XML') which is the name you must + use as a filter to call a renderserver or a LibreOffice server + a mimetype that corresponds to the mimetype of the produced file + if you ask LibreOffice to convert to the corresponding format + and a simple flag that indicates if the format is Native or if it is + produced by calling a LibreOffice filter to convert the native + document to an "external format" """ def __init__(self, name, odfname, mimetype=DEFAULT_MIMETYPE, native=False): diff --git a/views/assets.xml b/views/assets.xml deleted file mode 100644 --- a/views/assets.xml +++ /dev/null @@ -1,11 +0,0 @@ -<?xml version="1.0" encoding="utf-8" ?> -<odoo> - <template id="assets_backend" name="redner assets" inherit_id="web.assets_backend"> - <xpath expr="." position="inside"> - <script - type="text/javascript" - src="/redner/static/src/js/redneractionmanager.js" - /> - </xpath> - </template> -</odoo>