diff --git a/attachment_preview_office/README.rst b/attachment_preview_office/README.rst new file mode 100644 index 00000000000..53a9ddfcefd --- /dev/null +++ b/attachment_preview_office/README.rst @@ -0,0 +1,112 @@ +========================= +Attachment Preview Office +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0445bfa8daff7a73f252d9cdcbc27806a805f6398a32851691d0a5b07dd35454 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fknowledge-lightgray.png?logo=github + :target: https://github.com/OCA/knowledge/tree/17.0/attachment_preview_office + :alt: OCA/knowledge +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/knowledge-17-0/knowledge-17-0-attachment_preview_office + :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/knowledge&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Preview office documents (DOCX, XLSX, PPTX, macro-enabled DOCM, XLSM, +PPTM, legacy DOC, XLS, PPT, and ODT, ODS, ODP, ODG) directly in the +browser, including chatter attachments. + +The document is converted to PDF on the Odoo server using LibreOffice in +headless mode and rendered with Odoo's native PDF.js viewer. Nothing +leaves your server: no external viewer service (Microsoft Office Online, +Google Docs) is involved, which makes this suitable for sensitive +documents. + +Converted PDFs are cached on disk (keyed by document checksum), so each +document is only converted once. The *Download* button still downloads +the original file, not the converted PDF. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +LibreOffice must be installed on the Odoo server and the ``libreoffice`` +binary must be available in the PATH of the Odoo process: + +:: + + apt-get install libreoffice + +On Odoo.sh, LibreOffice is already available by default. + +If LibreOffice is not available, the module still installs, but the +preview button is simply not shown for office files (the availability is +exposed to the web client through the session info). + +Usage +===== + +1. Attach an office file (e.g. an ``.xlsx``) to any record with a + chatter, or send it in a message. +2. Click on the attachment thumbnail. +3. The file opens in the standard Odoo file viewer, rendered as PDF. + +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 +------- + +* Jarsa + +Contributors +------------ + +- Jesús Alan Ramos Rodríguez + +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/knowledge `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/attachment_preview_office/__init__.py b/attachment_preview_office/__init__.py new file mode 100644 index 00000000000..91c5580fed3 --- /dev/null +++ b/attachment_preview_office/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/attachment_preview_office/__manifest__.py b/attachment_preview_office/__manifest__.py new file mode 100644 index 00000000000..1b6dc2579fb --- /dev/null +++ b/attachment_preview_office/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Attachment Preview Office", + "summary": "Preview Office attachments in the browser " + "by converting them to PDF with LibreOffice", + "version": "17.0.1.0.0", + "category": "Knowledge Management", + "website": "https://github.com/OCA/knowledge", + "author": "Jarsa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["mail"], + "assets": { + "web.assets_backend": [ + "attachment_preview_office/static/src/attachment_model_patch.esm.js", + "attachment_preview_office/static/src/file_viewer_patch.esm.js", + "attachment_preview_office/static/src/file_viewer_patch.xml", + ], + }, + "installable": True, +} diff --git a/attachment_preview_office/controllers/__init__.py b/attachment_preview_office/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/attachment_preview_office/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/attachment_preview_office/controllers/main.py b/attachment_preview_office/controllers/main.py new file mode 100644 index 00000000000..3d72aaca8ae --- /dev/null +++ b/attachment_preview_office/controllers/main.py @@ -0,0 +1,234 @@ +# Copyright 2026 Jarsa +# Copyright 2026 Ledoweb +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +"""LibreOffice-based conversion endpoint for office-document preview. + +Converts office documents (ODF *and* OOXML: ODT/ODS/ODP, DOCX/XLSX/PPTX, +macro-enabled DOCM/XLSM/PPTM, legacy DOC/XLS/PPT, ODG) to PDF so they can be +rendered by Odoo's native PDF.js viewer. +LibreOffice headless must be installed; if it is absent the endpoint returns +HTTP 503. +""" + +import base64 +import hashlib +import logging +import os +import subprocess +import tempfile + +from odoo import http, tools +from odoo.http import request + +_logger = logging.getLogger(__name__) + +# Office formats LibreOffice can convert to PDF (ODF + OOXML + legacy binary). +OFFICE_EXTENSIONS = frozenset( + { + "docx", + "docm", + "xlsx", + "xlsm", + "pptx", + "pptm", + "doc", + "xls", + "ppt", + "odt", + "ods", + "odp", + "odg", + } +) + +# Spreadsheets are exported with the SinglePageSheets option (one full-size +# page per sheet) so columns are not cut by Calc's print pagination. The +# option is seeded into the isolated LibreOffice profile registry instead of +# being passed as CLI filter options: the JSON filter-options syntax is only +# parsed by LibreOffice >= 7.4, while the registry works from 7.2. +SPREADSHEET_EXTENSIONS = frozenset({"xlsx", "xlsm", "xls", "ods"}) +SINGLE_PAGE_SHEETS_XCU = """ + + + true + + +""" +# Bump to invalidate PDFs cached before a conversion-behavior change. +CACHE_KEY_VERSION = "2" + +# Reject documents larger than this before spawning a (slow) conversion. +MAX_CONTENT_BYTES = 50 * 1024 * 1024 # 50 MB +# Per-conversion wall-clock budget (seconds). +CONVERT_TIMEOUT = 60 + + +class AttachmentPreviewOfficeController(http.Controller): + @http.route( + "/attachment_preview_office/to_pdf", + type="http", + auth="user", + methods=["GET"], + ) + def office_to_pdf( # pylint: disable=redefined-builtin + self, model, field, id, filename="file", **kwargs + ): + """Convert a binary field's office document to PDF for preview. + + Query params: + model – Odoo model name (e.g. 'ir.attachment') + field – binary field name (e.g. 'datas') + id – record id (integer) + filename – original filename (used to derive the extension) + """ + try: + record_id = int(id) + except (TypeError, ValueError): + return request.make_response("Bad request", status=400) + + # Extension allow-list (on the claimed filename) before touching the DB. + ext = os.path.splitext(filename)[-1].lstrip(".").lower() + if ext not in OFFICE_EXTENSIONS: + return request.make_response( + "Extension not supported for conversion", status=415 + ) + + record = request.env[model].browse(record_id).exists() + if not record: + return request.make_response("Not found", status=404) + # Record-level ACL: raises AccessError (-> 403) if the user can't read. + record.check_access_rights("read") + record.check_access_rule("read") + + # The field must exist on the model AND be a binary field — never read + # an arbitrary attribute supplied in the query string. + model_field = record._fields.get(field) + if model_field is None or model_field.type != "binary": + return request.make_response("Invalid field", status=400) + + raw = record[field] + if not raw: + return request.make_response("No content", status=404) + + content = base64.b64decode(raw) + if len(content) > MAX_CONTENT_BYTES: + return request.make_response("Document too large to preview", status=413) + + pdf_bytes = self._get_pdf_cached(content, ext) + if pdf_bytes is None: + return request.make_response( + "LibreOffice not available — cannot convert document", status=503 + ) + + return request.make_response( + pdf_bytes, + headers=[ + ("Content-Type", "application/pdf"), + ( + "Content-Disposition", + f'inline; filename="{os.path.splitext(filename)[0]}.pdf"', + ), + ("Cache-Control", "private, max-age=3600"), + ], + ) + + # -- conversion + cache ---------------------------------------------------- + + def _get_pdf_cached(self, content, ext): + """Return converted PDF bytes, using a checksum-keyed disk cache. + + The cache lives under the Odoo filestore so it is shared across workers + on the same host; identical documents are converted only once. All cache + I/O is best-effort: a miss or any filesystem error falls back silently to + a fresh conversion. + """ + # The extension is part of the key: the export options depend on it. + key = f"{hashlib.sha256(content).hexdigest()}-{ext}-{CACHE_KEY_VERSION}" + cache_path = self._cache_path(key) + if cache_path and os.path.exists(cache_path): + try: + with open(cache_path, "rb") as fh: + return fh.read() + except OSError: + _logger.warning( + "attachment_preview_office: cache read failed", exc_info=True + ) + + pdf_bytes = self._libreoffice_to_pdf(content, ext) + if pdf_bytes and cache_path: + try: + # Atomic publish so a concurrent reader never sees a partial file. + tmp = f"{cache_path}.{os.getpid()}.tmp" + with open(tmp, "wb") as fh: + fh.write(pdf_bytes) + os.replace(tmp, cache_path) + except OSError: + _logger.warning( + "attachment_preview_office: cache write failed", exc_info=True + ) + return pdf_bytes + + @staticmethod + def _cache_path(key): + """Return the cache file path for ``key`` under the filestore, or None.""" + try: + base = os.path.join( + tools.config.filestore(request.env.cr.dbname), + "attachment_preview_office_cache", + ) + os.makedirs(base, exist_ok=True) + return os.path.join(base, f"{key}.pdf") + except Exception: # pragma: no cover - never let caching break preview + return None + + @staticmethod + def _libreoffice_to_pdf(content, ext): + """Run LibreOffice headless conversion. Returns PDF bytes or None. + + Each invocation uses an isolated ``UserInstallation`` profile so that + concurrent conversions do not collide on the shared LibreOffice profile + lock — the classic "another instance is already running" failure that + otherwise makes this endpoint flaky under any real load. + """ + try: + with tempfile.TemporaryDirectory() as tmpdir: + src = os.path.join(tmpdir, f"source.{ext}") + with open(src, "wb") as fh: + fh.write(content) + profile = os.path.join(tmpdir, "louser") + if ext in SPREADSHEET_EXTENSIONS: + user_dir = os.path.join(profile, "user") + os.makedirs(user_dir, exist_ok=True) + xcu = os.path.join(user_dir, "registrymodifications.xcu") + with open(xcu, "w", encoding="utf-8") as fh: + fh.write(SINGLE_PAGE_SHEETS_XCU) + result = subprocess.run( + [ + "libreoffice", + f"-env:UserInstallation=file://{profile}", + "--headless", + "--convert-to", + "pdf", + "--outdir", + tmpdir, + src, + ], + timeout=CONVERT_TIMEOUT, + capture_output=True, + ) + if result.returncode != 0: + _logger.warning( + "LibreOffice conversion failed (exit %s): %s", + result.returncode, + result.stderr.decode("utf-8", errors="replace")[-500:], + ) + return None + pdf_path = os.path.join(tmpdir, "source.pdf") + if not os.path.exists(pdf_path): + return None + with open(pdf_path, "rb") as fh: + return fh.read() + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return None diff --git a/attachment_preview_office/i18n/es.po b/attachment_preview_office/i18n/es.po new file mode 100644 index 00000000000..a54b9be603e --- /dev/null +++ b/attachment_preview_office/i18n/es.po @@ -0,0 +1,29 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * attachment_preview_office +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: attachment_preview_office +#. odoo-javascript +#: code:addons/attachment_preview_office/static/src/file_viewer_patch.xml:0 +#, python-format +msgid "Generating preview…" +msgstr "Generando vista previa…" + +#. module: attachment_preview_office +#. odoo-javascript +#: code:addons/attachment_preview_office/static/src/file_viewer_patch.xml:0 +#, python-format +msgid "Large files can take several seconds." +msgstr "Los archivos grandes pueden tardar varios segundos." diff --git a/attachment_preview_office/models/__init__.py b/attachment_preview_office/models/__init__.py new file mode 100644 index 00000000000..9a5eb71871d --- /dev/null +++ b/attachment_preview_office/models/__init__.py @@ -0,0 +1 @@ +from . import ir_http diff --git a/attachment_preview_office/models/ir_http.py b/attachment_preview_office/models/ir_http.py new file mode 100644 index 00000000000..114b4ec62c0 --- /dev/null +++ b/attachment_preview_office/models/ir_http.py @@ -0,0 +1,23 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import shutil + +from odoo import models + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): + # Let the web client know whether office preview can work at all, so + # the preview button is simply not shown on servers without LibreOffice + # instead of opening a viewer that errors out with HTTP 503. + info = super().session_info() + info[ + "attachment_preview_office_available" + ] = self._attachment_preview_office_available() + return info + + @staticmethod + def _attachment_preview_office_available(): + return bool(shutil.which("libreoffice")) diff --git a/attachment_preview_office/pyproject.toml b/attachment_preview_office/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/attachment_preview_office/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/attachment_preview_office/readme/CONTRIBUTORS.md b/attachment_preview_office/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..cbe0ec793d8 --- /dev/null +++ b/attachment_preview_office/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jesús Alan Ramos Rodríguez \<\> diff --git a/attachment_preview_office/readme/DESCRIPTION.md b/attachment_preview_office/readme/DESCRIPTION.md new file mode 100644 index 00000000000..27af72fa4ea --- /dev/null +++ b/attachment_preview_office/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +Preview office documents (DOCX, XLSX, PPTX, macro-enabled DOCM, XLSM, PPTM, +legacy DOC, XLS, PPT, and ODT, ODS, ODP, ODG) directly in the browser, +including chatter attachments. + +The document is converted to PDF on the Odoo server using LibreOffice in +headless mode and rendered with Odoo's native PDF.js viewer. Nothing leaves +your server: no external viewer service (Microsoft Office Online, Google +Docs) is involved, which makes this suitable for sensitive documents. + +Converted PDFs are cached on disk (keyed by document checksum), so each +document is only converted once. The *Download* button still downloads the +original file, not the converted PDF. diff --git a/attachment_preview_office/readme/INSTALL.md b/attachment_preview_office/readme/INSTALL.md new file mode 100644 index 00000000000..e63262eac75 --- /dev/null +++ b/attachment_preview_office/readme/INSTALL.md @@ -0,0 +1,10 @@ +LibreOffice must be installed on the Odoo server and the `libreoffice` +binary must be available in the PATH of the Odoo process: + + apt-get install libreoffice + +On Odoo.sh, LibreOffice is already available by default. + +If LibreOffice is not available, the module still installs, but the +preview button is simply not shown for office files (the availability is +exposed to the web client through the session info). diff --git a/attachment_preview_office/readme/USAGE.md b/attachment_preview_office/readme/USAGE.md new file mode 100644 index 00000000000..499c72ccc83 --- /dev/null +++ b/attachment_preview_office/readme/USAGE.md @@ -0,0 +1,4 @@ +1. Attach an office file (e.g. an `.xlsx`) to any record with a chatter, or + send it in a message. +2. Click on the attachment thumbnail. +3. The file opens in the standard Odoo file viewer, rendered as PDF. diff --git a/attachment_preview_office/static/description/index.html b/attachment_preview_office/static/description/index.html new file mode 100644 index 00000000000..0d1591eca86 --- /dev/null +++ b/attachment_preview_office/static/description/index.html @@ -0,0 +1,456 @@ + + + + + +Attachment Preview Office + + + +
+

Attachment Preview Office

+ + +

Beta License: AGPL-3 OCA/knowledge Translate me on Weblate Try me on Runboat

+

Preview office documents (DOCX, XLSX, PPTX, macro-enabled DOCM, XLSM, +PPTM, legacy DOC, XLS, PPT, and ODT, ODS, ODP, ODG) directly in the +browser, including chatter attachments.

+

The document is converted to PDF on the Odoo server using LibreOffice in +headless mode and rendered with Odoo’s native PDF.js viewer. Nothing +leaves your server: no external viewer service (Microsoft Office Online, +Google Docs) is involved, which makes this suitable for sensitive +documents.

+

Converted PDFs are cached on disk (keyed by document checksum), so each +document is only converted once. The Download button still downloads +the original file, not the converted PDF.

+

Table of contents

+ +
+

Installation

+

LibreOffice must be installed on the Odoo server and the libreoffice +binary must be available in the PATH of the Odoo process:

+
+apt-get install libreoffice
+
+

On Odoo.sh, LibreOffice is already available by default.

+

If LibreOffice is not available, the module still installs, but the +preview button is simply not shown for office files (the availability is +exposed to the web client through the session info).

+
+
+

Usage

+
    +
  1. Attach an office file (e.g. an .xlsx) to any record with a +chatter, or send it in a message.
  2. +
  3. Click on the attachment thumbnail.
  4. +
  5. The file opens in the standard Odoo file viewer, rendered as PDF.
  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

+
    +
  • Jarsa
  • +
+
+
+

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

+

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

+
+
+
+ + diff --git a/attachment_preview_office/static/src/attachment_model_patch.esm.js b/attachment_preview_office/static/src/attachment_model_patch.esm.js new file mode 100644 index 00000000000..5c9201dea77 --- /dev/null +++ b/attachment_preview_office/static/src/attachment_model_patch.esm.js @@ -0,0 +1,66 @@ +/** @odoo-module **/ +/* Copyright 2026 Jarsa + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +import {Attachment} from "@mail/core/common/attachment_model"; +import {patch} from "@web/core/utils/patch"; +import {session} from "@web/session"; +import {url} from "@web/core/utils/urls"; + +export const OFFICE_EXTENSIONS = [ + "doc", + "docm", + "docx", + "odg", + "odp", + "ods", + "odt", + "ppt", + "pptm", + "pptx", + "xls", + "xlsm", + "xlsx", +]; + +patch(Attachment.prototype, { + get isOffice() { + return ( + Boolean(session.attachment_preview_office_available) && + Boolean(this.id) && + !this.uploading && + OFFICE_EXTENSIONS.includes((this.extension || "").toLowerCase()) + ); + }, + /** + * Office files are previewed as PDF (server-side LibreOffice conversion), + * so the native PDF.js viewer branch of FileViewer renders them. + * + * @override + */ + get isPdf() { + return super.isPdf || this.isOffice; + }, + /** + * Raw URL of the server-side PDF conversion of this office attachment. + */ + get officePdfUrl() { + return url("/attachment_preview_office/to_pdf", { + model: "ir.attachment", + field: "datas", + id: this.id, + filename: this.displayName || `file.${this.extension}`, + }); + }, + /** + * @override + */ + get defaultSource() { + if (!this.isOffice) { + return super.defaultSource; + } + return `/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent( + this.officePdfUrl + )}#pagemode=none`; + }, +}); diff --git a/attachment_preview_office/static/src/file_viewer_patch.esm.js b/attachment_preview_office/static/src/file_viewer_patch.esm.js new file mode 100644 index 00000000000..996208cb9aa --- /dev/null +++ b/attachment_preview_office/static/src/file_viewer_patch.esm.js @@ -0,0 +1,40 @@ +/** @odoo-module **/ +/* Copyright 2026 Jarsa + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +import {FileViewer} from "@web/core/file_viewer/file_viewer"; +import {patch} from "@web/core/utils/patch"; + +patch(FileViewer.prototype, { + setup() { + super.setup(); + this._prepareOfficePreview(); + }, + /** + * @override + */ + activateFile(index) { + super.activateFile(index); + this._prepareOfficePreview(); + }, + /** + * The PDF.js iframe gives no feedback while the server converts an office + * document (which can take a while for big files). Warm the server-side + * conversion cache with a plain fetch, showing a spinner meanwhile; once + * it resolves the iframe is mounted and loads instantly from the cache. + */ + _prepareOfficePreview() { + const file = this.state.file; + if (!file.isOffice) { + this.state.officeLoading = false; + return; + } + this.state.officeLoading = true; + const done = () => { + if (this.state.file === file) { + this.state.officeLoading = false; + } + }; + fetch(file.officePdfUrl).then(done, done); + }, +}); diff --git a/attachment_preview_office/static/src/file_viewer_patch.xml b/attachment_preview_office/static/src/file_viewer_patch.xml new file mode 100644 index 00000000000..6b5131e390e --- /dev/null +++ b/attachment_preview_office/static/src/file_viewer_patch.xml @@ -0,0 +1,23 @@ + + + + + state.file.isPdf and !state.officeLoading + + +
+ + Generating preview… + Large files can take several seconds. +
+
+
+
diff --git a/attachment_preview_office/tests/__init__.py b/attachment_preview_office/tests/__init__.py new file mode 100644 index 00000000000..0daa2f9af5f --- /dev/null +++ b/attachment_preview_office/tests/__init__.py @@ -0,0 +1 @@ +from . import test_attachment_preview_office diff --git a/attachment_preview_office/tests/test_attachment_preview_office.py b/attachment_preview_office/tests/test_attachment_preview_office.py new file mode 100644 index 00000000000..22bbeb6eb2b --- /dev/null +++ b/attachment_preview_office/tests/test_attachment_preview_office.py @@ -0,0 +1,271 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import os +import subprocess +from unittest.mock import MagicMock, patch + +from werkzeug.wrappers import Response + +from odoo.addons.base.tests.common import BaseCommon + +from ..controllers.main import AttachmentPreviewOfficeController + + +class TestOfficeToPdfController(BaseCommon): + """Unit tests for the LibreOffice conversion controller.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.docx_content = base64.b64encode(b"fake docx content") + cls.attachment = cls.env["ir.attachment"].create( + {"name": "report.docx", "datas": cls.docx_content} + ) + cls.controller = AttachmentPreviewOfficeController() + + def _make_fake_completed_process(self, returncode=0): + result = subprocess.CompletedProcess(args=[], returncode=returncode) + result.stdout = b"" + result.stderr = b"" + return result + + def test_libreoffice_to_pdf_success(self): + """Returns PDF bytes when LibreOffice succeeds.""" + fake_pdf = b"%PDF-1.4 fake" + with ( + patch( + "odoo.addons.attachment_preview_office.controllers.main.subprocess.run" + ) as mock_run, + patch( + "odoo.addons.attachment_preview_office.controllers.main.open", + create=True, + ) as mock_open, + patch( + "odoo.addons.attachment_preview_office.controllers.main.os.path.exists", + return_value=True, + ), + ): + mock_run.return_value = self._make_fake_completed_process(returncode=0) + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = lambda s, *a: False + mock_open.return_value.read = lambda: fake_pdf + mock_open.return_value.write = lambda data: None + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNotNone(result) + + def test_libreoffice_not_installed_returns_none(self): + """Returns None when LibreOffice binary is not found.""" + with patch( + "odoo.addons.attachment_preview_office.controllers.main.subprocess.run", + side_effect=FileNotFoundError, + ): + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNone(result) + + def test_libreoffice_timeout_returns_none(self): + """Returns None on conversion timeout.""" + with patch( + "odoo.addons.attachment_preview_office.controllers.main.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="libreoffice", timeout=30), + ): + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNone(result) + + def test_libreoffice_nonzero_exit_returns_none(self): + """Returns None when LibreOffice exits with non-zero code.""" + with patch( + "odoo.addons.attachment_preview_office.controllers.main.subprocess.run", + return_value=self._make_fake_completed_process(returncode=1), + ): + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNone(result) + + # -- route handler (office_to_pdf) branch coverage ------------------------- + + def _call_route(self, **over): + """Invoke office_to_pdf with ``request`` patched to the test env; + return the resulting werkzeug Response.""" + + def make_response(data, status=200, headers=None): + return Response(data, status=status, headers=headers or []) + + fake_request = MagicMock() + fake_request.env = self.env + fake_request.make_response.side_effect = make_response + params = { + "model": "ir.attachment", + "field": "datas", + "id": self.attachment.id, + "filename": "report.docx", + } + params.update(over) + with patch( + "odoo.addons.attachment_preview_office.controllers.main.request", + fake_request, + ): + return self.controller.office_to_pdf(**params) + + def test_route_bad_id(self): + """A non-integer id yields HTTP 400.""" + self.assertEqual(self._call_route(id="not-an-int").status_code, 400) + + def test_route_no_content(self): + """A record with no binary content yields HTTP 404.""" + empty = self.env["ir.attachment"].create({"name": "empty.docx"}) + self.assertEqual(self._call_route(id=empty.id).status_code, 404) + + def test_route_unsupported_extension(self): + """A non-Office extension yields HTTP 415.""" + self.assertEqual(self._call_route(filename="notes.txt").status_code, 415) + + def test_route_libreoffice_unavailable(self): + """When the conversion pipeline yields nothing (LibreOffice missing + and no cached copy), the route yields HTTP 503. ``_get_pdf_cached`` is + mocked as a whole: mocking only ``_libreoffice_to_pdf`` would be flaky, + a previous test (or run) may have left the document in the disk cache.""" + with patch( + "odoo.addons.attachment_preview_office.controllers.main." + "AttachmentPreviewOfficeController._get_pdf_cached", + return_value=None, + ): + self.assertEqual(self._call_route().status_code, 503) + + def test_route_success(self): + """A successful conversion streams the PDF with HTTP 200.""" + with patch( + "odoo.addons.attachment_preview_office.controllers.main." + "AttachmentPreviewOfficeController._libreoffice_to_pdf", + return_value=b"%PDF-1.4 ok", + ): + res = self._call_route() + self.assertEqual(res.status_code, 200) + self.assertEqual(res.get_data(), b"%PDF-1.4 ok") + self.assertEqual(res.headers["Content-Type"], "application/pdf") + + def test_route_odf_extension_accepted(self): + """ODF formats (e.g. .ods) are converted, not just OOXML.""" + ods = self.env["ir.attachment"].create( + {"name": "sheet.ods", "datas": base64.b64encode(b"fake ods")} + ) + with patch( + "odoo.addons.attachment_preview_office.controllers.main." + "AttachmentPreviewOfficeController._libreoffice_to_pdf", + return_value=b"%PDF-1.4 ok", + ): + res = self._call_route(id=ods.id, filename="sheet.ods") + self.assertEqual(res.status_code, 200) + + def test_route_macro_enabled_extension_accepted(self): + """Macro-enabled OOXML formats (e.g. .xlsm) are converted too.""" + xlsm = self.env["ir.attachment"].create( + {"name": "macros.xlsm", "datas": base64.b64encode(b"fake xlsm")} + ) + with patch( + "odoo.addons.attachment_preview_office.controllers.main." + "AttachmentPreviewOfficeController._libreoffice_to_pdf", + return_value=b"%PDF-1.4 ok", + ): + res = self._call_route(id=xlsm.id, filename="macros.xlsm") + self.assertEqual(res.status_code, 200) + + def test_route_non_binary_field_rejected(self): + """A non-binary field name yields HTTP 400 (no arbitrary field read).""" + self.assertEqual(self._call_route(field="name").status_code, 400) + + def test_route_unknown_field_rejected(self): + """An unknown field name yields HTTP 400.""" + self.assertEqual(self._call_route(field="does_not_exist").status_code, 400) + + def test_route_oversized_rejected(self): + """Documents over the size cap are rejected with HTTP 413.""" + from ..controllers import main as ctrl + + big = self.env["ir.attachment"].create( + { + "name": "big.docx", + "datas": base64.b64encode(b"x" * 16), + } + ) + with patch.object(ctrl, "MAX_CONTENT_BYTES", 8): + res = self._call_route(id=big.id, filename="big.docx") + self.assertEqual(res.status_code, 413) + + def test_office_preview_available_flag(self): + """The session flag reflects whether the libreoffice binary exists.""" + IrHttp = self.env["ir.http"] + with patch( + "odoo.addons.attachment_preview_office.models.ir_http.shutil.which", + return_value="/usr/bin/libreoffice", + ): + self.assertTrue(IrHttp._attachment_preview_office_available()) + with patch( + "odoo.addons.attachment_preview_office.models.ir_http.shutil.which", + return_value=None, + ): + self.assertFalse(IrHttp._attachment_preview_office_available()) + + def test_libreoffice_conversion_failure_is_logged(self): + """A non-zero LibreOffice exit logs the captured stderr.""" + failed = self._make_fake_completed_process(returncode=1) + failed.stderr = b"soffice: some conversion error" + with ( + patch( + "odoo.addons.attachment_preview_office.controllers.main." + "subprocess.run", + return_value=failed, + ), + self.assertLogs( + "odoo.addons.attachment_preview_office.controllers.main", + level="WARNING", + ) as logs, + ): + self.assertIsNone(self.controller._libreoffice_to_pdf(b"content", "docx")) + self.assertIn("some conversion error", logs.output[0]) + + def test_spreadsheets_use_single_page_sheets_profile(self): + """Spreadsheets seed SinglePageSheets in the LibreOffice profile + registry (CLI JSON filter options need LO >= 7.4); other office + formats do not.""" + captured = {} + + def fake_run(cmd, **kwargs): + profile = next( + str(a) for a in cmd if str(a).startswith("-env:UserInstallation=") + ).replace("-env:UserInstallation=file://", "") + xcu = os.path.join(profile, "user", "registrymodifications.xcu") + captured["has_xcu"] = os.path.exists(xcu) + if captured["has_xcu"]: + with open(xcu, encoding="utf-8") as fh: + captured["xcu"] = fh.read() + return self._make_fake_completed_process(returncode=1) + + with patch( + "odoo.addons.attachment_preview_office.controllers.main.subprocess.run", + side_effect=fake_run, + ): + self.controller._libreoffice_to_pdf(b"content", "xlsx") + self.assertTrue(captured["has_xcu"]) + self.assertIn("SinglePageSheets", captured["xcu"]) + self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertFalse(captured["has_xcu"]) + + def test_libreoffice_uses_isolated_profile(self): + """Conversion passes an isolated UserInstallation profile to avoid the + shared LibreOffice profile-lock collision under concurrency.""" + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return self._make_fake_completed_process(returncode=1) + + with patch( + "odoo.addons.attachment_preview_office.controllers.main.subprocess.run", + side_effect=fake_run, + ): + self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertTrue( + any(str(a).startswith("-env:UserInstallation=") for a in captured["cmd"]), + "LibreOffice must run with an isolated UserInstallation profile", + )