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..d444816235 --- /dev/null +++ b/attachment_pdfa/README.rst @@ -0,0 +1,152 @@ +================ +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 background +conversion of PDF files to the **PDF/A-3** format. + +This module provides a flexible framework to automate PDF to PDF/A-3 +conversion via an asynchronous background cron: + +- **Opt-in via Mixin**: Rather than blindly converting all system PDFs, + models must opt-in by inheriting ``attachment.pdfa.mixin`` and + implementing conditional rules via + ``_attachment_must_be_pdfa(attachment)``. +- **Cron**: Attachments requiring conversion are flagged via the + ``is_pdfa_needed`` field on ``ir.attachment`` and processed by a + background cron. +- **Flexible Storage Strategy**: Configurable options allow converted + PDF/A files to either replace the original attachments or be saved + alongside them as new attachments. +- **Pluggable Engine**: Offers conversion via **Ghostscript** or native + **Odoo** PDF writer. + +**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**. + +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. Configure your options: + + - **PDF to PDF/A Conversion Method**: + + - **Ghostscript** (default). + - **Odoo**. + - **Disable**: Disables automatic conversion globally. + + - **PDF/A Storage Strategy**: + + - **Replace Original File** (default): Overwrites the original PDF + attachment with the PDF/A binary. + - **Store Beside Original File**: Preserves the original file and + creates a new attachment named ``_PDFA.pdf`` alongside + it. + +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(attachment)`` evaluates to ``True``). +2. The attachment automatically flags ``is_pdfa_needed = True``. +3. The scheduled cron processes pending attachments in the background, + updating or creating attachments according to your configured storage + strategy. + +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..1a3094947c --- /dev/null +++ b/attachment_pdfa/__manifest__.py @@ -0,0 +1,27 @@ +# 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": [ + "base_setup", + ], + "data": [ + "data/ir_cron.xml", + "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..716a3aa653 --- /dev/null +++ b/attachment_pdfa/data/config_parameter.xml @@ -0,0 +1,13 @@ + + + + + attachment_pdfa.method + ghostscript + + + attachment_pdfa.mode + replace + + 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/ir_cron.xml b/attachment_pdfa/data/ir_cron.xml new file mode 100644 index 0000000000..c66070bc10 --- /dev/null +++ b/attachment_pdfa/data/ir_cron.xml @@ -0,0 +1,13 @@ + + + + + Convert Attachments to PDF/A + + code + model._cron_convert_pdfa() + 1 + days + + 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..bde6414c26 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..a7a6ef6dca --- /dev/null +++ b/attachment_pdfa/models/attachment_pdfa_mixin.py @@ -0,0 +1,16 @@ +# 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, attachment): + """Determine whether an attachment requires PDF/A conversion. + + :param attachment: ir.attachment record + """ + return False diff --git a/attachment_pdfa/models/ir_attachment.py b/attachment_pdfa/models/ir_attachment.py new file mode 100644 index 0000000000..7fb9e3f485 --- /dev/null +++ b/attachment_pdfa/models/ir_attachment.py @@ -0,0 +1,314 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +import io +import logging +import re +import subprocess +import tempfile +from pathlib import Path + +from odoo import _, api, fields, models, modules, tools +from odoo.exceptions import UserError +from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter + +_logger = logging.getLogger(__name__) + +TIMEOUT_CONVERSION = 60 + +_PDFA_TARGET_PART = "3" +_PDFA_PART_RE = re.compile(rb'pdfaid:part\s*(?:=\s*["\x27]|>)\s*(\d+)') + +DATA_DIR = (Path(__file__).parent.parent / "data").resolve() +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." + +_PDFA_ABORT_MARKERS = ( + "PDF/A processing aborted", + "Failed to open the supplied ICCProfile", +) + +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" + + is_pdfa_needed = fields.Boolean( + string="Needs PDF/A Conversion", + compute="_compute_is_pdfa_needed", + store=True, + readonly=False, + precompute=True, + ) + + @api.depends("mimetype", "name", "res_model", "res_id") + def _compute_is_pdfa_needed(self): + for attachment in self: + attachment.is_pdfa_needed = attachment._check_should_be_pdfa() + + def _is_already_pdfa3(self, raw_data=None): + """Return True only if the content already declares PDF/A-3 conformance. + + Content already tagged PDF/A-1 or PDF/A-2 must still be (re-)converted, + since this module's conversion target is specifically PDF/A-3 -- the + presence of *a* pdfaid marker isn't enough to skip it. + """ + if raw_data is None: + raw_data = self._get_clean_raw_pdf() + match = _PDFA_PART_RE.search(raw_data) + if not match: + return False + return match.group(1).decode() == _PDFA_TARGET_PART + + def _get_clean_raw_pdf(self): + """Retrieve raw binary bytes, un-encoding base64 data if necessary.""" + self.ensure_one() + raw_data = self.raw + if not raw_data and self.datas: + try: + raw_data = base64.b64decode(self.datas) + except Exception: + raw_data = b"" + if raw_data and not raw_data.startswith(b"%PDF"): + try: + decoded = base64.b64decode(raw_data) + if decoded.startswith(b"%PDF"): + raw_data = decoded + except Exception as e: + _logger.debug("Failed to base64-decode attachment PDF raw data: %s", e) + return raw_data or b"" + + def _get_target_record(self): + """Retrieve the target record linked to this attachment.""" + self.ensure_one() + if not (self.res_model and self.res_id): + return False + res_model = self.res_model + res_id = self.res_id + # Target record may be linked to a mail.message (soft dependency: only + # resolved if the "mail" module happens to be installed) + message_model = self.env.get("mail.message") + if res_model == "mail.message" and message_model is not None: + message = message_model.browse(res_id) + if message.exists() and message.model and message.res_id: + res_model = message.model + res_id = message.res_id + model_obj = self.env.get(res_model) + if model_obj is None: + return False + record = model_obj.browse(res_id) + return record if record.exists() else False + + def _check_should_be_pdfa(self): + self.ensure_one() + is_pdf_mimetype = self.mimetype == "application/pdf" + is_pdf_extension = (self.name or "").lower().endswith(".pdf") + if not (is_pdf_mimetype or is_pdf_extension): + return False + raw_data = self._get_clean_raw_pdf() + if not raw_data.startswith(b"%PDF") or self._is_already_pdfa3(raw_data): + return False + record = self._get_target_record() + if not record or not hasattr(record, "_attachment_must_be_pdfa"): + return False + return record._attachment_must_be_pdfa(self) + + @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 + temp_in = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + temp_out = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + try: + temp_in.write(raw_content) + temp_in.close() + temp_out.close() + 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 "") + content = Path(temp_out.name).read_bytes() + pdfa_aborted = any(marker in log_output for marker in _PDFA_ABORT_MARKERS) + if result.returncode == 0 and content and not pdfa_aborted: + return content, True, log_output + if pdfa_aborted: + log_output = ( + "Ghostscript reported PDF/A processing was aborted " + "(output would not be PDF/A compliant):\n" + 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 + finally: + Path(temp_in.name).unlink(missing_ok=True) + Path(temp_out.name).unlink(missing_ok=True) + + @api.model + def _odoo_convert_pdf_to_pdfa(self, raw_content, title): + """Convert PDF raw bytes using Odoo native PyPDF writer.""" + try: + reader = OdooPdfFileReader(io.BytesIO(raw_content), 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")) + new_pdf_stream = io.BytesIO() + 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 + + def _process_one_pdfa_conversion(self, method): + """Process PDF/A conversion for a single attachment.""" + self.ensure_one() + raw_data = self._get_clean_raw_pdf() + if not raw_data or not raw_data.startswith(b"%PDF"): + self.write({"is_pdfa_needed": False}) + return + filename = self.name or "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: + self.write({"is_pdfa_needed": False}) + return + if success: + mode = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("attachment_pdfa.mode", "replace") + ) + if mode == "beside": + self.write({"is_pdfa_needed": False}) + base_name = Path(filename).stem + new_name = f"{base_name}_PDFA.pdf" + self.create( + { + "name": new_name, + "raw": new_raw, + "mimetype": "application/pdf", + "res_model": self.res_model, + "res_id": self.res_id, + "is_pdfa_needed": False, + } + ) + else: + self.write( + { + "raw": new_raw, + "mimetype": "application/pdf", + "is_pdfa_needed": False, + } + ) + else: + raise UserError( + _("PDF/A conversion failed for attachment %(name)s: %(msg)s") + % {"name": filename, "msg": log_msg} + ) + + @staticmethod + def _can_commit(): + """Helper to know if we can commit the current transaction or not. + + :returns: True if commit is acceptable, False otherwise. + """ + return not tools.config["test_enable"] and not modules.module.current_test + + @api.model + def _cron_convert_pdfa(self, batch_size=100): + """Cron entrypoint to convert attachments.""" + method = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("attachment_pdfa.method", "ghostscript") + ) + if method == "disable": + return + # Use skip_res_field_check=True to include field-backed attachments + attachments = ( + self.sudo() + .with_context(skip_res_field_check=True) + .search([("is_pdfa_needed", "=", True)], limit=batch_size) + ) + if not attachments: + return + errors = [] + for attachment in attachments: + try: + with self.env.cr.savepoint(): + attachment._process_one_pdfa_conversion(method) + except Exception as e: + attachment.write({"is_pdfa_needed": False}) + errors.append(str(e)) + # Persist changes per attachment + if self._can_commit(): + self.env.cr.commit() # pylint: disable=invalid-commit + remaining_count = ( + self.sudo() + .with_context(skip_res_field_check=True) + .search_count([("is_pdfa_needed", "=", True)]) + ) + if len(attachments) == batch_size and remaining_count: + cron = self.env.ref( + "attachment_pdfa.ir_cron_convert_pdfa", raise_if_not_found=False + ) + if cron: + cron._trigger() + # If any conversions failed, raise the combined errors after treating all + # attachments + if errors: + raise UserError( + _( + "PDF/A conversion failed for %(count)s attachment(s):\n\n" + "%(errors)s" + ) + % {"count": len(errors), "errors": "\n\n".join(errors)} + ) diff --git a/attachment_pdfa/models/res_config_settings.py b/attachment_pdfa/models/res_config_settings.py new file mode 100644 index 0000000000..72c80ff8be --- /dev/null +++ b/attachment_pdfa/models/res_config_settings.py @@ -0,0 +1,31 @@ +# 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.", + ) + attachment_pdfa_mode = fields.Selection( + selection=[ + ("replace", "Replace Original File"), + ("beside", "Store Beside Original File"), + ], + string="PDF/A Storage Strategy", + config_parameter="attachment_pdfa.mode", + default="replace", + help="Choose whether to replace the original PDF attachment or create a new " + "attachment beside it.", + ) 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..9d9e959013 --- /dev/null +++ b/attachment_pdfa/readme/CONFIGURE.md @@ -0,0 +1,13 @@ +To configure this module: + +1. Go to **Settings** > **General Settings**. +2. Scroll down to the **PDF/A Conversion** section under *Document Settings*. +3. Configure your options: + * **PDF to PDF/A Conversion Method**: + * **Ghostscript** (default). + * **Odoo**. + * **Disable**: Disables automatic conversion globally. + * **PDF/A Storage Strategy**: + * **Replace Original File** (default): Overwrites the original PDF attachment with the PDF/A binary. + * **Store Beside Original File**: Preserves the original file and creates a new attachment named `_PDFA.pdf` alongside it. +4. Click **Save**. diff --git a/attachment_pdfa/readme/CONTEXT.md b/attachment_pdfa/readme/CONTEXT.md new file mode 100644 index 0000000000..1abe61c9a7 --- /dev/null +++ b/attachment_pdfa/readme/CONTEXT.md @@ -0,0 +1 @@ +In many legal frameworks and corporate archiving policies, business documents must be stored in a compliant, long-term preservation format like **PDF/A-3**. 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..ab5ff9f19d --- /dev/null +++ b/attachment_pdfa/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module extends Odoo's attachment system to support background conversion of PDF files to the **PDF/A-3** format. + +This module provides a flexible framework to automate PDF to PDF/A-3 conversion via an asynchronous background cron: + +* **Opt-in via Mixin**: Rather than blindly converting all system PDFs, models must opt-in by inheriting `attachment.pdfa.mixin` and implementing conditional rules via `_attachment_must_be_pdfa(attachment)`. +* **Cron**: Attachments requiring conversion are flagged via the `is_pdfa_needed` field on `ir.attachment` and processed by a background cron. +* **Flexible Storage Strategy**: Configurable options allow converted PDF/A files to either replace the original attachments or be saved alongside them as new attachments. +* **Pluggable Engine**: Offers conversion via **Ghostscript** or native **Odoo** PDF writer. 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..f20e97cf2c --- /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(attachment)` evaluates to `True`). +2. The attachment automatically flags `is_pdfa_needed = True`. +3. The scheduled cron processes pending attachments in the background, updating or creating attachments according to your configured storage strategy. 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..0aec36ca8d --- /dev/null +++ b/attachment_pdfa/static/description/index.html @@ -0,0 +1,503 @@ + + + + + +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 background +conversion of PDF files to the PDF/A-3 format.

+

This module provides a flexible framework to automate PDF to PDF/A-3 +conversion via an asynchronous background cron:

+
    +
  • Opt-in via Mixin: Rather than blindly converting all system PDFs, +models must opt-in by inheriting attachment.pdfa.mixin and +implementing conditional rules via +_attachment_must_be_pdfa(attachment).
  • +
  • Cron: Attachments requiring conversion are flagged via the +is_pdfa_needed field on ir.attachment and processed by a +background cron.
  • +
  • Flexible Storage Strategy: Configurable options allow converted +PDF/A files to either replace the original attachments or be saved +alongside them as new attachments.
  • +
  • Pluggable Engine: Offers conversion via Ghostscript or native +Odoo PDF writer.
  • +
+

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.

+
+
+

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. Configure your options:
      +
    • PDF to PDF/A Conversion Method:
        +
      • Ghostscript (default).
      • +
      • Odoo.
      • +
      • Disable: Disables automatic conversion globally.
      • +
      +
    • +
    • PDF/A Storage Strategy:
        +
      • Replace Original File (default): Overwrites the original PDF +attachment with the PDF/A binary.
      • +
      • Store Beside Original File: Preserves the original file and +creates a new attachment named <Filename>_PDFA.pdf alongside +it.
      • +
      +
    • +
    +
  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(attachment) evaluates to True).
  2. +
  3. The attachment automatically flags is_pdfa_needed = True.
  4. +
  5. The scheduled cron processes pending attachments in the background, +updating or creating attachments according to your configured storage +strategy.
  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/fake_mail_message.py b/attachment_pdfa/tests/fake_mail_message.py new file mode 100644 index 0000000000..5b6ee19e82 --- /dev/null +++ b/attachment_pdfa/tests/fake_mail_message.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 fields, models + + +class FakeMailMessage(models.Model): + _name = "mail.message" + _description = "Fake Mail Message for PDF/A Tests" + + model = fields.Char() + res_id = fields.Integer() diff --git a/attachment_pdfa/tests/pdfa_test_model.py b/attachment_pdfa/tests/pdfa_test_model.py new file mode 100644 index 0000000000..0dd10bae72 --- /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 = ["attachment.pdfa.mixin"] + + must_convert = fields.Boolean(default=True) + + def _attachment_must_be_pdfa(self, attachment): + 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..7390ebc28e --- /dev/null +++ b/attachment_pdfa/tests/test_attachment_pdfa.py @@ -0,0 +1,481 @@ +# 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.exceptions import UserError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +from odoo.addons.attachment_pdfa.models import ir_attachment + + +class TestIrAttachmentPdfa(TransactionCase): + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + from .fake_mail_message import FakeMailMessage + from .pdfa_test_model import PdfaTestModel + + # Load FakeMailMessage if mail module is not installed in the test env + fake_models = [PdfaTestModel] + if "mail.message" not in self.env: + fake_models.append(FakeMailMessage) + self.loader.update_registry(tuple(fake_models)) + 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 _set_storage_mode(self, mode): + self.env["ir.config_parameter"].sudo().set_param("attachment_pdfa.mode", mode) + + def test_convert_pdf_ghostscript(self): + """Test real conversion using Ghostscript engine via cron in replace mode.""" + self._set_conversion_method("ghostscript") + self._set_storage_mode("replace") + 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.is_pdfa_needed) + self.assertEqual(attachment.raw, raw_pdf) + self.env["ir.attachment"]._cron_convert_pdfa() + self.assertFalse(attachment.is_pdfa_needed) + self.assertNotEqual(attachment.raw, raw_pdf) + self.assertTrue( + b"pdfaid" in attachment.raw or b"GTS_PDFA" in attachment.raw, + "Ghostscript output does not contain expected PDF/A metadata", + ) + + def test_mail_message_target_resolution(self): + """Test attachment linked to a mail.message pointing to a pdfa.test.model.""" + self._set_conversion_method("odoo") + message = self.env["mail.message"].create( + { + "model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "mail_document.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "mail.message", + "res_id": message.id, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + + @mute_logger("odoo.tools.pdf") + def test_convert_pdf_odoo_native(self): + """Test real conversion using Odoo native engine via cron in replace mode.""" + self._set_conversion_method("odoo") + self._set_storage_mode("replace") + 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.is_pdfa_needed) + self.assertEqual(attachment.raw, raw_pdf) + self.env["ir.attachment"]._cron_convert_pdfa() + self.assertFalse(attachment.is_pdfa_needed) + self.assertNotEqual(attachment.raw, raw_pdf) + self.assertIn( + b"3", + attachment.raw, + "Odoo native output does not contain PDF/A-3 XMP metadata", + ) + + @mute_logger("odoo.tools.pdf") + def test_storage_mode_beside(self): + """Test PDF/A conversion creating a new file beside original.""" + self._set_conversion_method("odoo") + self._set_storage_mode("beside") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "original_doc.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + self.env["ir.attachment"]._cron_convert_pdfa() + # Original attachment remains unchanged and flag is cleared + self.assertFalse(attachment.is_pdfa_needed) + self.assertEqual(attachment.raw, raw_pdf) + # Verify new PDF/A attachment was created beside original + new_attachment = self.env["ir.attachment"].search( + [ + ("res_model", "=", "pdfa.test.model"), + ("res_id", "=", self.test_record.id), + ("id", "!=", attachment.id), + ] + ) + self.assertEqual(len(new_attachment), 1) + self.assertEqual(new_attachment.name, "original_doc_PDFA.pdf") + self.assertNotEqual(new_attachment.raw, raw_pdf) + self.assertIn(b"3", new_attachment.raw) + self.assertFalse(new_attachment.is_pdfa_needed) + + @mute_logger("odoo.tools.pdf") + def test_write_delayed_record_linking(self): + """Test recomputing is_pdfa_needed and converting when linked late via write.""" + self._set_conversion_method("odoo") + self._set_storage_mode("replace") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "unlinked.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + } + ) + self.assertFalse(attachment.is_pdfa_needed) + # Link model and record via write + attachment.write( + { + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + self.env["ir.attachment"]._cron_convert_pdfa() + self.assertFalse(attachment.is_pdfa_needed) + 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 manually re-flagging is_pdfa_needed on content update.""" + self._set_conversion_method("odoo") + self._set_storage_mode("replace") + 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, + } + ) + self.env["ir.attachment"]._cron_convert_pdfa() + self.assertFalse(attachment.is_pdfa_needed) + # Update binary datas and set is_pdfa_needed manually (readonly=False) + encoded_datas = base64.b64encode(raw_pdf).decode("utf-8") + attachment.write({"datas": encoded_datas, "is_pdfa_needed": True}) + self.assertTrue(attachment.is_pdfa_needed) + self.env["ir.attachment"]._cron_convert_pdfa() + self.assertFalse(attachment.is_pdfa_needed) + self.assertIn(b"3", attachment.raw) + + @mute_logger("odoo.addons.attachment_pdfa.models.ir_attachment") + def test_conversion_failure_raises(self): + """Test that failure raises for a PDF that passes the header check + but is structurally corrupt (e.g. truncated), so it gets queued for + conversion and the actual parser fails on it.""" + self._set_conversion_method("odoo") + corrupt_pdf_raw = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<< /Type /Catalog" + 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, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + with self.assertRaises(UserError): + self.env["ir.attachment"]._cron_convert_pdfa() + + def test_conversion_disabled(self): + """Test that method 'disable' leaves attachments untreated in cron.""" + 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, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + self.env["ir.attachment"]._cron_convert_pdfa() + self.assertTrue(attachment.is_pdfa_needed) + self.assertEqual(attachment.raw, raw_pdf) + + def test_mixin_condition_false(self): + """Test that is_pdfa_needed computes to False 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, + } + ) + self.assertFalse(attachment.is_pdfa_needed) + + def test_unsupported_model_ignored(self): + """Test that models without attachment.pdfa.mixin compute is_pdfa_needed as + False.""" + 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.assertFalse(attachment.is_pdfa_needed) + + def test_non_pdf_attachment_ignored(self): + """Test non-PDF attachments compute is_pdfa_needed as False.""" + 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.assertFalse(attachment.is_pdfa_needed) + + def test_base_mixin_default_returns_false(self): + """Test default _attachment_must_be_pdfa implementation on base mixin.""" + attachment = self.env["ir.attachment"].create({"name": "test.pdf"}) + mixin_model = self.env["attachment.pdfa.mixin"] + self.assertFalse(mixin_model._attachment_must_be_pdfa(attachment)) + + def test_res_config_settings_integration(self): + """Test reading and saving conversion settings via res.config.settings.""" + config = self.env["res.config.settings"].create( + { + "attachment_pdfa_method": "odoo", + "attachment_pdfa_mode": "beside", + } + ) + config.execute() + self.assertEqual( + self.env["ir.config_parameter"].sudo().get_param("attachment_pdfa.method"), + "odoo", + ) + self.assertEqual( + self.env["ir.config_parameter"].sudo().get_param("attachment_pdfa.mode"), + "beside", + ) + + def test_already_pdfa3_ignored(self): + """Test that documents already tagged as PDF/A-3 are skipped.""" + self._set_conversion_method("odoo") + pdfa3_raw = b"%PDF-1.7\n3" + attachment = self.env["ir.attachment"].create( + { + "name": "already_pdfa3.pdf", + "raw": pdfa3_raw, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertFalse(attachment.is_pdfa_needed) + + def test_pdfa1_requires_conversion(self): + """Test that PDF/A-1 tagged files still require conversion to PDF/A-3.""" + self._set_conversion_method("odoo") + pdfa1_raw = b"%PDF-1.7\n1" + attachment = self.env["ir.attachment"].create( + { + "name": "pdfa1.pdf", + "raw": pdfa1_raw, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + + def test_pdf_extension_fallback(self): + """Test detection when mimetype is generic but filename ends in .pdf.""" + self._set_conversion_method("odoo") + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "document.pdf", + "raw": raw_pdf, + "mimetype": "application/octet-stream", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + + def test_datas_field_fallback_and_decoding(self): + """Test fallback to 'datas' field and base64 cleaning logic.""" + raw_pdf = self._get_dummy_pdf_bytes() + encoded_pdf = base64.b64encode(raw_pdf).decode("utf-8") + attachment = self.env["ir.attachment"].create( + { + "name": "datas_fallback.pdf", + "datas": encoded_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertTrue(attachment.is_pdfa_needed) + cleaned_raw = attachment._get_clean_raw_pdf() + self.assertTrue(cleaned_raw.startswith(b"%PDF")) + + def test_invalid_base64_datas_fallback_graceful(self): + """Test graceful error handling when raw content contains invalid base64.""" + attachment = self.env["ir.attachment"].create( + { + "name": "corrupt_b64.pdf", + "raw": b"!!!NOT_BASE64!!!", + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + self.assertEqual(attachment._get_clean_raw_pdf(), b"!!!NOT_BASE64!!!") + self.assertFalse(attachment.is_pdfa_needed) + + def test_missing_target_record_ignored(self): + """Test attachment with non-existent res_id computes is_pdfa_needed as False.""" + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "non_existent_record.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": 999999, + } + ) + self.assertFalse(attachment.is_pdfa_needed) + + def test_mail_message_pointing_to_unsupported_model(self): + """Test mail.message pointing to a model without mixin computes as False.""" + message = self.env["mail.message"].create( + { + "model": "res.partner", + "res_id": self.env.user.partner_id.id, + } + ) + raw_pdf = self._get_dummy_pdf_bytes() + attachment = self.env["ir.attachment"].create( + { + "name": "mail_partner.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "mail.message", + "res_id": message.id, + } + ) + self.assertFalse(attachment.is_pdfa_needed) + + def test_ghostscript_missing_profiles_failure(self): + """Test Ghostscript engine error handling when ICC profiles are unavailable.""" + self._set_conversion_method("ghostscript") + raw_pdf = self._get_dummy_pdf_bytes() + self.env["ir.attachment"].create( + { + "name": "dummy_gs_no_profile.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + orig_val = ir_attachment._PROFILES_AVAILABLE + try: + ir_attachment._PROFILES_AVAILABLE = False + with self.assertRaises(UserError): + self.env["ir.attachment"]._cron_convert_pdfa() + finally: + ir_attachment._PROFILES_AVAILABLE = orig_val + + @mute_logger("odoo.tools.pdf") + def test_cron_batching_and_retrigger(self): + """Test that cron schedules a trigger record when batch limit is reached.""" + self._set_conversion_method("odoo") + raw_pdf = self._get_dummy_pdf_bytes() + for i in range(2): + self.env["ir.attachment"].create( + { + "name": f"batch_{i}.pdf", + "raw": raw_pdf, + "mimetype": "application/pdf", + "res_model": "pdfa.test.model", + "res_id": self.test_record.id, + } + ) + cron = self.env.ref("attachment_pdfa.ir_cron_convert_pdfa") + triggers_before = self.env["ir.cron.trigger"].search_count( + [("cron_id", "=", cron.id)] + ) + self.env["ir.attachment"]._cron_convert_pdfa(batch_size=1) + triggers_after = self.env["ir.cron.trigger"].search_count( + [("cron_id", "=", cron.id)] + ) + self.assertGreater( + triggers_after, + triggers_before, + "Cron trigger record should be created when work remains.", + ) + + def test_can_commit_helper(self): + """Verify _can_commit returns False in test execution context.""" + self.assertFalse(self.env["ir.attachment"]._can_commit()) diff --git a/attachment_pdfa/views/res_config_settings.xml b/attachment_pdfa/views/res_config_settings.xml new file mode 100644 index 0000000000..fc657672d1 --- /dev/null +++ b/attachment_pdfa/views/res_config_settings.xml @@ -0,0 +1,31 @@ + + + + + res.config.settings.view.form.inherit.attachment.pdfa + res.config.settings + + + + + + + + + + + + + + +