diff --git a/report_docx/README.rst b/report_docx/README.rst new file mode 100644 index 0000000000..08a2a37770 --- /dev/null +++ b/report_docx/README.rst @@ -0,0 +1,148 @@ +============ +DOCX reports +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:79e0f48df24a25104ed7488dce18fa34f20814020b270bb7a7682746a4616596 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Freporting--engine-lightgray.png?logo=github + :target: https://github.com/OCA/reporting-engine/tree/16.0/report_docx + :alt: OCA/reporting-engine +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/reporting-engine-16-0/reporting-engine-16-0-report_docx + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/reporting-engine&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to provide docx files as report templates to generate +docx reports. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module was developed because for some users it is easier to use the +docx format for their templates. + +As it uses a slightly different templating language, it is *not* a +drop-in replacement for report_py3o. + +It also does not provide any format conversions, though it should be +pretty simple to write a glue module that converts the resulting docx +files to formats supported by report_py3o. + +Installation +============ + +To install this module, you need to: + +:: + + pip install docxtpl + +Configuration +============= + +To configure this module, you need to: + +1. Go to Settings / Technical / Actions / Report +2. Create a new report with type DOCX +3. Fill in the name of a model, ie ``crm.lead`` +4. Upload your DOCX file in the ``Template`` field +5. To help with crafting expressions in the template, switch to the DOCX + tab, select a record and fill in some expression. When happy with the + result, copy the code into your template document. Don't forget to + read the extensive documentation on the right hand side + +Usage +===== + +To use this module, you need to: + +1. Print some report created according to the configuration manual + +Known issues / Roadmap +====================== + +- support images and embedded objects +- support concatenating docx files for multiple records +- support embedding html + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Hunki Enterprises BV + +Contributors +------------ + +- Holger Brunn + (https://hunki-enterprises.com) + +Other credits +------------- + +The development of this module has been financially supported by: + +- The Open Source Company (https://tosc.nl) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-hbrunn| image:: https://github.com/hbrunn.png?size=40px + :target: https://github.com/hbrunn + :alt: hbrunn + +Current `maintainer `__: + +|maintainer-hbrunn| + +This module is part of the `OCA/reporting-engine `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/report_docx/__init__.py b/report_docx/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/report_docx/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/report_docx/__manifest__.py b/report_docx/__manifest__.py new file mode 100644 index 0000000000..773f2f1efb --- /dev/null +++ b/report_docx/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +{ + "name": "DOCX reports", + "summary": "Create report templates in DOCX and receive DOCX files", + "version": "16.0.1.0.0", + "development_status": "Alpha", + "category": "Reporting", + "website": "https://github.com/OCA/reporting-engine", + "author": "Hunki Enterprises BV, Odoo Community Association (OCA)", + "maintainers": ["hbrunn"], + "license": "AGPL-3", + "external_dependencies": { + "python": ["docxtpl"], + }, + "depends": [ + "mail", + ], + "data": [ + "views/ir_actions_report.xml", + "views/templates.xml", + ], + "demo": [ + "demo/ir_actions_report.xml", + ], + "assets": { + "web.assets_backend": [ + "/report_docx/static/src/report_docx.esm.js", + ] + }, +} diff --git a/report_docx/controllers/__init__.py b/report_docx/controllers/__init__.py new file mode 100644 index 0000000000..4c4f242fa0 --- /dev/null +++ b/report_docx/controllers/__init__.py @@ -0,0 +1 @@ +from . import report diff --git a/report_docx/controllers/report.py b/report_docx/controllers/report.py new file mode 100644 index 0000000000..dd4b0dbb23 --- /dev/null +++ b/report_docx/controllers/report.py @@ -0,0 +1,72 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import json +import logging + +from jinja2 import exceptions as jinja2_exceptions + +from odoo import http, tools + +from odoo.addons.web.controllers import report + +_logger = logging.getLogger(__name__) + + +class ReportController(report.ReportController): + @http.route(["/report_docx"], type="http", auth="user") + def report_docx(self, report_name, ids=None, context=None, data=None, **kwargs): + ids = ids and json.loads(ids) or [] + data = data and json.loads(data) or {} + try: + docx, ext = ( + http.request.env["ir.actions.report"] + .with_context(**(context and json.loads(context) or {})) + ._render_docx(report_name, ids, data=data) + ) + except jinja2_exceptions.TemplateError as e: + _logger.exception("Error while generating report %s", report_name) + return http.request.make_response( + tools.html_escape( + json.dumps( + { + "code": 200, + "message": e.message, + "data": http.serialize_exception(e), + } + ) + ) + ) + except Exception as e: + _logger.exception("Error while generating report %s", report_name) + return http.request.make_response( + tools.html_escape( + json.dumps( + { + "code": 200, + "message": "Odoo Server Error", + "data": http.serialize_exception(e), + } + ) + ) + ) + + report = http.request.env["ir.actions.report"]._get_report(report_name) + filename = report._render_docx_filename(report, ids, data, ext) + + return http.request.make_response( + docx, + headers=[ + ( + "Content-Type", + "application/zip" + if ext == "zip" + else ( + "application/vnd.openxmlformats-officedocument" + ".wordprocessingml.document" + ), + ), + ("Content-Length", len(docx)), + ("Content-Disposition", http.content_disposition(filename)), + ], + ) diff --git a/report_docx/demo/ir_actions_report.xml b/report_docx/demo/ir_actions_report.xml new file mode 100644 index 0000000000..84a14242ce --- /dev/null +++ b/report_docx/demo/ir_actions_report.xml @@ -0,0 +1,33 @@ + + + + + DOCX (one file) + ir.module.module + docx + ir_module_multi_mode_template + object.name + + template + + + + DOCX (zip if multiple) + ir.module.module + docx + ir_module_multi_mode_zip + object.name + + zip + + + diff --git a/report_docx/models/__init__.py b/report_docx/models/__init__.py new file mode 100644 index 0000000000..a248cf2162 --- /dev/null +++ b/report_docx/models/__init__.py @@ -0,0 +1 @@ +from . import ir_actions_report diff --git a/report_docx/models/ir_actions_report.py b/report_docx/models/ir_actions_report.py new file mode 100644 index 0000000000..228126f4d3 --- /dev/null +++ b/report_docx/models/ir_actions_report.py @@ -0,0 +1,270 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import functools +import inspect +import io +from base64 import b64decode +from collections import namedtuple +from zipfile import ZipFile + +from docxtpl import DocxTemplate +from jinja2 import Environment as Environment_jinja2, StrictUndefined +from markupsafe import Markup + +from odoo import _, api, fields, models, tools +from odoo.tools.safe_eval import safe_eval, time + +try: + from num2words import num2words +except ImportError: + + def num2words(*args, **kwargs): + return args[0] + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + report_type = fields.Selection( + selection_add=[("docx", "DOCX")], ondelete={"docx": "cascade"} + ) + docx_template = fields.Binary("Template", attachment=True) + docx_template_filename = fields.Char(compute="_compute_docx_template_filename") + docx_multi_mode = fields.Selection( + [("zip", "Zip file"), ("template", "Template")], + string="Multi records", + help="Select the behavior when the user selected multiple records", + default="zip", + ) + docx_expression_test_model_id = fields.Many2one( + "ir.model", compute="_compute_docx_expression_test_model_id" + ) + docx_expression_test_record = fields.Reference( + selection=lambda self: self.env["ir.model"] + .search([]) + .mapped(lambda x: (x.model, x.name)), + string="Record", + store=False, + ) + docx_expression_test_expression = fields.Char("Test expression", store=False) + docx_expression_test_result = fields.Char( + "Result", compute="_compute_docx_expression_test", store=False + ) + docx_expression_test_code = fields.Char( + "Code", compute="_compute_docx_expression_test", store=False + ) + docx_help = fields.Html(compute="_compute_docx_help") + + def _compute_docx_template_filename(self): + for this in self: + this.docx_template_filename = _("template.docx") + + @api.depends("model") + def _compute_docx_expression_test_model_id(self): + for this in self: + this.docx_expression_test_model_id = ( + self.env["ir.model"]._get(this.model).id + ) + + @api.depends( + "docx_expression_test_record", "docx_expression_test_expression", "model" + ) + def _compute_docx_expression_test(self): + for this in self: + if ( + this.docx_expression_test_record + and this.docx_expression_test_expression + ): + try: + template_code = "{{ " + this.docx_expression_test_expression + " }}" + this.docx_expression_test_result = ( + Environment_jinja2(undefined=StrictUndefined) + .from_string(template_code) + .render( + self._render_docx_eval_context( + self, this.docx_expression_test_record.ids, {} + ) + ) + ) + this.docx_expression_test_code = template_code + except Exception as e: + this.docx_expression_test_result = getattr(e, "message", str(e)) + this.docx_expression_test_code = False + else: + this.docx_expression_test_result = _( + "Select a record and fill in an expression" + ) + this.docx_expression_test_code = False + + @api.depends("docx_multi_mode", "docx_expression_test_record") + def _compute_docx_help(self): + for this in self: + this.docx_help = self.env["ir.qweb"]._render( + "report_docx.template_help", {"object": this} + ) + + @api.onchange("docx_template_filename") + def _onchange_docx_template_filename(self): + if not self.report_name: + self.report_name = self.docx_template_filename + + def _render_docx(self, report_ref, res_ids, data=None): + report = self._get_report(report_ref) + if report.docx_multi_mode == "zip" and len(res_ids) > 1: + zip_buffer = io.BytesIO() + with ZipFile(zip_buffer, "a") as zip_file: + for res_id in res_ids: + docx, ext = self._render_docx(report_ref, [res_id], data=data) + zip_file.writestr( + self._render_docx_filename(report, [res_id], data, ext), + docx, + ) + return zip_buffer.getvalue(), "zip" + template = DocxTemplate(io.BytesIO(b64decode(report.docx_template))) + template.render(self._render_docx_eval_context(report, res_ids, data)) + result = io.BytesIO() + template.save(result) + return result.getvalue(), "docx" + + def _render_docx_eval_context(self, report, res_ids, data): + result = self._get_rendering_context(report, res_ids, data) + result["html2plaintext"] = tools.html2plaintext + result["num2words"] = functools.partial( + num2words, lang=self.env.context.get("lang") or "en" + ) + result["object"] = result["docs"][:1] + result["o"] = result["docs"][:1] + result.update(**self.env["mail.render.mixin"]._render_eval_context()) + return result + + def _render_docx_filename(self, report, res_ids, data, ext): + filename = report.report_name + + if len(res_ids) == 1 and report.print_report_name: + filename = safe_eval( + report.print_report_name, + {"object": self.env[report.model].browse(res_ids), "time": time}, + ) + + if not filename.endswith(f".{ext}"): + filename += f".{ext}" + + return filename + + def _docx_help_get_scope(self): + self.ensure_one() + if not self.docx_expression_test_record: + return [] + ScopeItem = namedtuple("ScopeItem", ["name", "explanation"]) + result = [] + explanations = self._docx_help_get_explanations() + for key, value in self._render_docx_eval_context( + self, self.docx_expression_test_record.ids, {} + ).items(): + explanation = explanations.get(key) + if explanation == "hide": + continue + if inspect.isfunction(value): + key = key + str(inspect.signature(value)) + result.append(ScopeItem(key, explanation)) + return sorted(result, key=lambda x: x[0]) + + def _docx_help_get_explanations(self): + return { + "abs": _("Returns the absolute value of a number"), + "ctx": "hide", + "datetime": Markup( + _( + "Python datetime module. Ie datetime.datetime.now()" + " returns the current date" + ) + ), + "docs": "hide", + "doc_ids": "hide", + "doc_model": "hide", + "filter": "hide", + "format_amount": Markup( + _( + "Formats an amount according to a currency, usually called like " + "format_amount(object.amount_field, object.currency_id).
" + "Note you can format any number according to the company currency by " + "using object.env.company.currency_id.format(42)" + ) + ), + "format_date": Markup( + _( + "Formats a date according to the current language, ie " + "format_date(object.create_date)" + ) + ), + "format_datetime": Markup( + _( + "Formats a date and time according to the current language, ie " + "format_datetime(object.create_date)" + ) + ), + "format_duration": Markup( + _( + "Formats a number as a time interval, ie " + "format_duration(1.5) " + "returns 01:30" + ) + ), + "format_time": Markup( + _( + "Formats a time according to the current language, ie " + "format_time(object.create_date)" + ) + ), + "hasattr": "Checks if some object has an attribute", + "html2plaintext": "Converts HTML to text", + "is_html_empty": Markup( + _( + "Checks if an html field is empty, ie " + "is_html_empty('<p/>') returns True" + ) + ), + "len": "Returns the length of a string or record collection", + "map": "hide", + "max": Markup( + _( + "Returns the maximum of the arguments passed. " + "max(0, 42, 41) returns 42" + ) + ), + "min": Markup( + _( + "Returns the minimum of the arguments passed. " + "min(0, 42, 41) returns 0" + ) + ), + "num2words": Markup( + _( + "Converts a number to a string, ie num2words(42) returns " + ""fourty-two"" + ) + ), + "o": "hide", + "object": "hide", + "quote": "hide", + "reduce": "hide", + "relativedelta": Markup( + _( + "Python relativedelta module. Allows complex date computations " + "like " + "datetime.date.today() + relativedelta(months=2, day=1) - " + "relativedelta(days=1) which returns the date of the last " + "day of the next month" + ) + ), + "sum": Markup( + _( + "Sums up the argument collection, ie " + "sum(docs.mapped('some_field')) " + "returns the sum of the values of some_field" + ) + ), + "urlencode": "hide", + "user": "The user generating the report", + } diff --git a/report_docx/readme/CONFIGURE.md b/report_docx/readme/CONFIGURE.md new file mode 100644 index 0000000000..c7c8e17708 --- /dev/null +++ b/report_docx/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +To configure this module, you need to: + +1. Go to Settings / Technical / Actions / Report +2. Create a new report with type DOCX +3. Fill in the name of a model, ie `crm.lead` +4. Upload your DOCX file in the ``Template`` field +5. To help with crafting expressions in the template, switch to the DOCX tab, select a record and fill in some expression. When happy with the result, copy the code into your template document. Don't forget to read the extensive documentation on the right hand side diff --git a/report_docx/readme/CONTEXT.md b/report_docx/readme/CONTEXT.md new file mode 100644 index 0000000000..ea4763f04d --- /dev/null +++ b/report_docx/readme/CONTEXT.md @@ -0,0 +1,5 @@ +This module was developed because for some users it is easier to use the docx format for their templates. + +As it uses a slightly different templating language, it is *not* a drop-in replacement for report\_py3o. + +It also does not provide any format conversions, though it should be pretty simple to write a glue module that converts the resulting docx files to formats supported by report\_py3o. diff --git a/report_docx/readme/CONTRIBUTORS.md b/report_docx/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..b28199e1f4 --- /dev/null +++ b/report_docx/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Holger Brunn \ (https://hunki-enterprises.com) diff --git a/report_docx/readme/CREDITS.md b/report_docx/readme/CREDITS.md new file mode 100644 index 0000000000..3c89d97419 --- /dev/null +++ b/report_docx/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- The Open Source Company (https://tosc.nl) diff --git a/report_docx/readme/DESCRIPTION.md b/report_docx/readme/DESCRIPTION.md new file mode 100644 index 0000000000..407853e38d --- /dev/null +++ b/report_docx/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows to provide docx files as report templates to generate docx reports. diff --git a/report_docx/readme/INSTALL.md b/report_docx/readme/INSTALL.md new file mode 100644 index 0000000000..888d9dc1da --- /dev/null +++ b/report_docx/readme/INSTALL.md @@ -0,0 +1,3 @@ +To install this module, you need to: + + pip install docxtpl diff --git a/report_docx/readme/ROADMAP.md b/report_docx/readme/ROADMAP.md new file mode 100644 index 0000000000..4fcdfaac29 --- /dev/null +++ b/report_docx/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- support images and embedded objects +- support concatenating docx files for multiple records +- support embedding html diff --git a/report_docx/readme/USAGE.md b/report_docx/readme/USAGE.md new file mode 100644 index 0000000000..a2fb072afe --- /dev/null +++ b/report_docx/readme/USAGE.md @@ -0,0 +1,3 @@ +To use this module, you need to: + +1. Print some report created according to the configuration manual diff --git a/report_docx/static/description/icon.png b/report_docx/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/report_docx/static/description/icon.png differ diff --git a/report_docx/static/description/index.html b/report_docx/static/description/index.html new file mode 100644 index 0000000000..0b9b57ca29 --- /dev/null +++ b/report_docx/static/description/index.html @@ -0,0 +1,492 @@ + + + + + +DOCX reports + + + +
+

DOCX reports

+ + +

Alpha License: AGPL-3 OCA/reporting-engine Translate me on Weblate Try me on Runboat

+

This module allows to provide docx files as report templates to generate +docx reports.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Use Cases / Context

+

This module was developed because for some users it is easier to use the +docx format for their templates.

+

As it uses a slightly different templating language, it is not a +drop-in replacement for report_py3o.

+

It also does not provide any format conversions, though it should be +pretty simple to write a glue module that converts the resulting docx +files to formats supported by report_py3o.

+
+
+

Installation

+

To install this module, you need to:

+
+pip install docxtpl
+
+
+
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Go to Settings / Technical / Actions / Report
  2. +
  3. Create a new report with type DOCX
  4. +
  5. Fill in the name of a model, ie crm.lead
  6. +
  7. Upload your DOCX file in the Template field
  8. +
  9. To help with crafting expressions in the template, switch to the DOCX +tab, select a record and fill in some expression. When happy with the +result, copy the code into your template document. Don’t forget to +read the extensive documentation on the right hand side
  10. +
+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Print some report created according to the configuration manual
  2. +
+
+
+

Known issues / Roadmap

+
    +
  • support images and embedded objects
  • +
  • support concatenating docx files for multiple records
  • +
  • support embedding html
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Hunki Enterprises BV
  • +
+
+ +
+

Other credits

+

The development of this module has been financially supported by:

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

hbrunn

+

This module is part of the OCA/reporting-engine project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/report_docx/static/examples/modules_multi_mode_template.docx b/report_docx/static/examples/modules_multi_mode_template.docx new file mode 100644 index 0000000000..db3379ba03 Binary files /dev/null and b/report_docx/static/examples/modules_multi_mode_template.docx differ diff --git a/report_docx/static/examples/modules_multi_mode_zip.docx b/report_docx/static/examples/modules_multi_mode_zip.docx new file mode 100644 index 0000000000..8877b1346b Binary files /dev/null and b/report_docx/static/examples/modules_multi_mode_zip.docx differ diff --git a/report_docx/static/src/report_docx.esm.js b/report_docx/static/src/report_docx.esm.js new file mode 100644 index 0000000000..459a018eb6 --- /dev/null +++ b/report_docx/static/src/report_docx.esm.js @@ -0,0 +1,39 @@ +/* @odoo-module */ + +import {download} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; + +registry + .category("ir.actions.report handlers") + .add("docx", async function (action, options, env) { + if (action.report_type === "docx") { + env.services.ui.block(); + const context = action.context || {}; + try { + await download({ + url: "/report_docx", + data: { + ids: JSON.stringify(context.active_ids || []), + context: JSON.stringify( + Object.assign({}, env.services.user.context, context) + ), + report_name: action.report_name, + data: JSON.stringify(action.data || {}), + }, + }); + } finally { + env.services.ui.unblock(); + } + 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/report_docx/tests/__init__.py b/report_docx/tests/__init__.py new file mode 100644 index 0000000000..16b23ce720 --- /dev/null +++ b/report_docx/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_report_docx +from . import test_report_docx_controller diff --git a/report_docx/tests/test_report_docx.py b/report_docx/tests/test_report_docx.py new file mode 100644 index 0000000000..bf1f97a65d --- /dev/null +++ b/report_docx/tests/test_report_docx.py @@ -0,0 +1,53 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from io import BytesIO +from zipfile import ZipFile + +from docx import Document + +from odoo.tests.common import Form, TransactionCase + + +class TestReportDocx(TransactionCase): + def test_demo_reports(self): + zip_buffer, ext = self.env["ir.actions.report"]._render( + "ir_module_multi_mode_zip", + ( + self.env.ref("base.module_report_docx") + + self.env.ref("base.module_base") + ).ids, + ) + self.assertEqual(ext, "zip") + with ZipFile(BytesIO(zip_buffer)) as zip_file: + self.assertItemsEqual( + zip_file.namelist(), ["report_docx.docx", "base.docx"] + ) + docx_buffer, ext = self.env["ir.actions.report"]._render( + "ir_module_multi_mode_template", self.env.ref("base.module_base").ids + ) + self.assertEqual(ext, "docx") + all_text = "\n".join(p.text for p in Document(BytesIO(docx_buffer)).paragraphs) + self.assertIn("That’s not so many modules", all_text) + docx_buffer, ext = self.env["ir.actions.report"]._render( + "ir_module_multi_mode_template", + self.env["ir.module.module"].search([], limit=40).ids, + ) + all_text = "\n".join(p.text for p in Document(BytesIO(docx_buffer)).paragraphs) + self.assertIn("This are a lot of modules!", all_text) + self.assertNotIn("This are a looooot of modules!", all_text) + + def test_form(self): + with Form( + self.env.ref("report_docx.report_ir_module_multi_mode_template") + ) as report_form: + report_form.docx_expression_test_record = self.env.ref("base.module_base") + self.assertIn("format_amount", report_form.docx_help) + report_form.docx_expression_test_expression = "object.nonexisting" + self.assertIn( + "has no attribute 'nonexisting'", + report_form.docx_expression_test_result, + ) + report_form.docx_expression_test_expression = "object.name" + self.assertEqual(report_form.docx_expression_test_result, "base") + self.assertEqual(report_form.docx_expression_test_code, "{{ object.name }}") diff --git a/report_docx/tests/test_report_docx_controller.py b/report_docx/tests/test_report_docx_controller.py new file mode 100644 index 0000000000..2898678722 --- /dev/null +++ b/report_docx/tests/test_report_docx_controller.py @@ -0,0 +1,22 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import json + +from odoo.tests.common import HttpCase + + +class TestReportDocxController(HttpCase): + def test_demo_reports(self): + self.authenticate("admin", "admin") + result = self.opener.get( + self.base_url() + "/report_docx", + data=dict( + report_name="ir_module_multi_mode_zip", + ids=json.dumps(self.env.ref("base.module_report_docx").ids), + ), + ) + self.assertEqual( + result.headers["content-type"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) diff --git a/report_docx/views/ir_actions_report.xml b/report_docx/views/ir_actions_report.xml new file mode 100644 index 0000000000..6001f8c724 --- /dev/null +++ b/report_docx/views/ir_actions_report.xml @@ -0,0 +1,56 @@ + + + + + ir.actions.report + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/report_docx/views/templates.xml b/report_docx/views/templates.xml new file mode 100644 index 0000000000..eda0ca634f --- /dev/null +++ b/report_docx/views/templates.xml @@ -0,0 +1,60 @@ + + + diff --git a/requirements.txt b/requirements.txt index e02b3dd1f6..bf11d411f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # generated from manifests external_dependencies cryptography +docxtpl endesive ; python_version >= '3.12' endesive<=2.18.5 ; python_version < '3.12' mock diff --git a/setup/report_docx/odoo/addons/report_docx b/setup/report_docx/odoo/addons/report_docx new file mode 120000 index 0000000000..70e20647f8 --- /dev/null +++ b/setup/report_docx/odoo/addons/report_docx @@ -0,0 +1 @@ +../../../../report_docx \ No newline at end of file diff --git a/setup/report_docx/setup.py b/setup/report_docx/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/report_docx/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)