From b57e6e3bec383c185dd5ddf44f989199770582f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Alan=20Ramos=20Rodr=C3=ADguez?= Date: Wed, 12 Aug 2026 14:06:54 -0600 Subject: [PATCH] [ADD] attachment_preview_office: preview Office attachments via LibreOffice Convert DOCX/XLSX/PPTX (and legacy/ODF) attachments to PDF server-side with LibreOffice headless and render them with Odoo's native PDF.js viewer, including chatter attachments. No external viewer service is involved, so documents never leave the server. Converted PDFs are cached on disk keyed by content checksum. Conversion endpoint logic based on OCA/knowledge#603 (18.0). --- attachment_preview_office/README.rst | 112 +++++ attachment_preview_office/__init__.py | 2 + attachment_preview_office/__manifest__.py | 21 + .../controllers/__init__.py | 1 + attachment_preview_office/controllers/main.py | 234 +++++++++ attachment_preview_office/i18n/es.po | 29 ++ attachment_preview_office/models/__init__.py | 1 + attachment_preview_office/models/ir_http.py | 23 + attachment_preview_office/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 12 + attachment_preview_office/readme/INSTALL.md | 10 + attachment_preview_office/readme/USAGE.md | 4 + .../static/description/index.html | 456 ++++++++++++++++++ .../static/src/attachment_model_patch.esm.js | 66 +++ .../static/src/file_viewer_patch.esm.js | 40 ++ .../static/src/file_viewer_patch.xml | 23 + attachment_preview_office/tests/__init__.py | 1 + .../tests/test_attachment_preview_office.py | 271 +++++++++++ 19 files changed, 1310 insertions(+) create mode 100644 attachment_preview_office/README.rst create mode 100644 attachment_preview_office/__init__.py create mode 100644 attachment_preview_office/__manifest__.py create mode 100644 attachment_preview_office/controllers/__init__.py create mode 100644 attachment_preview_office/controllers/main.py create mode 100644 attachment_preview_office/i18n/es.po create mode 100644 attachment_preview_office/models/__init__.py create mode 100644 attachment_preview_office/models/ir_http.py create mode 100644 attachment_preview_office/pyproject.toml create mode 100644 attachment_preview_office/readme/CONTRIBUTORS.md create mode 100644 attachment_preview_office/readme/DESCRIPTION.md create mode 100644 attachment_preview_office/readme/INSTALL.md create mode 100644 attachment_preview_office/readme/USAGE.md create mode 100644 attachment_preview_office/static/description/index.html create mode 100644 attachment_preview_office/static/src/attachment_model_patch.esm.js create mode 100644 attachment_preview_office/static/src/file_viewer_patch.esm.js create mode 100644 attachment_preview_office/static/src/file_viewer_patch.xml create mode 100644 attachment_preview_office/tests/__init__.py create mode 100644 attachment_preview_office/tests/test_attachment_preview_office.py 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", + )