diff --git a/document_page_distribution_download_log/README.rst b/document_page_distribution_download_log/README.rst new file mode 100644 index 000000000..50ff7eda1 --- /dev/null +++ b/document_page_distribution_download_log/README.rst @@ -0,0 +1,143 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================================= +Document Page Distribution Download Log +======================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:faa911e0ce3b5f9838401c584a990300ef36a04bde8a269c6de14c0091cc68f7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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-NuoBiT%2Fodoo--addons-lightgray.png?logo=github + :target: https://github.com/NuoBiT/odoo-addons/tree/14.0/document_page_distribution_download_log + :alt: NuoBiT/odoo-addons + +|badge1| |badge2| |badge3| + +This module records **per-recipient download evidence** for distributed +document page versions. It is the download-log counterpart of +``document_page_distribution``: while that module owns the person + version +coverage (``document.page.history.recipient``), this module logs each real +access/download of a version's PDF by a covered recipient, and exposes the +download coverage as quality-audit evidence. + +For every ``(version, recipient)`` coverage line it adds a download summary +(downloaded, first/last download date, download count) and a detail of every +real access. A download is only ever recorded against an **existing** recipient +line of the same version and person; this module never creates coverage nor +invents recipients (that remains the responsibility of +``document_page_distribution``). + +**How it works** + +* Saving a document page stores its content as a new version + (``document.page.history``); the page itself always shows the head version. +* Each time a version's content is saved, the single document link in it is + rewritten from the raw ``/web/content/`` form to a version-aware + download route that carries the version id. +* Opening that link checks the user's read access to the page, records the + download against the recipient's coverage line of that exact version, and + redirects to the standard ``/web/content`` file download. + +**Audit contract** + +The download log is **append-only**. Evidence rows are written only by the +system, through the download controller, on the recipient's behalf; the access +rights are **read-only for every role, the Document Manager included** +(``perm_read`` only). No user can create, edit or delete a download record, so +the trail cannot be tampered with — which is the point of a compliance log. +Visibility follows the document's own Security groups: a user sees the download +evidence of the documents they are allowed to read, and a Document Manager sees +all of it. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +.. important:: + + Install this module **before** the controlled document versions you want to + track are authored. The version-aware download link is written into a + version's content the moment that content is **saved with this module + installed**. A version whose content was saved *before* the install keeps its + raw ``/web/content`` link and is therefore **not tracked** until its next + revision. + +#. Install this module on top of *Document Page Distribution*: it adds the + download evidence on top of the person + version coverage. +#. Make sure your controlled documents have a Security group and have been + distributed at least once (which creates the coverage lines). +#. Edit a document page and insert a single document link (one file) in its + content. Saving content with more than one document link is rejected. + Insert the file with the page editor, which binds it to the page: a file + the recipient cannot read (e.g. uploaded outside the editor and bound to + nothing) answers 404 and no download evidence is recorded. +#. On save, the document link is rewritten to a controlled, version-aware + download URL. +#. When a covered recipient opens the link, the access is logged against their + coverage line for that exact version. Opening it again adds a new evidence + row and updates the last download date, keeping the first one. Opening the + link of an old version is always imputed to that old version. +#. If a user with read access but without a coverage line opens the link, the + file is served but no evidence is recorded (coverage stays owned by the + distribution module). +#. In the *Distribution* tab of the document, each recipient row shows whether + they downloaded the current version, with a button opening the full download + detail (date, user, file), next to the send detail. + +.. note:: + + Uninstalling this module does **not** restore the original ``/web/content`` + links in versions saved while it was installed: their stored content keeps + the rewritten tracking links, which stop working once the module (and its + download route) is gone. Re-insert the document link manually on the next + revision if you uninstall. + +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 +~~~~~~~ + +* NuoBiT Solutions +* S.L. + +Contributors +~~~~~~~~~~~~ + +* `NuoBiT `_: + + * Deniz Gallo + +Maintainers +~~~~~~~~~~~ + +This module is part of the `NuoBiT/odoo-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/document_page_distribution_download_log/__init__.py b/document_page_distribution_download_log/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/document_page_distribution_download_log/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/document_page_distribution_download_log/__manifest__.py b/document_page_distribution_download_log/__manifest__.py new file mode 100644 index 000000000..35b45ff01 --- /dev/null +++ b/document_page_distribution_download_log/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Document Page Distribution Download Log", + "summary": "Per-recipient download evidence log for distributed document " + "page versions.", + "version": "14.0.1.0.0", + "category": "Knowledge Management", + "author": "NuoBiT Solutions, S.L.", + "website": "https://github.com/nuobit/odoo-addons", + "license": "AGPL-3", + "depends": [ + "document_page_distribution", + ], + "data": [ + "security/ir.model.access.csv", + "security/document_page_distribution_download_log_security.xml", + "views/document_page_history_recipient_views.xml", + ], +} diff --git a/document_page_distribution_download_log/controllers/__init__.py b/document_page_distribution_download_log/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/document_page_distribution_download_log/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/document_page_distribution_download_log/controllers/main.py b/document_page_distribution_download_log/controllers/main.py new file mode 100644 index 000000000..caf4efec7 --- /dev/null +++ b/document_page_distribution_download_log/controllers/main.py @@ -0,0 +1,60 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from werkzeug.exceptions import NotFound +from werkzeug.utils import redirect + +from odoo import http +from odoo.exceptions import AccessError +from odoo.http import request + + +class DocumentPageDistributionDownloadLogController(http.Controller): + @http.route( + "/document_page_distribution_download_log/download" + "//", + type="http", + auth="user", + methods=["GET"], + ) + def download_version(self, history_id, attachment_id, **kwargs): + # resolve-then-authorize: sudo only resolves the version; the access + # gate is the page read check below, run as the requesting user. + # Missing and forbidden both answer 404 so the route does not reveal + # whether a version exists. + history = ( + request.env["document.page.history"].sudo().browse(history_id).exists() + ) + if not history: + raise NotFound() + page = request.env["document.page"].browse(history.page_id.id) + try: + page.check_access_rights("read") + page.check_access_rule("read") + except AccessError: + raise NotFound() from None + if not history._download_attachment_is_tracked(attachment_id): + raise NotFound() + # the version content may still link an attachment deleted afterwards + attachment = request.env["ir.attachment"].browse(attachment_id).exists() + if not attachment: + raise NotFound() + # mirror /web/content's own access check BEFORE logging: a download + # row is evidence a covered user reached a valid, readable file link + # (never log one the redirect would deny with 403, e.g. an attachment + # not bound to the page); serving the binary and its cache semantics + # stay in /web/content. + try: + attachment.check("read") + except AccessError: + raise NotFound() from None + http_request = request.httprequest + history._log_recipient_download( + attachment_id, + user=request.env.user, + ip_address=http_request.remote_addr, + user_agent=http_request.user_agent.string + if http_request.user_agent + else False, + ) + return redirect("/web/content/%s?download=true" % attachment_id, code=303) diff --git a/document_page_distribution_download_log/i18n/ca.po b/document_page_distribution_download_log/i18n/ca.po new file mode 100644 index 000000000..1dd5ce1e9 --- /dev/null +++ b/document_page_distribution_download_log/i18n/ca.po @@ -0,0 +1,168 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * document_page_distribution_download_log +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-08 00:00+0000\n" +"PO-Revision-Date: 2026-06-08 00:00+0000\n" +"Last-Translator: Deniz Gallo \n" +"Language-Team: Catalan\n" +"Language: ca\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: document_page_distribution_download_log +#: code:addons/document_page_distribution_download_log/models/document_page_history.py:0 +#, python-format +msgid "A document version can contain at most one downloadable document link." +msgstr "Una versió del document pot contenir com a màxim un enllaç de document descarregable." + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__attachment_id +msgid "Attachment" +msgstr "Adjunt" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__company_id +msgid "Company" +msgstr "Empresa" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__create_uid +msgid "Created by" +msgstr "Creat per" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__create_date +msgid "Created on" +msgstr "Creat el" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history__display_name +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__display_name +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__display_name +msgid "Display Name" +msgstr "Nom mostrat" + +#. module: document_page_distribution_download_log +#: model:ir.model,name:document_page_distribution_download_log.model_document_page_history_recipient_download +msgid "Document Page Distribution Download" +msgstr "Descàrrega de distribució de pàgina de document" + +#. module: document_page_distribution_download_log +#: model:ir.model,name:document_page_distribution_download_log.model_document_page_history_recipient +msgid "Document Page Distribution Recipient" +msgstr "Destinatari de distribució de pàgina de document" + +#. module: document_page_distribution_download_log +#: model:ir.model,name:document_page_distribution_download_log.model_document_page_history +msgid "Document Page History" +msgstr "Historial de pàgina de document" + +#. module: document_page_distribution_download_log +#: model_terms:ir.ui.view,arch_db:document_page_distribution_download_log.document_page_history_recipient_download_form +msgid "Download" +msgstr "Descàrrega" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__download_count +msgid "Download Count" +msgstr "Nombre de descàrregues" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__download_date +msgid "Download Date" +msgstr "Data de descàrrega" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__downloaded +msgid "Downloaded" +msgstr "Descarregat" + +#. module: document_page_distribution_download_log +#: code:addons/document_page_distribution_download_log/models/document_page_history_recipient.py:0 +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__download_ids +#: model_terms:ir.ui.view,arch_db:document_page_distribution_download_log.view_wiki_form_distribution_download_log +#: model_terms:ir.ui.view,arch_db:document_page_distribution_download_log.view_wiki_history_form_distribution_download_log +#, python-format +msgid "Downloads" +msgstr "Descàrregues" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__first_download_date +msgid "First Download Date" +msgstr "Data de primera descàrrega" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__history_id +msgid "History" +msgstr "Historial" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history__id +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__id +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__id +msgid "ID" +msgstr "ID" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__ip_address +msgid "IP Address" +msgstr "Adreça IP" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,help:document_page_distribution_download_log.field_document_page_history_recipient_download__company_id +msgid "If set, page is accessible only from this company" +msgstr "Si es configura, només es pot accedir a la pàgina des d'aquesta empresa" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__last_download_date +msgid "Last Download Date" +msgstr "Data de darrera descàrrega" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history____last_update +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient____last_update +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download____last_update +msgid "Last Modified on" +msgstr "Última modificació el" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__write_uid +msgid "Last Updated by" +msgstr "Última actualització per" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__write_date +msgid "Last Updated on" +msgstr "Última actualització el" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__document_page_id +msgid "Page" +msgstr "Pàgina" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__partner_id +msgid "Partner" +msgstr "Contacte" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__recipient_id +msgid "Recipient" +msgstr "Destinatari" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__user_id +msgid "User" +msgstr "Usuari" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__user_agent +msgid "User Agent" +msgstr "Agent d'usuari" diff --git a/document_page_distribution_download_log/i18n/es.po b/document_page_distribution_download_log/i18n/es.po new file mode 100644 index 000000000..a0402b743 --- /dev/null +++ b/document_page_distribution_download_log/i18n/es.po @@ -0,0 +1,168 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * document_page_distribution_download_log +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-08 00:00+0000\n" +"PO-Revision-Date: 2026-06-08 00:00+0000\n" +"Last-Translator: Deniz Gallo \n" +"Language-Team: Spanish\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: document_page_distribution_download_log +#: code:addons/document_page_distribution_download_log/models/document_page_history.py:0 +#, python-format +msgid "A document version can contain at most one downloadable document link." +msgstr "Una versión del documento puede contener como máximo un enlace de documento descargable." + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__attachment_id +msgid "Attachment" +msgstr "Adjunto" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__company_id +msgid "Company" +msgstr "Compañía" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history__display_name +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__display_name +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: document_page_distribution_download_log +#: model:ir.model,name:document_page_distribution_download_log.model_document_page_history_recipient_download +msgid "Document Page Distribution Download" +msgstr "Descarga de distribución de página de documento" + +#. module: document_page_distribution_download_log +#: model:ir.model,name:document_page_distribution_download_log.model_document_page_history_recipient +msgid "Document Page Distribution Recipient" +msgstr "Destinatario de distribución de página de documento" + +#. module: document_page_distribution_download_log +#: model:ir.model,name:document_page_distribution_download_log.model_document_page_history +msgid "Document Page History" +msgstr "Historial de página de documento" + +#. module: document_page_distribution_download_log +#: model_terms:ir.ui.view,arch_db:document_page_distribution_download_log.document_page_history_recipient_download_form +msgid "Download" +msgstr "Descarga" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__download_count +msgid "Download Count" +msgstr "Número de descargas" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__download_date +msgid "Download Date" +msgstr "Fecha de descarga" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__downloaded +msgid "Downloaded" +msgstr "Descargado" + +#. module: document_page_distribution_download_log +#: code:addons/document_page_distribution_download_log/models/document_page_history_recipient.py:0 +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__download_ids +#: model_terms:ir.ui.view,arch_db:document_page_distribution_download_log.view_wiki_form_distribution_download_log +#: model_terms:ir.ui.view,arch_db:document_page_distribution_download_log.view_wiki_history_form_distribution_download_log +#, python-format +msgid "Downloads" +msgstr "Descargas" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__first_download_date +msgid "First Download Date" +msgstr "Fecha de primera descarga" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__history_id +msgid "History" +msgstr "Historial" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history__id +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__id +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__id +msgid "ID" +msgstr "ID" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__ip_address +msgid "IP Address" +msgstr "Dirección IP" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,help:document_page_distribution_download_log.field_document_page_history_recipient_download__company_id +msgid "If set, page is accessible only from this company" +msgstr "Si se configura, solo se puede acceder a la página desde esta empresa" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient__last_download_date +msgid "Last Download Date" +msgstr "Fecha de última descarga" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history____last_update +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient____last_update +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download____last_update +msgid "Last Modified on" +msgstr "Última modificación el" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__document_page_id +msgid "Page" +msgstr "Página" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__partner_id +msgid "Partner" +msgstr "Contacto" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__recipient_id +msgid "Recipient" +msgstr "Destinatario" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__user_id +msgid "User" +msgstr "Usuario" + +#. module: document_page_distribution_download_log +#: model:ir.model.fields,field_description:document_page_distribution_download_log.field_document_page_history_recipient_download__user_agent +msgid "User Agent" +msgstr "Agente de usuario" diff --git a/document_page_distribution_download_log/models/__init__.py b/document_page_distribution_download_log/models/__init__.py new file mode 100644 index 000000000..a0a5cac3f --- /dev/null +++ b/document_page_distribution_download_log/models/__init__.py @@ -0,0 +1,3 @@ +from . import document_page_history +from . import document_page_history_recipient +from . import document_page_history_recipient_download diff --git a/document_page_distribution_download_log/models/document_page_history.py b/document_page_distribution_download_log/models/document_page_history.py new file mode 100644 index 000000000..0b742997e --- /dev/null +++ b/document_page_distribution_download_log/models/document_page_history.py @@ -0,0 +1,169 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from lxml import html as lxml_html + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +WEB_CONTENT_PREFIX = "/web/content/" +TRACKING_ROUTE = "/document_page_distribution_download_log/download" + + +class DocumentPageHistory(models.Model): + _inherit = "document.page.history" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + # rewrite AFTER create: the tracking URL embeds the record id, which + # does not exist before the INSERT (cannot be a vals transform) + for record in records: + record._apply_download_link_tracking() + return records + + def write(self, vals): + res = super().write(vals) + if "content" in vals: + for record in self: + record._apply_download_link_tracking() + return res + + def _apply_download_link_tracking(self): + self.ensure_one() + content = self.content or "" + new_content = self._rewrite_download_links(content) + if new_content != content: + # plain self.write() would re-enter the write override above and + # recurse; call the base write to persist the rewritten content + super(DocumentPageHistory, self).write({"content": new_content}) + self._bind_tracked_attachment() + + def _bind_tracked_attachment(self): + self.ensure_one() + if not self.page_id: + return + for attachment_id in self._tracked_attachment_ids(self.content or ""): + attachment = self.env["ir.attachment"].sudo().browse(attachment_id) + if ( + attachment.exists() + and attachment.res_model == "document.page" + and not attachment.res_id + and attachment.create_uid.id == self.env.uid + ): + attachment.res_id = self.page_id.id + + @staticmethod + def _leading_int(segment): + digits = "" + for char in segment: + if not char.isdigit(): + break + digits += char + return int(digits) if digits else False + + def _download_link_attachment_id(self, href): + """Attachment id of a ``/web/content`` document link, or ``False``. + + Recognises the URL shapes Odoo emits: ``/web/content/42``, + ``/web/content/42?download=true``, the slugged + ``/web/content/42-name.pdf`` and ``/web/content/ir.attachment/42/datas``, + plus already-tracked controller links. Inline images + (``/web/image/...``) are not matched. URL parsing only, no regex; the + PDF restriction is applied separately in :meth:`_is_trackable_pdf`. + """ + path = (href or "").split("?", 1)[0] + if path.startswith(TRACKING_ROUTE + "/"): + parts = path[len(TRACKING_ROUTE) + 1 :].split("/") + if len(parts) >= 2 and parts[1].isdigit(): + return int(parts[1]) + return False + if path.startswith(WEB_CONTENT_PREFIX): + rest = path[len(WEB_CONTENT_PREFIX) :] + if rest.startswith("ir.attachment/"): + rest = rest[len("ir.attachment/") :] + return self._leading_int(rest.split("/", 1)[0]) + return False + + def _is_trackable_pdf(self, attachment_id): + if not attachment_id: + return False + attachment = self.env["ir.attachment"].sudo().browse(attachment_id) + return attachment.exists() and attachment.mimetype == "application/pdf" + + def _tracked_attachment_ids(self, content): + if not content or " 1: + raise ValidationError( + _( + "A document version can contain at most one downloadable " + "document link." + ) + ) + if not trackable: + return content + anchor = trackable[0] + href = anchor.get("href") or "" + attachment_id = self._download_link_attachment_id(href) + expected = "%s/%s/%s" % (TRACKING_ROUTE, self.id, attachment_id) + if href.split("?", 1)[0] == expected: + return content + anchor.set("href", expected) + # serialize text + children only: create_parent wrapped the content + # in an artificial
that must not be saved into the document + return (fragment.text or "") + "".join( + lxml_html.tostring(child, encoding="unicode") for child in fragment + ) + + def _download_attachment_is_tracked(self, attachment_id): + self.ensure_one() + return attachment_id in self._tracked_attachment_ids(self.content or "") + + def _log_recipient_download( + self, attachment_id, user=None, ip_address=None, user_agent=None + ): + self.ensure_one() + user = user or self.env.user + download_model = self.env["document.page.history.recipient.download"] + partner = user.partner_id + recipient = self.env["document.page.history.recipient"].search( + [("history_id", "=", self.id), ("partner_id", "=", partner.id)], + limit=1, + ) + if not recipient: + return download_model + # sudo on create only: the download log is read-only at ACL level + # (1,0,0,0) -- rows are audit evidence, never user-writable -- so the + # system appends them on the user's behalf. + return download_model.sudo().create( + { + "recipient_id": recipient.id, + "user_id": user.id, + "partner_id": partner.id, + "attachment_id": attachment_id, + "download_date": fields.Datetime.now(), + "ip_address": ip_address, + "user_agent": user_agent, + } + ) diff --git a/document_page_distribution_download_log/models/document_page_history_recipient.py b/document_page_distribution_download_log/models/document_page_history_recipient.py new file mode 100644 index 000000000..9c284f310 --- /dev/null +++ b/document_page_distribution_download_log/models/document_page_history_recipient.py @@ -0,0 +1,50 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class DocumentPageHistoryRecipient(models.Model): + _inherit = "document.page.history.recipient" + + download_ids = fields.One2many( + "document.page.history.recipient.download", + "recipient_id", + string="Downloads", + ) + downloaded = fields.Boolean( + compute="_compute_download_info", + store=True, + ) + download_count = fields.Integer( + compute="_compute_download_info", + store=True, + ) + first_download_date = fields.Datetime( + compute="_compute_download_info", + store=True, + ) + last_download_date = fields.Datetime( + compute="_compute_download_info", + store=True, + ) + + @api.depends("download_ids", "download_ids.download_date") + def _compute_download_info(self): + for recipient in self: + downloads = recipient.download_ids.sorted("download_date") + recipient.download_count = len(downloads) + recipient.downloaded = bool(downloads) + recipient.first_download_date = downloads[:1].download_date or False + recipient.last_download_date = downloads[-1:].download_date or False + + def action_open_downloads(self): + self.ensure_one() + return { + "name": _("Downloads"), + "type": "ir.actions.act_window", + "res_model": "document.page.history.recipient.download", + "view_mode": "tree,form", + "domain": [("recipient_id", "=", self.id)], + "target": "new", + } diff --git a/document_page_distribution_download_log/models/document_page_history_recipient_download.py b/document_page_distribution_download_log/models/document_page_history_recipient_download.py new file mode 100644 index 000000000..55ea092f3 --- /dev/null +++ b/document_page_distribution_download_log/models/document_page_history_recipient_download.py @@ -0,0 +1,48 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class DocumentPageHistoryRecipientDownload(models.Model): + """Real download evidence of a (version, partner) recipient line. + + A row only exists when a covered recipient actually accessed/downloaded the + PDF of the version. It is always imputed to the version (``history_id``) the + link belonged to, never to the page's current head. + """ + + _name = "document.page.history.recipient.download" + _description = "Document Page Distribution Download" + _order = "download_date desc, id desc" + + recipient_id = fields.Many2one( + "document.page.history.recipient", + required=True, + ondelete="cascade", + index=True, + ) + history_id = fields.Many2one( + "document.page.history", + related="recipient_id.history_id", + store=True, + index=True, + ) + document_page_id = fields.Many2one( + "document.page", + related="recipient_id.document_page_id", + store=True, + index=True, + ) + company_id = fields.Many2one( + "res.company", + related="recipient_id.company_id", + store=True, + index=True, + ) + user_id = fields.Many2one("res.users") + partner_id = fields.Many2one("res.partner") + attachment_id = fields.Many2one("ir.attachment", ondelete="set null") + download_date = fields.Datetime() + ip_address = fields.Char(string="IP Address") + user_agent = fields.Char() diff --git a/document_page_distribution_download_log/readme/CONTRIBUTORS.rst b/document_page_distribution_download_log/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..aa9885b2c --- /dev/null +++ b/document_page_distribution_download_log/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `NuoBiT `_: + + * Deniz Gallo diff --git a/document_page_distribution_download_log/readme/DESCRIPTION.rst b/document_page_distribution_download_log/readme/DESCRIPTION.rst new file mode 100644 index 000000000..52f4f79a3 --- /dev/null +++ b/document_page_distribution_download_log/readme/DESCRIPTION.rst @@ -0,0 +1,35 @@ +This module records **per-recipient download evidence** for distributed +document page versions. It is the download-log counterpart of +``document_page_distribution``: while that module owns the person + version +coverage (``document.page.history.recipient``), this module logs each real +access/download of a version's PDF by a covered recipient, and exposes the +download coverage as quality-audit evidence. + +For every ``(version, recipient)`` coverage line it adds a download summary +(downloaded, first/last download date, download count) and a detail of every +real access. A download is only ever recorded against an **existing** recipient +line of the same version and person; this module never creates coverage nor +invents recipients (that remains the responsibility of +``document_page_distribution``). + +**How it works** + +* Saving a document page stores its content as a new version + (``document.page.history``); the page itself always shows the head version. +* Each time a version's content is saved, the single document link in it is + rewritten from the raw ``/web/content/`` form to a version-aware + download route that carries the version id. +* Opening that link checks the user's read access to the page, records the + download against the recipient's coverage line of that exact version, and + redirects to the standard ``/web/content`` file download. + +**Audit contract** + +The download log is **append-only**. Evidence rows are written only by the +system, through the download controller, on the recipient's behalf; the access +rights are **read-only for every role, the Document Manager included** +(``perm_read`` only). No user can create, edit or delete a download record, so +the trail cannot be tampered with — which is the point of a compliance log. +Visibility follows the document's own Security groups: a user sees the download +evidence of the documents they are allowed to read, and a Document Manager sees +all of it. diff --git a/document_page_distribution_download_log/readme/USAGE.rst b/document_page_distribution_download_log/readme/USAGE.rst new file mode 100644 index 000000000..c13456308 --- /dev/null +++ b/document_page_distribution_download_log/readme/USAGE.rst @@ -0,0 +1,38 @@ +.. important:: + + Install this module **before** the controlled document versions you want to + track are authored. The version-aware download link is written into a + version's content the moment that content is **saved with this module + installed**. A version whose content was saved *before* the install keeps its + raw ``/web/content`` link and is therefore **not tracked** until its next + revision. + +#. Install this module on top of *Document Page Distribution*: it adds the + download evidence on top of the person + version coverage. +#. Make sure your controlled documents have a Security group and have been + distributed at least once (which creates the coverage lines). +#. Edit a document page and insert a single document link (one file) in its + content. Saving content with more than one document link is rejected. + Insert the file with the page editor, which binds it to the page: a file + the recipient cannot read (e.g. uploaded outside the editor and bound to + nothing) answers 404 and no download evidence is recorded. +#. On save, the document link is rewritten to a controlled, version-aware + download URL. +#. When a covered recipient opens the link, the access is logged against their + coverage line for that exact version. Opening it again adds a new evidence + row and updates the last download date, keeping the first one. Opening the + link of an old version is always imputed to that old version. +#. If a user with read access but without a coverage line opens the link, the + file is served but no evidence is recorded (coverage stays owned by the + distribution module). +#. In the *Distribution* tab of the document, each recipient row shows whether + they downloaded the current version, with a button opening the full download + detail (date, user, file), next to the send detail. + +.. note:: + + Uninstalling this module does **not** restore the original ``/web/content`` + links in versions saved while it was installed: their stored content keeps + the rewritten tracking links, which stop working once the module (and its + download route) is gone. Re-insert the document link manually on the next + revision if you uninstall. diff --git a/document_page_distribution_download_log/security/document_page_distribution_download_log_security.xml b/document_page_distribution_download_log/security/document_page_distribution_download_log_security.xml new file mode 100644 index 000000000..e1495b40e --- /dev/null +++ b/document_page_distribution_download_log/security/document_page_distribution_download_log_security.xml @@ -0,0 +1,41 @@ + + + + + document.page.history.recipient.download multi-company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + document.page.history.recipient.download document groups + + + + ['|', ('document_page_id.groups_id', '=', False), ('document_page_id.groups_id', 'in', [g.id for g in user.groups_id])] + + + document.page.history.recipient.download full access + + + + [(1, '=', 1)] + + diff --git a/document_page_distribution_download_log/security/ir.model.access.csv b/document_page_distribution_download_log/security/ir.model.access.csv new file mode 100644 index 000000000..1a801579f --- /dev/null +++ b/document_page_distribution_download_log/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_document_page_history_recipient_download_user,document.page.history.recipient.download user,model_document_page_history_recipient_download,knowledge.group_document_user,1,0,0,0 +access_document_page_history_recipient_download_manager,document.page.history.recipient.download manager,model_document_page_history_recipient_download,document_page.group_document_manager,1,0,0,0 diff --git a/document_page_distribution_download_log/static/description/icon.png b/document_page_distribution_download_log/static/description/icon.png new file mode 100644 index 000000000..1cd641e79 Binary files /dev/null and b/document_page_distribution_download_log/static/description/icon.png differ diff --git a/document_page_distribution_download_log/static/description/index.html b/document_page_distribution_download_log/static/description/index.html new file mode 100644 index 000000000..390d0cd74 --- /dev/null +++ b/document_page_distribution_download_log/static/description/index.html @@ -0,0 +1,501 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Document Page Distribution Download Log

+ +

Beta License: AGPL-3 NuoBiT/odoo-addons

+

This module records per-recipient download evidence for distributed +document page versions. It is the download-log counterpart of +document_page_distribution: while that module owns the person + version +coverage (document.page.history.recipient), this module logs each real +access/download of a version’s PDF by a covered recipient, and exposes the +download coverage as quality-audit evidence.

+

For every (version, recipient) coverage line it adds a download summary +(downloaded, first/last download date, download count) and a detail of every +real access. A download is only ever recorded against an existing recipient +line of the same version and person; this module never creates coverage nor +invents recipients (that remains the responsibility of +document_page_distribution).

+

How it works

+
    +
  • Saving a document page stores its content as a new version +(document.page.history); the page itself always shows the head version.
  • +
  • Each time a version’s content is saved, the single document link in it is +rewritten from the raw /web/content/<attachment> form to a version-aware +download route that carries the version id.
  • +
  • Opening that link checks the user’s read access to the page, records the +download against the recipient’s coverage line of that exact version, and +redirects to the standard /web/content file download.
  • +
+

Audit contract

+

The download log is append-only. Evidence rows are written only by the +system, through the download controller, on the recipient’s behalf; the access +rights are read-only for every role, the Document Manager included +(perm_read only). No user can create, edit or delete a download record, so +the trail cannot be tampered with — which is the point of a compliance log. +Visibility follows the document’s own Security groups: a user sees the download +evidence of the documents they are allowed to read, and a Document Manager sees +all of it.

+

Table of contents

+ +
+

Usage

+
+

Important

+

Install this module before the controlled document versions you want to +track are authored. The version-aware download link is written into a +version’s content the moment that content is saved with this module +installed. A version whose content was saved before the install keeps its +raw /web/content link and is therefore not tracked until its next +revision.

+
+
    +
  1. Install this module on top of Document Page Distribution: it adds the +download evidence on top of the person + version coverage.
  2. +
  3. Make sure your controlled documents have a Security group and have been +distributed at least once (which creates the coverage lines).
  4. +
  5. Edit a document page and insert a single document link (one file) in its +content. Saving content with more than one document link is rejected. +Insert the file with the page editor, which binds it to the page: a file +the recipient cannot read (e.g. uploaded outside the editor and bound to +nothing) answers 404 and no download evidence is recorded.
  6. +
  7. On save, the document link is rewritten to a controlled, version-aware +download URL.
  8. +
  9. When a covered recipient opens the link, the access is logged against their +coverage line for that exact version. Opening it again adds a new evidence +row and updates the last download date, keeping the first one. Opening the +link of an old version is always imputed to that old version.
  10. +
  11. If a user with read access but without a coverage line opens the link, the +file is served but no evidence is recorded (coverage stays owned by the +distribution module).
  12. +
  13. In the Distribution tab of the document, each recipient row shows whether +they downloaded the current version, with a button opening the full download +detail (date, user, file), next to the send detail.
  14. +
+
+

Note

+

Uninstalling this module does not restore the original /web/content +links in versions saved while it was installed: their stored content keeps +the rewritten tracking links, which stop working once the module (and its +download route) is gone. Re-insert the document link manually on the next +revision if you uninstall.

+
+
+
+

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

+
    +
  • NuoBiT Solutions
  • +
  • S.L.
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the NuoBiT/odoo-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/document_page_distribution_download_log/tests/__init__.py b/document_page_distribution_download_log/tests/__init__.py new file mode 100644 index 000000000..18cb939cc --- /dev/null +++ b/document_page_distribution_download_log/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_document_page_distribution_download_log +from . import test_document_page_distribution_download_log_controller diff --git a/document_page_distribution_download_log/tests/test_document_page_distribution_download_log.py b/document_page_distribution_download_log/tests/test_document_page_distribution_download_log.py new file mode 100644 index 000000000..ca433eac2 --- /dev/null +++ b/document_page_distribution_download_log/tests/test_document_page_distribution_download_log.py @@ -0,0 +1,380 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from datetime import datetime + +from freezegun import freeze_time + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests.common import SavepointCase + + +class TestDocumentPageDistributionDownloadLog(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.doc_user = cls.env.ref("knowledge.group_document_user") + cls.group = cls.env["res.groups"].create({"name": "Quality Audience"}) + users = cls.env["res.users"].with_context(no_reset_password=True) + cls.alice = users.create( + { + "name": "Alice", + "login": "alice_dpddl", + "email": "alice@example.com", + "groups_id": [(6, 0, [cls.doc_user.id, cls.group.id])], + } + ) + cls.attachment = cls.env["ir.attachment"].create( + { + "name": "procedure.pdf", + "datas": base64.b64encode(b"%PDF-1.4 test").decode(), + } + ) + cls.page = cls.env["document.page"].create( + { + "name": "Procedure", + "type": "content", + "groups_id": [(6, 0, [cls.group.id])], + "content": cls._doc_link(cls.attachment.id), + } + ) + cls.head = cls.page.history_head + cls.page._ensure_distribution_recipients( + cls.head, cls.page._get_distribution_coverage_users() + ) + + @classmethod + def _doc_link(cls, attachment_id, count=1): + return "".join( + '

file %s

' % (attachment_id, i) + for i in range(count) + ) + + def _recipient(self, history, user): + return self.env["document.page.history.recipient"].search( + [ + ("history_id", "=", history.id), + ("partner_id", "=", user.partner_id.id), + ] + ) + + def test_link_is_rewritten_with_history_id(self): + route = "/document_page_distribution_download_log/download/%s/%s" % ( + self.head.id, + self.attachment.id, + ) + self.assertIn(route, self.head.content) + self.assertNotIn('"/web/content/%s"' % self.attachment.id, self.head.content) + + def test_more_than_one_document_link_is_rejected(self): + with self.assertRaises(ValidationError): + self.env["document.page"].create( + { + "name": "Two links", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": self._doc_link(self.attachment.id, count=2), + } + ) + + def test_rewriting_is_idempotent(self): + self.assertEqual( + self.head.content, + self.head._rewrite_download_links(self.head.content), + ) + + def test_inline_image_is_not_tracked(self): + page = self.env["document.page"].create( + { + "name": "With image", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

' % self.attachment.id, + } + ) + head = page.history_head + self.assertFalse(head._tracked_attachment_ids(head.content)) + self.assertNotIn("/document_page_distribution_download_log/", head.content) + + def test_first_download_is_logged(self): + recipient = self._recipient(self.head, self.alice) + with freeze_time("2026-06-01 10:00:00"): + download = self.head.with_user(self.alice)._log_recipient_download( + self.attachment.id + ) + self.assertTrue(download) + self.assertEqual(download.recipient_id, recipient) + self.assertEqual(download.history_id, self.head) + self.assertEqual(download.user_id, self.alice) + self.assertTrue(recipient.downloaded) + self.assertEqual(recipient.download_count, 1) + self.assertEqual(recipient.first_download_date, datetime(2026, 6, 1, 10, 0, 0)) + + def test_second_download_increments_and_keeps_first(self): + recipient = self._recipient(self.head, self.alice) + with freeze_time("2026-06-01 10:00:00"): + self.head.with_user(self.alice)._log_recipient_download(self.attachment.id) + with freeze_time("2026-06-05 18:30:00"): + self.head.with_user(self.alice)._log_recipient_download(self.attachment.id) + self.assertEqual(recipient.download_count, 2) + self.assertEqual(recipient.first_download_date, datetime(2026, 6, 1, 10, 0, 0)) + self.assertEqual(recipient.last_download_date, datetime(2026, 6, 5, 18, 30, 0)) + + def test_download_without_recipient_is_not_logged(self): + bob = ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Bob", + "login": "bob_dpddl", + "email": "bob@example.com", + "groups_id": [(6, 0, [self.doc_user.id, self.group.id])], + } + ) + ) + self.assertFalse(self._recipient(self.head, bob)) + download = self.head.with_user(bob)._log_recipient_download(self.attachment.id) + self.assertFalse(download) + + def test_attachment_must_belong_to_version(self): + self.assertTrue(self.head._download_attachment_is_tracked(self.attachment.id)) + self.assertFalse( + self.head._download_attachment_is_tracked(self.attachment.id + 99999) + ) + + def test_old_version_keeps_its_own_attribution(self): + v1 = self.head + recipient_v1 = self._recipient(v1, self.alice) + self.page.write({"content": "

Second version, plain text

"}) + v2 = self.page.history_head + self.assertNotEqual(v1, v2) + download = v1.with_user(self.alice)._log_recipient_download(self.attachment.id) + self.assertEqual(download.history_id, v1) + self.assertEqual(download.recipient_id, recipient_v1) + + def test_revision_relinks_inherited_tracking_link_to_new_version(self): + v1 = self.head + route = "/document_page_distribution_download_log/download/%s/%s" + self.assertIn(route % (v1.id, self.attachment.id), v1.content) + self.page.write({"content": v1.content + "

revised

"}) + v2 = self.page.history_head + self.assertNotEqual(v1, v2) + self.assertIn(route % (v2.id, self.attachment.id), v2.content) + self.assertNotIn(route % (v1.id, self.attachment.id), v2.content) + # A real download of v2 lands on v2's recipient line, not v1's. + self.page._ensure_distribution_recipients( + v2, self.page._get_distribution_coverage_users() + ) + recipient_v2 = self._recipient(v2, self.alice) + download = v2.with_user(self.alice)._log_recipient_download(self.attachment.id) + self.assertEqual(download.history_id, v2) + self.assertEqual(download.recipient_id, recipient_v2) + + def test_user_without_security_group_cannot_read_page(self): + stranger = ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Stranger", + "login": "stranger_dpddl", + "groups_id": [(6, 0, [self.doc_user.id])], + } + ) + ) + with self.assertRaises(AccessError): + self.page.with_user(stranger).check_access_rule("read") + + def test_download_rows_hidden_from_users_outside_document_groups(self): + stranger = ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Stranger ACL", + "login": "stranger_acl_dpddl", + "groups_id": [(6, 0, [self.doc_user.id])], + } + ) + ) + download = self.head.with_user(self.alice)._log_recipient_download( + self.attachment.id + ) + download_model = self.env["document.page.history.recipient.download"] + self.assertFalse( + download_model.with_user(stranger).search([("id", "=", download.id)]) + ) + with self.assertRaises(AccessError): + download.with_user(stranger).read(["id"]) + # users covered by the document's security group keep reading the evidence + self.assertEqual( + download_model.with_user(self.alice).search([("id", "=", download.id)]), + download, + ) + + def test_manager_reads_download_rows_outside_own_groups(self): + manager = ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Manager", + "login": "manager_dpddl", + "groups_id": [ + ( + 6, + 0, + [self.env.ref("document_page.group_document_manager").id], + ) + ], + } + ) + ) + download = self.head.with_user(self.alice)._log_recipient_download( + self.attachment.id + ) + self.assertEqual( + self.env["document.page.history.recipient.download"] + .with_user(manager) + .search([("id", "=", download.id)]), + download, + ) + + def test_new_page_editor_upload_is_bound_to_the_page(self): + # the backend editor uploads a NOT-yet-saved page's file with + # res_model='document.page' but res_id=0 plus an access_token the + # rewrite strips; saving must adopt the orphan so any reader of the + # page can be served the file by /web/content + orphan = self.env["ir.attachment"].create( + { + "name": "draft.pdf", + "datas": base64.b64encode(b"%PDF-1.4 draft").decode(), + "res_model": "document.page", + "res_id": 0, + } + ) + page = self.env["document.page"].create( + { + "name": "Authored from scratch", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

f

' + % orphan.id, + } + ) + self.assertEqual(orphan.res_id, page.id) + + def test_foreign_attachment_is_not_adopted(self): + # an attachment of another model, bound to another record, or + # uploaded by another user must never be rebound by the rewrite + partner = self.env["res.partner"].create({"name": "Other owner"}) + foreign = self.env["ir.attachment"].create( + { + "name": "foreign.pdf", + "datas": base64.b64encode(b"%PDF-1.4 foreign").decode(), + "res_model": "res.partner", + "res_id": partner.id, + } + ) + self.env["document.page"].create( + { + "name": "Links a foreign file", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

f

' % foreign.id, + } + ) + self.assertEqual(foreign.res_model, "res.partner") + self.assertEqual(foreign.res_id, partner.id) + + def test_web_content_url_variants_resolve_to_attachment(self): + # the parser must recognise every shape Odoo emits, including the + # slugged /web/content/-name.pdf that was previously missed + aid = self.attachment.id + for href in ( + "/web/content/%s" % aid, + "/web/content/%s?download=true" % aid, + "/web/content/%s-procedure.pdf" % aid, + "/web/content/ir.attachment/%s/datas" % aid, + ): + self.assertEqual( + self.head._download_link_attachment_id(href), + aid, + "URL not recognised: %s" % href, + ) + + def test_slugified_pdf_link_is_rewritten(self): + page = self.env["document.page"].create( + { + "name": "Slug link", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

f

' + % self.attachment.id, + } + ) + head = page.history_head + route = "/document_page_distribution_download_log/download/%s/%s" % ( + head.id, + self.attachment.id, + ) + self.assertIn(route, head.content) + + def test_non_pdf_link_is_not_tracked(self): + notes = self.env["ir.attachment"].create( + { + "name": "notes.txt", + "datas": base64.b64encode(b"just text").decode(), + } + ) + self.assertNotEqual(notes.mimetype, "application/pdf") + page = self.env["document.page"].create( + { + "name": "Non-PDF link", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

notes

' % notes.id, + } + ) + head = page.history_head + # left untouched: not rewritten, not tracked + self.assertIn('/web/content/%s"' % notes.id, head.content) + self.assertNotIn("/document_page_distribution_download_log/", head.content) + self.assertFalse(head._tracked_attachment_ids(head.content)) + + def test_non_pdf_link_does_not_trip_single_document_rule(self): + # one PDF + one non-PDF must save fine: only the PDF is a document + notes = self.env["ir.attachment"].create( + { + "name": "annex.txt", + "datas": base64.b64encode(b"annex text").decode(), + } + ) + page = self.env["document.page"].create( + { + "name": "PDF plus non-PDF", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

pdf' + 'txt

' + % (self.attachment.id, notes.id), + } + ) + head = page.history_head + route = "/document_page_distribution_download_log/download/%s/%s" % ( + head.id, + self.attachment.id, + ) + self.assertIn(route, head.content) # the PDF is tracked + self.assertIn('/web/content/%s"' % notes.id, head.content) # the txt is not + + def test_opening_page_does_not_log_download(self): + # merely reading the page/version content must not create evidence; + # a download is only logged by the controller click + download_model = self.env["document.page.history.recipient.download"] + before = download_model.search_count([]) + self.page.with_user(self.alice).read(["content"]) + self.head.with_user(self.alice).read(["content"]) + self.assertEqual(download_model.search_count([]), before) diff --git a/document_page_distribution_download_log/tests/test_document_page_distribution_download_log_controller.py b/document_page_distribution_download_log/tests/test_document_page_distribution_download_log_controller.py new file mode 100644 index 000000000..9d3429515 --- /dev/null +++ b/document_page_distribution_download_log/tests/test_document_page_distribution_download_log_controller.py @@ -0,0 +1,181 @@ +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestDocumentPageDistributionDownloadLogController(HttpCase): + def setUp(self): + super().setUp() + self.download_model = self.env["document.page.history.recipient.download"] + self.doc_user = self.env.ref("knowledge.group_document_user") + setup_env = self.env(context=dict(self.env.context, install_filename="test")) + self.group = setup_env["res.groups"].create({"name": "Quality Audience HTTP"}) + users = setup_env["res.users"].with_context(no_reset_password=True) + self.alice = users.create( + { + "name": "Alice", + "login": "alice_dpddl_http", + "password": "alice_dpddl_http", + "email": "alice.http@example.com", + "groups_id": [(6, 0, [self.doc_user.id, self.group.id])], + } + ) + self.attachment = self.env["ir.attachment"].create( + { + "name": "procedure.pdf", + "datas": base64.b64encode(b"%PDF-1.4 test").decode(), + } + ) + self.page = self.env["document.page"].create( + { + "name": "Procedure HTTP", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

file

' + % self.attachment.id, + } + ) + # the web editor binds files inserted in a page to the page record; + # mirror that binding so /web/content grants read access to any + # recipient that can read the page, not only the attachment's author + self.attachment.write({"res_model": "document.page", "res_id": self.page.id}) + self.head = self.page.history_head + self.page._ensure_distribution_recipients( + self.head, self.page._get_distribution_coverage_users() + ) + self.url = "/document_page_distribution_download_log/download/%s/%s" % ( + self.head.id, + self.attachment.id, + ) + + def test_recipient_download_is_logged_over_http(self): + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + response = self.url_open(self.url, allow_redirects=False) + self.assertEqual(response.status_code, 303) + self.assertIn( + "/web/content/%s" % self.attachment.id, + response.headers.get("Location", ""), + ) + self.assertEqual(self.download_model.search_count([]), before + 1) + + def test_recipient_download_serves_file_after_logging(self): + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + response = self.url_open(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"%PDF-1.4 test") + self.assertEqual(self.download_model.search_count([]), before + 1) + + def test_attachment_of_another_version_returns_404(self): + other_attachment = self.env["ir.attachment"].create( + { + "name": "procedure-v2.pdf", + "datas": base64.b64encode(b"%PDF-1.4 v2").decode(), + } + ) + self.page.write( + { + "content": '

file

' + % other_attachment.id + } + ) + v2 = self.page.history_head + self.assertNotEqual(v2, self.head) + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + # a real attachment, but tracked by another version of the page + forged_url = "/document_page_distribution_download_log/download/%s/%s" % ( + v2.id, + self.attachment.id, + ) + response = self.url_open(forged_url, allow_redirects=False) + self.assertEqual(response.status_code, 404) + self.assertEqual(self.download_model.search_count([]), before) + + def test_forged_attachment_returns_404(self): + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + forged_url = "/document_page_distribution_download_log/download/%s/%s" % ( + self.head.id, + self.attachment.id + 99999, + ) + response = self.url_open(forged_url, allow_redirects=False) + self.assertEqual(response.status_code, 404) + self.assertEqual(self.download_model.search_count([]), before) + + def test_download_of_deleted_attachment_returns_404(self): + self.attachment.unlink() + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + response = self.url_open(self.url, allow_redirects=False) + self.assertEqual(response.status_code, 404) + self.assertEqual(self.download_model.search_count([]), before) + + def test_unbound_attachment_returns_404_and_no_log(self): + # a file uploaded outside the page editor is bound to nothing, so + # /web/content would deny it to a non-author (403): no download + # evidence may be written for a file that will not be served + self.attachment.write({"res_model": False, "res_id": False}) + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + response = self.url_open(self.url, allow_redirects=False) + self.assertEqual(response.status_code, 404) + self.assertEqual(self.download_model.search_count([]), before) + + def test_new_page_editor_upload_serves_recipient_end_to_end(self): + # real authoring flow: the editor uploads the file of a NOT-yet-saved + # page as an orphan (res_model set, res_id=0, token-compensated); the + # rewrite adopts it on save, so a covered recipient gets the file + orphan = self.env["ir.attachment"].create( + { + "name": "authored.pdf", + "datas": base64.b64encode(b"%PDF-1.4 authored").decode(), + "res_model": "document.page", + "res_id": 0, + } + ) + page = self.env["document.page"].create( + { + "name": "Authored from scratch HTTP", + "type": "content", + "groups_id": [(6, 0, [self.group.id])], + "content": '

f

' + % orphan.id, + } + ) + self.assertEqual(orphan.res_id, page.id) + head = page.history_head + page._ensure_distribution_recipients( + head, page._get_distribution_coverage_users() + ) + self.authenticate("alice_dpddl_http", "alice_dpddl_http") + before = self.download_model.search_count([]) + response = self.url_open( + "/document_page_distribution_download_log/download/%s/%s" + % (head.id, orphan.id) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"%PDF-1.4 authored") + self.assertEqual(self.download_model.search_count([]), before + 1) + + def test_user_without_document_access_is_blocked(self): + self.env["res.users"].with_context( + no_reset_password=True, install_filename="test" + ).create( + { + "name": "Stranger", + "login": "stranger_dpddl_http", + "password": "stranger_dpddl_http", + "groups_id": [(6, 0, [self.doc_user.id])], + } + ) + self.authenticate("stranger_dpddl_http", "stranger_dpddl_http") + before = self.download_model.search_count([]) + response = self.url_open(self.url, allow_redirects=False) + self.assertEqual(response.status_code, 404) + self.assertEqual(self.download_model.search_count([]), before) diff --git a/document_page_distribution_download_log/views/document_page_history_recipient_views.xml b/document_page_distribution_download_log/views/document_page_history_recipient_views.xml new file mode 100644 index 000000000..f47724004 --- /dev/null +++ b/document_page_distribution_download_log/views/document_page_history_recipient_views.xml @@ -0,0 +1,96 @@ + + + + + document.page.history.recipient.download.tree + document.page.history.recipient.download + + + + + + + + + + + document.page.history.recipient.download.form + document.page.history.recipient.download + +
+ + + + + + + + + + + + + +
+
+
+ + document.page.form.distribution.download.log + document.page + + + + + + + + +