diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a06488079f..256706ebdf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,8 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + - name: Install Ghostscript + run: apt-get update && apt-get install -y ghostscript - name: Install addons and dependencies run: oca_install_addons - name: Check licenses diff --git a/attachment_pdfa/README.rst b/attachment_pdfa/README.rst new file mode 100644 index 0000000000..d62992ce02 --- /dev/null +++ b/attachment_pdfa/README.rst @@ -0,0 +1,141 @@ +================ +Attachment PDF/A +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:607a7dc56a5708440b64968c78229b78bc02b715336989ba87460c94bd98e2fb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fstorage-lightgray.png?logo=github + :target: https://github.com/OCA/storage/tree/18.0/attachment_pdfa + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-attachment_pdfa + :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/storage&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends Odoo's attachment system to support automatic +conversion of PDF files to the **PDF/A-3** format. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +In many legal frameworks and corporate archiving policies, business +documents must be stored in a compliant, long-term preservation format +like **PDF/A-3**. + +This module provides a flexible framework to automate PDF to PDF/A-3 +conversion upon attachment creation: + +- **Opt-in via Mixin**: Rather than blindly converting all system PDFs, + models must opt-in by inheriting ``attachment.pdfa.mixin`` and + defining conditional rules. +- **Pluggable Engine**: Offers conversion via **Ghostscript** or + **Odoo**. +- **Chatter Integration**: Non-blocking conversion pipeline — if the + conversion generates warnings or errors during conversion, details are + posted directly as internal notes in the record's Chatter. + +Installation +============ + +To install and use Ghostscript conversion (default engine): + +1. Ensure Ghostscript is installed on your Odoo server host system. +2. Ensure the ``gs`` binary is available in the system execution PATH of + the user running Odoo. + +Configuration +============= + +To configure this module: + +1. Go to **Settings** > **General Settings**. + +2. Scroll down to the **PDF/A Conversion** section under *Document + Settings*. + +3. Select your preferred conversion method: + + - **Ghostscript** (default). + - **Odoo**. + - **Disable**: Disables automatic conversion globally. + +4. Click **Save**. + +Usage +===== + +To use this module: + +1. Create or generate a PDF attachment on a record whose model + implements ``attachment.pdfa.mixin`` (and where + ``_attachment_must_be_pdfa()`` evaluates to ``True``). +2. The created PDF is automatically converted to PDF/A-3. +3. If the conversion encounters any non-fatal warnings or fails to + convert the file, a log note is automatically posted to the record's + **Chatter**, allowing administrators and users to review the + conversion output. + +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 +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Zina Rasoamanana zina.rasoamanana@acsone.eu + (`Acsone `__) +- Benoit Aimont benoit.aimont@acsone.eu (`Acsone `__) +- Stéphane Bidoul stephane.bidoul@acsone.eu + (`Acsone `__) + +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/storage `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/attachment_pdfa/__init__.py b/attachment_pdfa/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/attachment_pdfa/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/attachment_pdfa/__manifest__.py b/attachment_pdfa/__manifest__.py new file mode 100644 index 0000000000..ec89eb5f06 --- /dev/null +++ b/attachment_pdfa/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "Attachment PDF/A", + "summary": """Convert PDF attachments to PDF/A""", + "version": "18.0.1.0.0", + "category": "Storage", + "license": "LGPL-3", + "author": "ACSONE SA/NV, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/storage", + "depends": [ + "mail", + ], + "data": [ + "data/config_parameter.xml", + "data/pdfa3_metadata.xml", + "views/res_config_settings.xml", + ], + "external_dependencies": { + "bin": [ + "gs", + ], + }, + "installable": True, +} diff --git a/attachment_pdfa/data/config_parameter.xml b/attachment_pdfa/data/config_parameter.xml new file mode 100644 index 0000000000..05e33945ce --- /dev/null +++ b/attachment_pdfa/data/config_parameter.xml @@ -0,0 +1,9 @@ + + + + + attachment_pdfa.method + ghostscript + + diff --git a/attachment_pdfa/data/iccprofiles_default_rgb.icc b/attachment_pdfa/data/iccprofiles_default_rgb.icc new file mode 100644 index 0000000000..055997234c Binary files /dev/null and b/attachment_pdfa/data/iccprofiles_default_rgb.icc differ diff --git a/attachment_pdfa/data/pdfa3_metadata.xml b/attachment_pdfa/data/pdfa3_metadata.xml new file mode 100644 index 0000000000..98c4f46e6c --- /dev/null +++ b/attachment_pdfa/data/pdfa3_metadata.xml @@ -0,0 +1,46 @@ + + + + diff --git a/attachment_pdfa/data/pdfa_def.ps b/attachment_pdfa/data/pdfa_def.ps new file mode 100644 index 0000000000..612d091612 Binary files /dev/null and b/attachment_pdfa/data/pdfa_def.ps differ diff --git a/attachment_pdfa/models/__init__.py b/attachment_pdfa/models/__init__.py new file mode 100644 index 0000000000..a68e01fa2b --- /dev/null +++ b/attachment_pdfa/models/__init__.py @@ -0,0 +1,3 @@ +from . import attachment_pdfa_mixin +from . import res_config_settings +from . import ir_attachment diff --git a/attachment_pdfa/models/attachment_pdfa_mixin.py b/attachment_pdfa/models/attachment_pdfa_mixin.py new file mode 100644 index 0000000000..56f6cf5c97 --- /dev/null +++ b/attachment_pdfa/models/attachment_pdfa_mixin.py @@ -0,0 +1,12 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class AttachmentPdfaMixin(models.AbstractModel): + _name = "attachment.pdfa.mixin" + _description = "Attachment PDF/A Mixin" + + def _attachment_must_be_pdfa(self): + return False diff --git a/attachment_pdfa/models/ir_attachment.py b/attachment_pdfa/models/ir_attachment.py new file mode 100644 index 0000000000..d4a5644775 --- /dev/null +++ b/attachment_pdfa/models/ir_attachment.py @@ -0,0 +1,264 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +import io +import logging +import subprocess +import tempfile +from pathlib import Path + +from markupsafe import Markup + +from odoo import api, fields, models +from odoo.tools import html_escape +from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter + +_logger = logging.getLogger(__name__) + +TIMEOUT_CONVERSION = 60 + +DATA_DIR = Path(__file__).parent.parent / "data" +ICC_PROFILE_PS = DATA_DIR / "pdfa_def.ps" +RGB_PROFILE = DATA_DIR / "iccprofiles_default_rgb.icc" + +_MISSING_PROFILE_MSG = "Ghostscript ICC Profile or PDF/A definition file not found." + +if not ICC_PROFILE_PS.is_file() or not RGB_PROFILE.is_file(): + _logger.error(_MISSING_PROFILE_MSG) + _PROFILES_AVAILABLE = False +else: + _PROFILES_AVAILABLE = True + + +class IrAttachment(models.Model): + _inherit = "ir.attachment" + + @api.model + def _gs_convert_pdf_to_pdfa(self, raw_content): + """Convert PDF raw bytes using Ghostscript.""" + if not _PROFILES_AVAILABLE: + return raw_content, False, _MISSING_PROFILE_MSG + try: + with ( + tempfile.NamedTemporaryFile(suffix=".pdf") as temp_in, + tempfile.NamedTemporaryFile(suffix=".pdf") as temp_out, + ): + temp_in.write(raw_content) + temp_in.flush() + args = [ + "gs", + f"--permit-file-read={RGB_PROFILE}", + "-dPDFACompatibilityPolicy=1", + "-sDEVICE=pdfwrite", + "-dPDFA=3", + "-sColorConversionStrategy=RGB", + "-o", + temp_out.name, + "-c", + f"/ICCProfile ({RGB_PROFILE}) def", + "-f", + str(ICC_PROFILE_PS), + str(temp_in.name), + ] + result = subprocess.run( + args, + capture_output=True, + text=True, + errors="replace", + timeout=TIMEOUT_CONVERSION, + check=False, + ) + log_output = (result.stdout or "") + "\n" + (result.stderr or "") + temp_out.seek(0, 2) + if result.returncode == 0 and temp_out.tell() > 0: + temp_out.seek(0) + return temp_out.read(), True, log_output + return raw_content, False, log_output + except Exception as e: + msg = f"Ghostscript conversion exception: {e}" + _logger.exception(msg) + return raw_content, False, msg + + @api.model + def _odoo_convert_pdf_to_pdfa(self, raw_content, title): + """Convert PDF raw bytes using Odoo native PyPDF writer.""" + try: + with io.BytesIO(raw_content) as pdf_stream: + reader = OdooPdfFileReader(pdf_stream, strict=False) + writer = OdooPdfFileWriter() + writer.cloneReaderDocumentRoot(reader) + writer.convert_to_pdfa() + + metadata_template = self.env.ref( + "attachment_pdfa.pdfa3_metadata", raise_if_not_found=False + ) + if metadata_template: + content = self.env["ir.qweb"]._render( + "attachment_pdfa.pdfa3_metadata", + { + "title": title or "Document", + "date": fields.Date.context_today(self), + }, + ) + writer.add_file_metadata(content.encode("utf-8")) + with io.BytesIO() as new_pdf_stream: + writer.write(new_pdf_stream) + return ( + new_pdf_stream.getvalue(), + True, + "Odoo conversion successful.", + ) + except Exception as e: + msg = f"Odoo PDF/A conversion failed: {str(e)}" + _logger.exception(msg) + return raw_content, False, msg + + @api.model + def _check_record_requires_pdfa(self, res_model, res_id): + """Check if target model and record require PDF/A conversion.""" + if not (res_model and res_id): + return False + model_obj = self.env.get(res_model) + if model_obj is None or not hasattr(model_obj, "_attachment_must_be_pdfa"): + return False + record = model_obj.browse(int(res_id)) + return record.exists() and record._attachment_must_be_pdfa() + + @api.model + def _should_convert_to_pdfa(self, vals): + """Check if attachment payload qualifies for conversion.""" + mimetype = vals.get("mimetype", "") + name = vals.get("name", "") + is_pdf_mimetype = mimetype == "application/pdf" + is_pdf_extension = name.lower().endswith(".pdf") + if not (is_pdf_mimetype or is_pdf_extension): + return False + res_model = vals.get("res_model") or self.env.context.get("default_res_model") + res_id = vals.get("res_id") or self.env.context.get("default_res_id") + return self._check_record_requires_pdfa(res_model, res_id) + + @api.model + def _process_pdfa_vals(self, vals, method): + """Mutate creation/update dictionary to replace binary with PDF/A output.""" + raw_data = vals.get("raw") + if not raw_data and vals.get("datas"): + raw_data = base64.b64decode(vals["datas"]) + if not raw_data: + return None + filename = vals.get("name", "Document.pdf") + if method == "odoo": + new_raw, success, log_msg = self._odoo_convert_pdf_to_pdfa( + raw_data, filename + ) + elif method == "ghostscript": + new_raw, success, log_msg = self._gs_convert_pdf_to_pdfa(raw_data) + else: + return None + if success: + vals["raw"] = new_raw + if "datas" in vals: + vals["datas"] = base64.b64encode(new_raw) + vals["mimetype"] = "application/pdf" + return { + "filename": filename, + "success": success, + "log_msg": log_msg, + "res_model": ( + vals.get("res_model") or self.env.context.get("default_res_model") + ), + "res_id": (vals.get("res_id") or self.env.context.get("default_res_id")), + } + + def _post_pdfa_conversion_log(self, log_info): + """Post conversion errors or warnings to target record chatter.""" + log_msg = log_info["log_msg"] + success = log_info["success"] + is_warning_or_error = ( + not success or "warning" in log_msg.lower() or "error" in log_msg.lower() + ) + if not ( + is_warning_or_error + and log_msg.strip() + and log_info["res_model"] + and log_info["res_id"] + ): + return + target_record = self.env[log_info["res_model"]].browse(int(log_info["res_id"])) + if not hasattr(target_record, "message_post"): + return + escaped_name = html_escape(log_info["filename"]) + status_type = "Error" if not success else "Warning" + status_title = ( + f"PDF/A-3 Conversion {status_type} for attachment {escaped_name}" + ) + style = "background-color: #f8f9fa; padding: 8px; border-radius: 4px;" + formatted_body = ( + f"

{status_title}

" + f"
{html_escape(log_msg)}
" + ) + target_record.message_post( + body=Markup(formatted_body), + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + @api.model_create_multi + def create(self, vals_list): + method = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("attachment_pdfa.method", "ghostscript") + ) + logs_to_post = [] + if method != "disable": + for vals in vals_list: + if self._should_convert_to_pdfa(vals): + log_info = self._process_pdfa_vals(vals, method) + if log_info: + logs_to_post.append(log_info) + attachments = super().create(vals_list) + for log_info in logs_to_post: + self._post_pdfa_conversion_log(log_info) + return attachments + + def write(self, vals): + method = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("attachment_pdfa.method", "ghostscript") + ) + if method == "disable" or not ( + "raw" in vals or "datas" in vals or "res_model" in vals or "res_id" in vals + ): + return super().write(vals) + new_raw_from_datas = ( + base64.b64decode(vals["datas"]) if "datas" in vals else None + ) + logs_to_post = [] + for attachment in self: + combined_vals = { + "name": vals.get("name", attachment.name), + "mimetype": vals.get("mimetype", attachment.mimetype), + "res_model": vals.get("res_model", attachment.res_model), + "res_id": vals.get("res_id", attachment.res_id), + "raw": vals.get("raw") + or ( + new_raw_from_datas + if new_raw_from_datas is not None + else attachment.raw + ), + } + record_vals = dict(vals) + if self._should_convert_to_pdfa(combined_vals): + log_info = self._process_pdfa_vals(combined_vals, method) + if log_info: + if log_info["success"]: + record_vals["raw"] = combined_vals["raw"] + record_vals["mimetype"] = combined_vals["mimetype"] + record_vals.pop("datas", None) + logs_to_post.append(log_info) + super(IrAttachment, attachment).write(record_vals) + for log_info in logs_to_post: + self._post_pdfa_conversion_log(log_info) + return True diff --git a/attachment_pdfa/models/res_config_settings.py b/attachment_pdfa/models/res_config_settings.py new file mode 100644 index 0000000000..5da561626c --- /dev/null +++ b/attachment_pdfa/models/res_config_settings.py @@ -0,0 +1,20 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + attachment_pdfa_method = fields.Selection( + selection=[ + ("ghostscript", "Ghostscript"), + ("odoo", "Odoo"), + ("disable", "Disable"), + ], + string="PDF to PDF/A Conversion Method", + config_parameter="attachment_pdfa.method", + default="ghostscript", + help="Choose the underlying engine to convert PDF to PDF/A.", + ) diff --git a/attachment_pdfa/pyproject.toml b/attachment_pdfa/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/attachment_pdfa/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/attachment_pdfa/readme/CONFIGURE.md b/attachment_pdfa/readme/CONFIGURE.md new file mode 100644 index 0000000000..2256688f97 --- /dev/null +++ b/attachment_pdfa/readme/CONFIGURE.md @@ -0,0 +1,11 @@ +To configure this module: + +1. Go to **Settings** > **General Settings**. +2. Scroll down to the **PDF/A Conversion** section under *Document Settings*. +3. Select your preferred conversion method: + + * **Ghostscript** (default). + * **Odoo**. + * **Disable**: Disables automatic conversion globally. + +4. Click **Save**. diff --git a/attachment_pdfa/readme/CONTEXT.md b/attachment_pdfa/readme/CONTEXT.md new file mode 100644 index 0000000000..41b5dd3ada --- /dev/null +++ b/attachment_pdfa/readme/CONTEXT.md @@ -0,0 +1,7 @@ +In many legal frameworks and corporate archiving policies, business documents must be stored in a compliant, long-term preservation format like **PDF/A-3**. + +This module provides a flexible framework to automate PDF to PDF/A-3 conversion upon attachment creation: + +* **Opt-in via Mixin**: Rather than blindly converting all system PDFs, models must opt-in by inheriting ``attachment.pdfa.mixin`` and defining conditional rules. +* **Pluggable Engine**: Offers conversion via **Ghostscript** or **Odoo**. +* **Chatter Integration**: Non-blocking conversion pipeline — if the conversion generates warnings or errors during conversion, details are posted directly as internal notes in the record's Chatter. diff --git a/attachment_pdfa/readme/CONTRIBUTORS.md b/attachment_pdfa/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..385e280022 --- /dev/null +++ b/attachment_pdfa/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Zina Rasoamanana ([Acsone](https://acsone.eu)) +- Benoit Aimont ([Acsone](https://acsone.eu)) +- Stéphane Bidoul ([Acsone](https://acsone.eu)) diff --git a/attachment_pdfa/readme/DESCRIPTION.md b/attachment_pdfa/readme/DESCRIPTION.md new file mode 100644 index 0000000000..5e9fe2875c --- /dev/null +++ b/attachment_pdfa/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module extends Odoo's attachment system to support automatic conversion of PDF files to the **PDF/A-3** format. diff --git a/attachment_pdfa/readme/INSTALL.md b/attachment_pdfa/readme/INSTALL.md new file mode 100644 index 0000000000..b205a51238 --- /dev/null +++ b/attachment_pdfa/readme/INSTALL.md @@ -0,0 +1,4 @@ +To install and use Ghostscript conversion (default engine): + +1. Ensure Ghostscript is installed on your Odoo server host system. +2. Ensure the ``gs`` binary is available in the system execution PATH of the user running Odoo. diff --git a/attachment_pdfa/readme/USAGE.md b/attachment_pdfa/readme/USAGE.md new file mode 100644 index 0000000000..d34155b860 --- /dev/null +++ b/attachment_pdfa/readme/USAGE.md @@ -0,0 +1,5 @@ +To use this module: + +1. Create or generate a PDF attachment on a record whose model implements ``attachment.pdfa.mixin`` (and where ``_attachment_must_be_pdfa()`` evaluates to ``True``). +2. The created PDF is automatically converted to PDF/A-3. +3. If the conversion encounters any non-fatal warnings or fails to convert the file, a log note is automatically posted to the record's **Chatter**, allowing administrators and users to review the conversion output. diff --git a/attachment_pdfa/static/description/icon.png b/attachment_pdfa/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/attachment_pdfa/static/description/icon.png differ diff --git a/attachment_pdfa/static/description/index.html b/attachment_pdfa/static/description/index.html new file mode 100644 index 0000000000..fa8b7f5cc0 --- /dev/null +++ b/attachment_pdfa/static/description/index.html @@ -0,0 +1,489 @@ + + + + + +Attachment PDF/A + + + +
+

Attachment PDF/A

+ + +

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

+

This module extends Odoo’s attachment system to support automatic +conversion of PDF files to the PDF/A-3 format.

+

Table of contents

+ +
+

Use Cases / Context

+

In many legal frameworks and corporate archiving policies, business +documents must be stored in a compliant, long-term preservation format +like PDF/A-3.

+

This module provides a flexible framework to automate PDF to PDF/A-3 +conversion upon attachment creation:

+
    +
  • Opt-in via Mixin: Rather than blindly converting all system PDFs, +models must opt-in by inheriting attachment.pdfa.mixin and +defining conditional rules.
  • +
  • Pluggable Engine: Offers conversion via Ghostscript or +Odoo.
  • +
  • Chatter Integration: Non-blocking conversion pipeline — if the +conversion generates warnings or errors during conversion, details are +posted directly as internal notes in the record’s Chatter.
  • +
+
+
+

Installation

+

To install and use Ghostscript conversion (default engine):

+
    +
  1. Ensure Ghostscript is installed on your Odoo server host system.
  2. +
  3. Ensure the gs binary is available in the system execution PATH of +the user running Odoo.
  4. +
+
+
+

Configuration

+

To configure this module:

+
    +
  1. Go to Settings > General Settings.
  2. +
  3. Scroll down to the PDF/A Conversion section under Document +Settings.
  4. +
  5. Select your preferred conversion method:
      +
    • Ghostscript (default).
    • +
    • Odoo.
    • +
    • Disable: Disables automatic conversion globally.
    • +
    +
  6. +
  7. Click Save.
  8. +
+
+
+

Usage

+

To use this module:

+
    +
  1. Create or generate a PDF attachment on a record whose model +implements attachment.pdfa.mixin (and where +_attachment_must_be_pdfa() evaluates to True).
  2. +
  3. The created PDF is automatically converted to PDF/A-3.
  4. +
  5. If the conversion encounters any non-fatal warnings or fails to +convert the file, a log note is automatically posted to the record’s +Chatter, allowing administrators and users to review the +conversion output.
  6. +
+
+
+

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

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

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/storage project on GitHub.

+

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

+
+
+
+ + diff --git a/attachment_pdfa/tests/__init__.py b/attachment_pdfa/tests/__init__.py new file mode 100644 index 0000000000..0ad1d8a437 --- /dev/null +++ b/attachment_pdfa/tests/__init__.py @@ -0,0 +1 @@ +from . import test_attachment_pdfa diff --git a/attachment_pdfa/tests/dummy.pdf b/attachment_pdfa/tests/dummy.pdf new file mode 100644 index 0000000000..774c2ea70c Binary files /dev/null and b/attachment_pdfa/tests/dummy.pdf differ diff --git a/attachment_pdfa/tests/pdfa_test_model.py b/attachment_pdfa/tests/pdfa_test_model.py new file mode 100644 index 0000000000..d2c7e81639 --- /dev/null +++ b/attachment_pdfa/tests/pdfa_test_model.py @@ -0,0 +1,15 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class PdfaTestModel(models.Model): + _name = "pdfa.test.model" + _description = "PDF/A Test Model" + _inherit = ["mail.thread", "attachment.pdfa.mixin"] + + must_convert = fields.Boolean(default=True) + + def _attachment_must_be_pdfa(self): + return self.must_convert diff --git a/attachment_pdfa/tests/test_attachment_pdfa.py b/attachment_pdfa/tests/test_attachment_pdfa.py new file mode 100644 index 0000000000..10242b4001 --- /dev/null +++ b/attachment_pdfa/tests/test_attachment_pdfa.py @@ -0,0 +1,220 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +import os + +from odoo_test_helper import FakeModelLoader + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestIrAttachmentPdfa(TransactionCase): + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + + from .pdfa_test_model import PdfaTestModel + + self.loader.update_registry((PdfaTestModel,)) + self.test_record = self.env["pdfa.test.model"].create( + { + "must_convert": True, + } + ) + self.dir_path = os.path.dirname(os.path.realpath(__file__)) + self.dummy_pdf_path = os.path.join(self.dir_path, "dummy.pdf") + + def tearDown(self): + self.loader.restore_registry() + super().tearDown() + + def _get_dummy_pdf_bytes(self): + with open(self.dummy_pdf_path, "rb") as pdf_file: + return pdf_file.read() + + def _set_conversion_method(self, method): + self.env["ir.config_parameter"].sudo().set_param( + "attachment_pdfa.method", method + ) + + def test_convert_pdf_ghostscript(self): + """Test real conversion using Ghostscript engine.""" + self._set_conversion_method("ghostscript") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "dummy_gs.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.id) + self.assertEqual(attachment.mimetype, "application/pdf") + # Verify the binary content was altered by Ghostscript + self.assertNotEqual(attachment.raw, raw_pdf) + # Check for Ghostscript PDF/A output intent markers + self.assertTrue( + b"pdfaid" in attachment.raw or b"GTS_PDFA" in attachment.raw, + "Ghostscript output does not contain expected PDF/A metadata", + ) + + @mute_logger("odoo.tools.pdf") + def test_convert_pdf_odoo_native(self): + """Test real conversion using Odoo native engine.""" + self._set_conversion_method("odoo") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "dummy_odoo.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.id) + self.assertEqual(attachment.mimetype, "application/pdf") + # Verify the binary content was rewritten + self.assertNotEqual(attachment.raw, raw_pdf) + # Check for XMP PDF/A-3 metadata injected by Odoo + self.assertIn( + b"3", + attachment.raw, + "Odoo native output does not contain PDF/A-3 XMP metadata", + ) + + @mute_logger("odoo.tools.pdf") + def test_write_delayed_record_linking(self): + """Test conversion triggered on write when res_model/res_id are linked late.""" + self._set_conversion_method("odoo") + raw_pdf = self._get_dummy_pdf_bytes() + # Create unlinked attachment first + attachment = self.env["ir.attachment"].create( + { + "name": "unlinked.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + } + ) + self.assertEqual(attachment.raw, raw_pdf) + # Link model and record via write + attachment.write( + { + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertNotEqual(attachment.raw, raw_pdf) + self.assertIn(b"3", attachment.raw) + + @mute_logger("odoo.tools.pdf") + def test_write_update_binary_content(self): + """Test conversion when updating binary datas field via write.""" + self._set_conversion_method("odoo") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "initial.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + # Write new content using datas field + encoded_datas = base64.b64encode(raw_pdf).decode("utf-8") + attachment.write({"datas": encoded_datas}) + self.assertNotEqual(attachment.raw, raw_pdf) + self.assertIn(b"3", attachment.raw) + + @mute_logger("odoo.addons.attachment_pdfa.models.ir_attachment") + def test_conversion_failure_posts_chatter_message(self): + """Test that a conversion failure posts an error note to chatter.""" + self._set_conversion_method("odoo") + corrupt_pdf_raw = b"INVALID_CORRUPT_PDF_DATA" + initial_msg_count = len(self.test_record.message_ids) + attachment = self.env["ir.attachment"].create( + { + "name": "corrupt_document.pdf", + "raw": corrupt_pdf_raw, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + # Binary should remain unchanged on failure + self.assertEqual(attachment.raw, corrupt_pdf_raw) + # Verify that an error note was posted to chatter + messages = self.test_record.message_ids + self.assertEqual(len(messages), initial_msg_count + 1) + latest_message = messages[0] + self.assertIn("PDF/A-3 Conversion Error", latest_message.body) + self.assertIn("corrupt_document.pdf", latest_message.body) + + def test_conversion_disabled(self): + """Test that setting method to 'disable' leaves PDF untouched.""" + self._set_conversion_method("disable") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "dummy_disabled.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + # Binary content must remain identical to original + self.assertEqual(attachment.raw, raw_pdf) + + def test_mixin_condition_false(self): + """Test that conversion is skipped when mixin returns False.""" + self._set_conversion_method("ghostscript") + self.test_record.must_convert = False + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "dummy_skipped.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + # Binary content must remain untouched + self.assertEqual(attachment.raw, raw_pdf) + + def test_unsupported_model_ignored(self): + """Test that models without attachment.pdfa.mixin are safely skipped.""" + self._set_conversion_method("ghostscript") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "res_partner.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "res.partner", + "res_id": self.env.user.partner_id.id, + } + ) + self.assertEqual(attachment.raw, raw_pdf) + + def test_non_pdf_attachment_ignored(self): + """Test that non-PDF attachments (e.g. images) are ignored.""" + self._set_conversion_method("ghostscript") + image_raw = b"FAKE_PNG_DATA" + attachment = self.env["ir.attachment"].create( + { + "name": "test_image.png", + "raw": image_raw, + "mimetype": "image/png", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertEqual(attachment.raw, image_raw) diff --git a/attachment_pdfa/views/res_config_settings.xml b/attachment_pdfa/views/res_config_settings.xml new file mode 100644 index 0000000000..5e72218b49 --- /dev/null +++ b/attachment_pdfa/views/res_config_settings.xml @@ -0,0 +1,22 @@ + + + + + res.config.settings.view.form.inherit.attachment.pdfa + res.config.settings + + + + + + + + + + + +