diff --git a/report_qweb_text_control/README.rst b/report_qweb_text_control/README.rst new file mode 100644 index 0000000000..d6dcd1e712 --- /dev/null +++ b/report_qweb_text_control/README.rst @@ -0,0 +1,250 @@ +================= +QWeb Text Control +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:620ee9b73c86f1ddea10c06a761f12e08bce6e56c2963c2bed1a99be1f88df0b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Freporting--engine-lightgray.png?logo=github + :target: https://github.com/OCA/reporting-engine/tree/14.0/report_qweb_text_control + :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-14-0/reporting-engine-14-0-report_qweb_text_control + :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=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allow text reports to generate positional text files by providing +precise control over whitespace, special characters, and field formatting. + +Use Case +-------- + +Generate fixed-width text files for legacy systems, bank transfers, or any interface +requiring exact positional formatting with specific characters and spacing. + +Example +------- + +Template:: + + + + + +Output:: + + John Doe 00000012340000015000+ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Default Replacement Elements +----------------------------- + +The module provides these default replacement elements: + +* ```` → Carriage return (``\r``) +* ```` → Line feed (``\n``) +* ```` → Windows line ending (``\r\n``) +* ```` → Tab character (``\t``) +* ```` → Semicolon (``;``) +* ```` → Removed (empty string) +* ``
`` → Removed (empty string) + +Field Formatting Functions +-------------------------- + +The module includes these field formatting functions: + +* ``A(value, length=None)`` - Alphanumeric: left-aligned, space-padded +* ``N(value, length=None, digits=None, sign=False)`` - Numeric: right-aligned, + zero-padded, optional sign at the end +* ``M(value, length=None)`` - Monetary: calls ``N`` with ``digits=2`` and ``sign=True`` +* ``T(value, length=None, digits=2)`` - Tax: calls ``N`` with ``digits=2`` and ``sign=False`` +* ``D(value, length=8, dtformat="%Y%m%d")`` - Date: formats dates with + customizable format, defaults to yyyymmdd +* ``H(value, length=4, dtformat="%H%M")`` - Time: formats times with + customizable format, defaults to hhmm +* ``DT(value, length=12, dtformat="%Y%m%d%H%M")`` - Datetime: formats + datetime with customizable format, defaults to yyyyMMddhhmm + +Post-processing Behavior +------------------------ + +The module automatically performs the following post-processing on text +content: + +1. Strips whitespace from each line +2. Removes all line breaks from QWeb template structure +3. Removes HTML elements (SPAN, DIV) completely +4. Replaces special elements with their defined characters +5. Returns single-line output with controlled formatting + +This ensures clean, consistent text output regardless of QWeb template +formatting or HTML elements. + +Usage +===== + +To create a text-controlled QWeb report, define a report with +``report_type="qweb-text"`` and enable the "Enable Text Control" option: + +.. code-block:: xml + + + Custom Text Report + your.model + qweb-text + + your_module.report_custom_text + + report + + +Special Elements in Templates +------------------------------ + +In your QWeb template, use special elements for controlled output. HTML elements +like SPAN and DIV are automatically removed: + +.. code-block:: xml + + + +Field Formatting +----------------- + +The module provides field formatting functions available in templates: + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Customization +------------- + +Override the ``_get_replacement_elements()`` method to define your own special +elements: + +.. code-block:: python + + from odoo import models + + class CustomTextReport(models.Model): + _inherit = "ir.actions.report" + + def _get_replacement_elements(self): + """Override to customize replacement elements""" + elements = super()._get_replacement_elements() + elements.update({ + "CUSTOM": "CustomValue", + "NEWLINE": "\r\n", + }) + return elements + +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 +~~~~~~~ + +* Open Source Integrators + +Contributors +~~~~~~~~~~~~ + +This module was developed with AI assistance under close guidance and supervision +from experienced Odoo developers. The development utilized Cascade (SWE-1.5) for +code implementation and testing, with Claude AI providing architectural guidance +and code quality review. All AI-generated code was thoroughly reviewed, validated, +and approved by human supervisors. + +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. + +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_qweb_text_control/__init__.py b/report_qweb_text_control/__init__.py new file mode 100644 index 0000000000..55c48967bb --- /dev/null +++ b/report_qweb_text_control/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2025 Open Source Integrators +from . import report diff --git a/report_qweb_text_control/__manifest__.py b/report_qweb_text_control/__manifest__.py new file mode 100644 index 0000000000..af8332dc37 --- /dev/null +++ b/report_qweb_text_control/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +{ + "name": "QWeb Text Control", + "summary": "Controls whitespace and special characters in text QWeb reports", + "version": "14.0.1.0.0", + "category": "Reporting", + "website": "https://github.com/OCA/reporting-engine", + "author": "Open Source Integrators, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "base", + ], + "data": [ + "views/ir_actions_report_views.xml", + ], + "demo": [ + "demo/partner_report.xml", + ], +} diff --git a/report_qweb_text_control/demo/partner_report.xml b/report_qweb_text_control/demo/partner_report.xml new file mode 100644 index 0000000000..4f1408c8c3 --- /dev/null +++ b/report_qweb_text_control/demo/partner_report.xml @@ -0,0 +1,30 @@ + + + + + + Partner Text Report + res.partner + qweb-text + + report_qweb_text_control.report_partner_text + + report + + + + + + diff --git a/report_qweb_text_control/readme/CONFIGURE.rst b/report_qweb_text_control/readme/CONFIGURE.rst new file mode 100644 index 0000000000..321c5cc730 --- /dev/null +++ b/report_qweb_text_control/readme/CONFIGURE.rst @@ -0,0 +1,44 @@ +Default Replacement Elements +----------------------------- + +The module provides these default replacement elements: + +* ```` → Carriage return (``\r``) +* ```` → Line feed (``\n``) +* ```` → Windows line ending (``\r\n``) +* ```` → Tab character (``\t``) +* ```` → Semicolon (``;``) +* ```` → Removed (empty string) +* ``
`` → Removed (empty string) + +Field Formatting Functions +-------------------------- + +The module includes these field formatting functions: + +* ``A(value, length=None)`` - Alphanumeric: left-aligned, space-padded +* ``N(value, length=None, digits=None, sign=False)`` - Numeric: right-aligned, + zero-padded, optional sign at the end +* ``M(value, length=None)`` - Monetary: calls ``N`` with ``digits=2`` and ``sign=True`` +* ``T(value, length=None, digits=2)`` - Tax: calls ``N`` with ``digits=2`` and ``sign=False`` +* ``D(value, length=8, dtformat="%Y%m%d")`` - Date: formats dates with + customizable format, defaults to yyyymmdd +* ``H(value, length=4, dtformat="%H%M")`` - Time: formats times with + customizable format, defaults to hhmm +* ``DT(value, length=12, dtformat="%Y%m%d%H%M")`` - Datetime: formats + datetime with customizable format, defaults to yyyyMMddhhmm + +Post-processing Behavior +------------------------ + +The module automatically performs the following post-processing on text +content: + +1. Strips whitespace from each line +2. Removes all line breaks from QWeb template structure +3. Removes HTML elements (SPAN, DIV) completely +4. Replaces special elements with their defined characters +5. Returns single-line output with controlled formatting + +This ensures clean, consistent text output regardless of QWeb template +formatting or HTML elements. diff --git a/report_qweb_text_control/readme/CONTRIBUTORS.rst b/report_qweb_text_control/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..b58d363e17 --- /dev/null +++ b/report_qweb_text_control/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +This module was developed with AI assistance under close guidance and supervision +from experienced Odoo developers. The development utilized Cascade (SWE-1.5) for +code implementation and testing, with Claude AI providing architectural guidance +and code quality review. All AI-generated code was thoroughly reviewed, validated, +and approved by human supervisors. diff --git a/report_qweb_text_control/readme/DESCRIPTION.rst b/report_qweb_text_control/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..ddbf63bbdc --- /dev/null +++ b/report_qweb_text_control/readme/DESCRIPTION.rst @@ -0,0 +1,21 @@ +Allow text reports to generate positional text files by providing +precise control over whitespace, special characters, and field formatting. + +Use Case +-------- + +Generate fixed-width text files for legacy systems, bank transfers, or any interface +requiring exact positional formatting with specific characters and spacing. + +Example +------- + +Template:: + + + + + +Output:: + + John Doe 00000012340000015000+ diff --git a/report_qweb_text_control/readme/USAGE.rst b/report_qweb_text_control/readme/USAGE.rst new file mode 100644 index 0000000000..0cf49be831 --- /dev/null +++ b/report_qweb_text_control/readme/USAGE.rst @@ -0,0 +1,98 @@ +To create a text-controlled QWeb report, define a report with +``report_type="qweb-text"`` and enable the "Enable Text Control" option: + +.. code-block:: xml + + + Custom Text Report + your.model + qweb-text + + your_module.report_custom_text + + report + + +Special Elements in Templates +------------------------------ + +In your QWeb template, use special elements for controlled output. HTML elements +like SPAN and DIV are automatically removed: + +.. code-block:: xml + + + +Field Formatting +----------------- + +The module provides field formatting functions available in templates: + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Customization +------------- + +Override the ``_get_replacement_elements()`` method to define your own special +elements: + +.. code-block:: python + + from odoo import models + + class CustomTextReport(models.Model): + _inherit = "ir.actions.report" + + def _get_replacement_elements(self): + """Override to customize replacement elements""" + elements = super()._get_replacement_elements() + elements.update({ + "CUSTOM": "CustomValue", + "NEWLINE": "\r\n", + }) + return elements diff --git a/report_qweb_text_control/report/__init__.py b/report_qweb_text_control/report/__init__.py new file mode 100644 index 0000000000..1bcfad7bf4 --- /dev/null +++ b/report_qweb_text_control/report/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Open Source Integrators +from . import field_formatter +from . import report_qweb_text_control diff --git a/report_qweb_text_control/report/field_formatter.py b/report_qweb_text_control/report/field_formatter.py new file mode 100644 index 0000000000..6086a71286 --- /dev/null +++ b/report_qweb_text_control/report/field_formatter.py @@ -0,0 +1,107 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields + + +def A(value, length=None): + """ + Format as alphanumeric (A): left-aligned. + + Args: + value: Value to format + length: Fixed length for padding (optional) + + Returns: + str: Formatted string + """ + text = str(value or "") + if length: + text = text[:length] + text = text.ljust(length) + return text + + +def N(value, length=None, digits=None, sign=False): + """ + Format as numeric (N): right-aligned with left zero padding. + + Args: + value: Value to format + length: Fixed length for padding (optional) + digits: Number of decimal digits (optional) + sign: Whether to include sign (+/-) at the end (optional) + + Returns: + str: Formatted string + """ + if value is None: + value = 0 + + # Handle decimal digits + if digits is not None: + # Multiply by 10^digits and round to get integer representation + multiplier = 10**digits + integer_value = int(round(abs(value) * multiplier)) + text = str(integer_value) + else: + text = str(abs(int(value))) + + # Apply padding (accounting for sign if requested) + if length: + padding_length = length - 1 if sign else length + text = text.zfill(padding_length)[-padding_length:] + + # Add sign if requested (after padding) + if sign: + sign_char = "+" if value >= 0 else "-" + text += sign_char + + return text + + +def M(value, length=None): + """ + Format as monetary (M): calls N with default digits=2 and sign=True. + + Args: + value: Value to format + length: Fixed length for padding (optional) + + Returns: + str: Formatted string + """ + return N(value, length, digits=2, sign=True) + + +def T(value, length=5, digits=2): + # Tax: number with two decimals and no sign + return N(value, length, digits=digits, sign=False) + + +def DT(value, length=12, dtformat="%Y%m%d%H%M"): + """ + Format as datetime (DT): formats datetime values with customizable format. + + Args: + value: Datetime value to format + length: Fixed length for padding (default: 12 for yyyyMMddhhmm) + dtformat: Datetime format string (default: "%Y%m%d%H%M") + + Returns: + str: Formatted datetime string + """ + if not value: + value = fields.Datetime.now() + text = value.strftime(dtformat) if value else "" + return A(text, length) + + +def D(value, length=8, dtformat="%Y%m%d"): + # Format as date yyymmdd + return DT(value, length=length, dtformat=dtformat) + + +def H(value, length=4, dtformat="%H%M"): + # Format as hour 24h format HHMM + return DT(value, length=length, dtformat=dtformat) diff --git a/report_qweb_text_control/report/report_qweb_text_control.py b/report_qweb_text_control/report/report_qweb_text_control.py new file mode 100644 index 0000000000..afffc6a69c --- /dev/null +++ b/report_qweb_text_control/report/report_qweb_text_control.py @@ -0,0 +1,83 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import re + +from odoo import fields, models + +from . import field_formatter + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + text_control_enabled = fields.Boolean( + string="Enable Text Control", + help="Enable whitespace control and field formatting for text reports", + ) + + def _get_rendering_context(self, docids, data): + # Add field formatters to rendering context only when text control is enabled + if self.text_control_enabled: + context = super()._get_rendering_context(docids, data) + context.update( + { + "A": field_formatter.A, + "N": field_formatter.N, + "M": field_formatter.M, + "T": field_formatter.T, + "D": field_formatter.D, + "DT": field_formatter.DT, + "H": field_formatter.H, + } + ) + return context + return super()._get_rendering_context(docids, data) + + def _render_qweb_text(self, docids, data=None): + # Extend standard text rendering with post-processing only when text control is enabled + if self.text_control_enabled: + content, format_type = super()._render_qweb_text(docids, data) + processed_content = self._postprocess_text(content) + return processed_content, format_type + return super()._render_qweb_text(docids, data) + + def _postprocess_text(self, content): + """Post-process text content to control whitespace and replace elements.""" + # Ensure content is a string (decode if bytes) + if isinstance(content, bytes): + content = content.decode("utf-8") + + # Strip spaces from each line first + lines = content.split("\n") + stripped_lines = [line.strip() for line in lines] + content = "\n".join(stripped_lines) + + # Remove all unwanted line breaks (both \r and \n) first + content = re.sub(r"[\r\n]+", "", content) + + # Then replace special elements with their corresponding characters + replacement_elements = self._get_replacement_elements() + for element, replacement in replacement_elements.items(): + # Replace opening tags with the replacement character + pattern = ( + f"<{element}>|<{element}/>|<{element.lower()}>|<{element.lower()}/>" + ) + content = re.sub(pattern, replacement, content) + # Remove closing tags completely + pattern_close = f"|" + content = re.sub(pattern_close, "", content) + + return content + + def _get_replacement_elements(self): + """Return dictionary of special elements and their replacements.""" + return { + "CR": "\r", + "LF": "\n", + "CRLF": "\r\n", + "TAB": "\t", + "SEMICOLON": ";", + "SPAN": "", + "DIV": "", + } diff --git a/report_qweb_text_control/static/description/index.html b/report_qweb_text_control/static/description/index.html new file mode 100644 index 0000000000..f79c70dc49 --- /dev/null +++ b/report_qweb_text_control/static/description/index.html @@ -0,0 +1,579 @@ + + + + + +QWeb Text Control + + + +
+

QWeb Text Control

+ + +

Beta License: LGPL-3 OCA/reporting-engine Translate me on Weblate Try me on Runboat

+

Allow text reports to generate positional text files by providing +precise control over whitespace, special characters, and field formatting.

+
+

Use Case

+

Generate fixed-width text files for legacy systems, bank transfers, or any interface +requiring exact positional formatting with specific characters and spacing.

+
+
+

Example

+

Template:

+
+<span t-esc="A(partner.name, 20)"/>
+<span t-esc="N(partner.id, 10)"/>
+<span t-esc="M(partner.credit_limit, 12)"/>
+
+

Output:

+
+John Doe           00000012340000015000+
+
+

Table of contents

+ + +
+
+

Default Replacement Elements

+

The module provides these default replacement elements:

+
    +
  • <CR> → Carriage return (\r)
  • +
  • <LF> → Line feed (\n)
  • +
  • <CRLF> → Windows line ending (\r\n)
  • +
  • <TAB> → Tab character (\t)
  • +
  • <SEMICOLON> → Semicolon (;)
  • +
  • <SPAN> → Removed (empty string)
  • +
  • <DIV> → Removed (empty string)
  • +
+
+
+

Field Formatting Functions

+

The module includes these field formatting functions:

+
    +
  • A(value, length=None) - Alphanumeric: left-aligned, space-padded
  • +
  • N(value, length=None, digits=None, sign=False) - Numeric: right-aligned, +zero-padded, optional sign at the end
  • +
  • M(value, length=None) - Monetary: calls N with digits=2 and sign=True
  • +
  • T(value, length=None, digits=2) - Tax: calls N with digits=2 and sign=False
  • +
  • D(value, length=8, dtformat="%Y%m%d") - Date: formats dates with +customizable format, defaults to yyyymmdd
  • +
  • H(value, length=4, dtformat="%H%M") - Time: formats times with +customizable format, defaults to hhmm
  • +
  • DT(value, length=12, dtformat="%Y%m%d%H%M") - Datetime: formats +datetime with customizable format, defaults to yyyyMMddhhmm
  • +
+
+
+

Post-processing Behavior

+

The module automatically performs the following post-processing on text +content:

+
    +
  1. Strips whitespace from each line
  2. +
  3. Removes all line breaks from QWeb template structure
  4. +
  5. Removes HTML elements (SPAN, DIV) completely
  6. +
  7. Replaces special elements with their defined characters
  8. +
  9. Returns single-line output with controlled formatting
  10. +
+

This ensures clean, consistent text output regardless of QWeb template +formatting or HTML elements.

+
+

Usage

+

To create a text-controlled QWeb report, define a report with +report_type="qweb-text" and enable the “Enable Text Control” option:

+
+<record id="action_report_custom_text" model="ir.actions.report">
+    <field name="name">Custom Text Report</field>
+    <field name="model">your.model</field>
+    <field name="report_type">qweb-text</field>
+    <field name="text_control_enabled" eval="True"/>
+    <field name="report_name">your_module.report_custom_text</field>
+    <field name="binding_model_id" ref="model_your_model"/>
+    <field name="binding_type">report</field>
+</record>
+
+
+
+
+

Special Elements in Templates

+

In your QWeb template, use special elements for controlled output. HTML elements +like SPAN and DIV are automatically removed:

+
+<template id="report_custom_text">
+    <t t-foreach="docs" t-as="doc">
+        <span t-esc="doc.name"/>,<span t-esc="doc.email"/><LF/>
+        <span t-esc="doc.phone"/>,<span t-esc="doc.address"/><CRLF/>
+        <span t-esc="doc.field1"/><TAB/><span t-esc="doc.field2"/><SEMICOLON/><span t-esc="doc.field3"/>
+    </t>
+</template>
+
+
+
+

Field Formatting

+

The module provides field formatting functions available in templates:

+
+<!-- Alphanumeric (A): left-aligned, space-padded -->
+<span t-esc="A(doc.name, 10)"/>
+
+<!-- Numeric (N): right-aligned, zero-padded -->
+<span t-esc="N(doc.amount, 8)"/>
+
+<!-- Numeric with decimals and sign (sign at the end) -->
+<span t-esc="N(doc.amount, 12, digits=2, sign=True)"/>
+
+<!-- Monetary (M): calls N with digits=2 and sign=True -->
+<span t-esc="M(doc.total, 12)"/>
+
+<!-- Date (D): formats dates, defaults to yyyymmdd -->
+<span t-esc="D(doc.date)"/>
+
+<!-- Date with custom format -->
+<span t-esc="D(doc.date, dtformat='%Y-%m-%d')"/>
+
+<!-- Date with length padding -->
+<span t-esc="D(doc.date, 12, dtformat='%Y%m%d')"/>
+
+<!-- Time (T): formats times, defaults to hhmm -->
+<span t-esc="T(doc.time)"/>
+
+<!-- Time with custom format -->
+<span t-esc="T(doc.time, dtformat='%H:%M')"/>
+
+<!-- Time with length padding -->
+<span t-esc="T(doc.time, 6, dtformat='%H:%M:%S')"/>
+
+<!-- Datetime (DT): formats datetime, defaults to yyyyMMddhhmm -->
+<span t-esc="DT(doc.datetime)"/>
+
+<!-- Datetime with custom format -->
+<span t-esc="DT(doc.datetime, dtformat='%Y-%m-%d %H:%M')"/>
+
+<!-- Datetime with length padding -->
+<span t-esc="DT(doc.datetime, 20, dtformat='%Y-%m-%d %H:%M:%S')"/>
+
+
+
+

Customization

+

Override the _get_replacement_elements() method to define your own special +elements:

+
+from odoo import models
+
+class CustomTextReport(models.Model):
+    _inherit = "ir.actions.report"
+
+    def _get_replacement_elements(self):
+        """Override to customize replacement elements"""
+        elements = super()._get_replacement_elements()
+        elements.update({
+            "CUSTOM": "CustomValue",
+            "NEWLINE": "\r\n",
+        })
+        return elements
+
+
+

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

+
    +
  • Open Source Integrators
  • +
+
+
+

Contributors

+

This module was developed with AI assistance under close guidance and supervision +from experienced Odoo developers. The development utilized Cascade (SWE-1.5) for +code implementation and testing, with Claude AI providing architectural guidance +and code quality review. All AI-generated code was thoroughly reviewed, validated, +and approved by human supervisors.

+
+
+

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.

+

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_qweb_text_control/tests/__init__.py b/report_qweb_text_control/tests/__init__.py new file mode 100644 index 0000000000..8dd98744cd --- /dev/null +++ b/report_qweb_text_control/tests/__init__.py @@ -0,0 +1,4 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_field_formatter +from . import test_report_integration diff --git a/report_qweb_text_control/tests/test_field_formatter.py b/report_qweb_text_control/tests/test_field_formatter.py new file mode 100644 index 0000000000..4f5199734a --- /dev/null +++ b/report_qweb_text_control/tests/test_field_formatter.py @@ -0,0 +1,156 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo.tests import TransactionCase, tagged + +from ..report import field_formatter + + +@tagged("-at_install", "post_install") +class TestFieldFormatter(TransactionCase): + """Test field formatting functions.""" + + def test_alphanumeric_formatter(self): + """Test alphanumeric field formatting.""" + self.assertEqual(field_formatter.A(None, 5), " ") + self.assertEqual(field_formatter.A(123, 5), "123 ") + self.assertEqual(field_formatter.A("Test", 10), "Test ") + self.assertEqual(field_formatter.A("LongerText", 5), "Longe") + + def test_numeric_formatter(self): + """Test numeric field formatting.""" + # Test basic formatting + self.assertEqual(field_formatter.N(None), "0") + self.assertEqual(field_formatter.N(123), "123") + self.assertEqual(field_formatter.N(123, 8), "00000123") + self.assertEqual(field_formatter.N(123.45, 8), "00000123") + + # Test with digits parameter + self.assertEqual(field_formatter.N(123.456, 8, digits=2), "00012346") + self.assertEqual(field_formatter.N(123.456, 8, digits=3), "00123456") + + # Test with sign parameter + self.assertEqual(field_formatter.N(123, 8, sign=True), "0000123+") + self.assertEqual(field_formatter.N(-123, 8, sign=True), "0000123-") + self.assertEqual(field_formatter.N(-123, 8, sign=False), "00000123") + + # Test edge cases + self.assertEqual(field_formatter.N(-123, 8, sign=True), "0000123-") + self.assertEqual(field_formatter.N(0, 5), "00000") + + def test_monetary_formatter(self): + """Test monetary field formatting.""" + # Test basic formatting + self.assertEqual(field_formatter.M(100.50, 10), "000010050+") + self.assertEqual(field_formatter.M(-100.50, 10), "000010050-") + self.assertEqual(field_formatter.M(0, 8), "0000000+") + + # Test with None length + self.assertEqual(field_formatter.M(100.50), "10050+") + + # Test rounding + self.assertEqual(field_formatter.M(100.456, 10), "000010046+") + self.assertEqual(field_formatter.M(100.454, 10), "000010045+") + + def test_date_formatter(self): + """Test date field formatting.""" + from datetime import date, datetime + + # Test with datetime object + dt = datetime(2025, 1, 15, 10, 30, 45) + self.assertEqual(field_formatter.D(dt), "20250115") + + # Test with date object + d = date(2025, 1, 15) + self.assertEqual(field_formatter.D(d), "20250115") + + # Test with custom format + self.assertEqual(field_formatter.D(dt, 10, dtformat="%Y-%m-%d"), "2025-01-15") + self.assertEqual(field_formatter.D(dt, 10, dtformat="%d/%m/%Y"), "15/01/2025") + + # Test with None - should use current datetime and format as date + from odoo import fields + + current_dt = fields.Datetime.now() + expected_current_date = current_dt.strftime("%Y%m%d") + self.assertEqual(field_formatter.D(None, None), expected_current_date) + + # Test with length + self.assertEqual(field_formatter.D(dt, 12), "20250115 ") + self.assertEqual(field_formatter.D(dt, 12, dtformat="%Y-%m-%d"), "2025-01-15 ") + + def test_H_formatter(self): + """Test H field formatting (time).""" + from datetime import datetime, time + + # Test with datetime object + dt = datetime(2025, 1, 15, 14, 30, 45) + self.assertEqual(field_formatter.H(dt), "1430") + + # Test with time object + t = time(14, 30, 45) + self.assertEqual(field_formatter.H(t), "1430") + + # Test with custom format + self.assertEqual(field_formatter.H(dt, 5, dtformat="%H:%M"), "14:30") + self.assertEqual(field_formatter.H(dt, None, dtformat="%I:%M %p"), "02:30 PM") + + # Test with None - should use current datetime and format as time + from odoo import fields + + current_dt = fields.Datetime.now() + expected_current_time = current_dt.strftime("%H%M") + self.assertEqual(field_formatter.H(None), expected_current_time) + + # Test with length + self.assertEqual(field_formatter.H(dt, 6), "1430 ") + self.assertEqual(field_formatter.H(dt, 8, dtformat="%H:%M:%S"), "14:30:45") + + def test_T_formatter(self): + """Test T field formatting (tax).""" + # Test basic tax formatting without sign (default length=5, digits=2) + self.assertEqual(field_formatter.T(123.45), "12345") + self.assertEqual(field_formatter.T(0), "00000") + self.assertEqual(field_formatter.T(-123.45), "12345") # No sign + + # Test with explicit length + self.assertEqual(field_formatter.T(123.45, 6), "012345") + + # Test with custom digits + self.assertEqual(field_formatter.T(23.456, digits=3), "23456") + self.assertEqual(field_formatter.T(123.45, digits=0), "00123") + + # Test with None + self.assertEqual(field_formatter.T(None), "00000") + + def test_datetime_formatter(self): + """Test datetime field formatting.""" + from datetime import datetime + + # Test with datetime object + dt = datetime(2025, 1, 15, 14, 30, 45) + self.assertEqual(field_formatter.DT(dt), "202501151430") + + # Test with custom format + self.assertEqual( + field_formatter.DT(dt, None, dtformat="%Y-%m-%d %H:%M"), "2025-01-15 14:30" + ) + self.assertEqual( + field_formatter.DT(dt, None, dtformat="%Y%m%d_%H%M%S"), "20250115_143045" + ) + + # Test with None - should use current datetime + from odoo import fields + + current_dt = fields.Datetime.now() + expected_current = current_dt.strftime("%Y%m%d%H%M") + self.assertEqual(field_formatter.DT(None), expected_current) + self.assertEqual(field_formatter.DT(None, None), expected_current) + + # Test with length + self.assertEqual(field_formatter.DT(dt, 16), "202501151430 ") + self.assertEqual( + field_formatter.DT(dt, 19, dtformat="%Y-%m-%d %H:%M:%S"), + "2025-01-15 14:30:45", + ) diff --git a/report_qweb_text_control/tests/test_report_integration.py b/report_qweb_text_control/tests/test_report_integration.py new file mode 100644 index 0000000000..08d96a2a6d --- /dev/null +++ b/report_qweb_text_control/tests/test_report_integration.py @@ -0,0 +1,170 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from datetime import datetime + +from odoo.tests import TransactionCase, tagged +from odoo.tools.convert import xml_import + + +@tagged("-at_install", "post_install") +class TestReportIntegration(TransactionCase): + """Test report integration with demo report.""" + + def setUp(self): + super().setUp() + # Load demo report data using Odoo's XML import + self._load_demo_report() + + # Create test partner + self.test_partner = self.env["res.partner"].create( + { + "name": "Test Partner", + "email": "test@example.com", + "phone": "+1234567890", + "company_type": "company", + "credit_limit": 1500.50, + "country_id": self.env.ref("base.us").id, + } + ) + + def _load_demo_report(self): + """Load demo report data using Odoo's XML import utility.""" + # Use Odoo's xml_import to load the demo data + xml_import( + self.env.cr, + "report_qweb_text_control", + "report_qweb_text_control/demo/partner_report.xml", + mode="init", + noupdate=True, + ) + + # Get the demo report that was created + self.demo_report = self.env.ref( + "report_qweb_text_control.action_report_partner_text" + ) + + def test_field_formatters_in_context(self): + """Test that field formatters are available in render context.""" + report = self.demo_report + context = report._get_rendering_context([self.test_partner.id], {}) + + # Test formatter functions + self.assertEqual(context["A"]("Test", 10), "Test ") + self.assertEqual(context["N"](123, 8), "00000123") + self.assertEqual(context["M"](100.50, 10), "000010050+") + test_date = datetime(2025, 1, 15) + self.assertEqual(context["D"](test_date), "20250115") + test_datetime = datetime(2025, 1, 15, 14, 30, 45) + self.assertEqual(context["H"](test_datetime), "1430") + self.assertEqual( + context["T"](123.45), "12345" + ) # Tax formatter with default length=5 + self.assertEqual(context["DT"](test_datetime), "202501151430") + + def test_report_rendering(self): + """Test that the report renders correctly with post-processing.""" + report = self.demo_report + + # Render the report + content, format_type = report._render_qweb_text([self.test_partner.id]) + + # Check format type + self.assertEqual(format_type, "text") + + # Check that content is a string + self.assertIsInstance(content, str) + + # Check that special elements are replaced + self.assertIn("\r\n", content) # CRLF elements + + # Check that field formatting works + self.assertIn("Test Partner", content) + self.assertIn("test@example.com", content) + self.assertIn("+1234567890", content) + self.assertIn("United States", content) + self.assertIn("0010000+", content) # Company monetary format + self.assertIn("0000150050+", content) # Credit limit with sign + + def test_postprocess_text_replacement_elements(self): + """Test text post-processing with replacement elements.""" + report = self.demo_report + + # Test content with various special elements + test_content = "Field1Field2Field3Field4" + processed = report._postprocess_text(test_content) + + expected = "Field1\tField2\r\nField3;Field4" + self.assertEqual(processed, expected) + + def test_postprocess_text_removes_line_breaks(self): + """Test that unwanted line breaks are removed.""" + report = self.demo_report + + # Test content with unwanted line breaks + test_content = "Line1\nLine2\r\nLine3\rLine4\nLine5" + processed = report._postprocess_text(test_content) + + # Unwanted line breaks should be removed, special elements preserved + self.assertNotIn("\n", processed) + self.assertNotIn("\r", processed) + + def test_postprocess_text_handles_bytes(self): + """Test that bytes input is handled correctly.""" + report = self.demo_report + + # Test bytes input + test_content = b"Field1Field2Field3" + processed = report._postprocess_text(test_content) + + expected = "Field1\tField2\r\nField3" + self.assertEqual(processed, expected) + + def test_replacement_elements_configuration(self): + """Test that replacement elements can be customized.""" + report = self.demo_report + + # Get default replacement elements + elements = report._get_replacement_elements() + + # Check default elements + self.assertEqual(elements["CR"], "\r") + self.assertEqual(elements["LF"], "\n") + self.assertEqual(elements["CRLF"], "\r\n") + self.assertEqual(elements["TAB"], "\t") + self.assertEqual(elements["SEMICOLON"], ";") + + def test_case_insensitive_elements(self): + """Test that elements work case-insensitively.""" + report = self.demo_report + + # Test mixed case elements + test_content = "Field1field2Field3field4" + processed = report._postprocess_text(test_content) + + expected = "Field1\tfield2\tField3\rfield4\r" + self.assertEqual(processed, expected) + + def test_regular_text_report_no_formatters(self): + """ + Test that regular text reports don't have field formatters + when text control is disabled. + """ + # Create a regular text report with text control disabled + regular_report = self.env["ir.actions.report"].create( + { + "name": "Regular Text Report", + "model": "res.partner", + "report_type": "qweb-text", + "report_name": "test_regular_text_report", + "text_control_enabled": False, + } + ) + + # Get rendering context - should not have field formatters + context = regular_report._get_rendering_context([self.test_partner.id], {}) + + # Field formatters should not be available + self.assertNotIn("A", context) + self.assertNotIn("N", context) + self.assertNotIn("DT", context) diff --git a/report_qweb_text_control/tests/test_report_qweb_text_control.py b/report_qweb_text_control/tests/test_report_qweb_text_control.py new file mode 100644 index 0000000000..b21ba67f8b --- /dev/null +++ b/report_qweb_text_control/tests/test_report_qweb_text_control.py @@ -0,0 +1,139 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests import TransactionCase, tagged +from odoo.tools.convert import xml_import + + +@tagged("-at_install", "post_install") +class TestQWebTextControl(TransactionCase): + """Test QWeb Text Control functionality.""" + + def setUp(self): + super().setUp() + # Load demo report data using Odoo's XML import + self._load_demo_report() + + # Create test partner + self.test_partner = self.env["res.partner"].create( + { + "name": "Test Partner", + "email": "test@example.com", + "phone": "+1234567890", + "company_type": "company", + "credit_limit": 1500.50, + "country_id": self.env.ref("base.us").id, + } + ) + + def _load_demo_report(self): + """Load demo report data using Odoo's XML import utility.""" + # Use Odoo's xml_import to load the demo data + xml_import( + self.env.cr, + "report_qweb_text_control", + "report_qweb_text_control/demo/partner_report.xml", + mode="init", + noupdate=True, + ) + + # Get the demo report that was created + self.demo_report = self.env.ref( + "report_qweb_text_control.action_report_partner_text" + ) + + def test_field_formatters_in_context(self): + """Test that field formatters are available in render context.""" + report = self.demo_report + context = report._get_rendering_context([self.test_partner.id], {}) + # Test formatter functions + self.assertEqual(context["A"]("Test", 10), "Test ") + self.assertEqual(context["N"](123, 8), "00000123") + self.assertEqual(context["M"](100.50, 10), "0000010050+") + self.assertEqual(context["T"](0.15, 8), "00000150") + + def test_report_rendering(self): + """Test that the report renders correctly with post-processing.""" + report = self.demo_report + + # Render the report + content, format_type = report._render_qweb_text([self.test_partner.id]) + + # Check format type + self.assertEqual(format_type, "text") + + # Check that content is a string + self.assertIsInstance(content, str) + + # Check that special elements are replaced + self.assertIn("|", content) # PIPE elements + self.assertIn("\r\n", content) # CRLF elements + + # Check that field formatting works + self.assertIn("Test Partner", content) + self.assertIn("test@example.com", content) + self.assertIn("+1234567890", content) + self.assertIn("United States", content) + self.assertIn("00000100+", content) # Company monetary format + self.assertIn("0000150050+", content) # Credit limit with sign + + def test_postprocess_text_replacement_elements(self): + """Test text post-processing with replacement elements.""" + report = self.demo_report + + # Test content with various special elements + test_content = "Field1Field2Field3Field4Field5" + processed = report._postprocess_text(test_content) + + expected = "Field1|Field2\tField3Field4\r\nField5" + self.assertEqual(processed, expected) + + def test_postprocess_text_removes_line_breaks(self): + """Test that unwanted line breaks are removed.""" + report = self.demo_report + + # Test content with unwanted line breaks + test_content = "Line1\nLine2\r\nLine3\rLine4Line5" + processed = report._postprocess_text(test_content) + + # Unwanted line breaks should be removed, special elements preserved + self.assertNotIn("\n", processed) + self.assertNotIn("\r", processed) + self.assertIn("|", processed) + + def test_postprocess_text_handles_bytes(self): + """Test that bytes input is handled correctly.""" + report = self.demo_report + + # Test bytes input + test_content = b"Field1Field2Field3" + processed = report._postprocess_text(test_content) + + expected = "Field1|Field2\r\nField3" + self.assertEqual(processed, expected) + + def test_replacement_elements_configuration(self): + """Test that replacement elements can be customized.""" + report = self.demo_report + + # Get default replacement elements + elements = report._get_replacement_elements() + + # Check default elements + self.assertEqual(elements["CR"], "\r") + self.assertEqual(elements["LF"], "\n") + self.assertEqual(elements["CRLF"], "\r\n") + self.assertEqual(elements["TAB"], "\t") + self.assertEqual(elements["PIPE"], "|") + self.assertEqual(elements["SEMICOLON"], ";") + + def test_case_insensitive_elements(self): + """Test that elements work case-insensitively.""" + report = self.demo_report + + # Test mixed case elements + test_content = "Field1field2Field3field4" + processed = report._postprocess_text(test_content) + + expected = "Field1|field2|Field3field4" + self.assertEqual(processed, expected) diff --git a/report_qweb_text_control/views/ir_actions_report_views.xml b/report_qweb_text_control/views/ir_actions_report_views.xml new file mode 100644 index 0000000000..527581f9f2 --- /dev/null +++ b/report_qweb_text_control/views/ir_actions_report_views.xml @@ -0,0 +1,26 @@ + + + + ir.actions.report.form.inherit + ir.actions.report + + + + + + +
+ Enable whitespace control and field formatting functions (A, N, M, D, T, DT) for text reports. +
+
+
+ +
+
+
diff --git a/setup/report_qweb_text_control/odoo/addons/report_qweb_text_control b/setup/report_qweb_text_control/odoo/addons/report_qweb_text_control new file mode 120000 index 0000000000..105c93d1e8 --- /dev/null +++ b/setup/report_qweb_text_control/odoo/addons/report_qweb_text_control @@ -0,0 +1 @@ +../../../../report_qweb_text_control \ No newline at end of file diff --git a/setup/report_qweb_text_control/setup.py b/setup/report_qweb_text_control/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/report_qweb_text_control/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)