From 314bedba0fd52ad45a3625c4fa86d783d5b6abad Mon Sep 17 00:00:00 2001 From: ??? Date: Mon, 8 Jun 2026 18:17:56 +0200 Subject: [PATCH 1/7] [ADD] document_page_distribution_download_log --- .../README.rst | 132 +++++ .../__init__.py | 2 + .../__manifest__.py | 21 + .../controllers/__init__.py | 1 + .../controllers/main.py | 60 +++ .../i18n/ca.po | 167 ++++++ .../i18n/es.po | 167 ++++++ .../models/__init__.py | 3 + .../models/document_page_history.py | 152 ++++++ .../models/document_page_history_recipient.py | 50 ++ ...ocument_page_history_recipient_download.py | 48 ++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 24 + .../readme/USAGE.rst | 38 ++ ...age_distribution_download_log_security.xml | 41 ++ .../security/ir.model.access.csv | 3 + .../static/description/icon.png | Bin 0 -> 6342 bytes .../static/description/index.html | 492 ++++++++++++++++++ .../tests/__init__.py | 2 + ...document_page_distribution_download_log.py | 290 +++++++++++ ...ge_distribution_download_log_controller.py | 181 +++++++ .../document_page_history_recipient_views.xml | 67 +++ .../document_page_distribution_download_log | 1 + .../setup.py | 6 + 24 files changed, 1951 insertions(+) create mode 100644 document_page_distribution_download_log/README.rst create mode 100644 document_page_distribution_download_log/__init__.py create mode 100644 document_page_distribution_download_log/__manifest__.py create mode 100644 document_page_distribution_download_log/controllers/__init__.py create mode 100644 document_page_distribution_download_log/controllers/main.py create mode 100644 document_page_distribution_download_log/i18n/ca.po create mode 100644 document_page_distribution_download_log/i18n/es.po create mode 100644 document_page_distribution_download_log/models/__init__.py create mode 100644 document_page_distribution_download_log/models/document_page_history.py create mode 100644 document_page_distribution_download_log/models/document_page_history_recipient.py create mode 100644 document_page_distribution_download_log/models/document_page_history_recipient_download.py create mode 100644 document_page_distribution_download_log/readme/CONTRIBUTORS.rst create mode 100644 document_page_distribution_download_log/readme/DESCRIPTION.rst create mode 100644 document_page_distribution_download_log/readme/USAGE.rst create mode 100644 document_page_distribution_download_log/security/document_page_distribution_download_log_security.xml create mode 100644 document_page_distribution_download_log/security/ir.model.access.csv create mode 100644 document_page_distribution_download_log/static/description/icon.png create mode 100644 document_page_distribution_download_log/static/description/index.html create mode 100644 document_page_distribution_download_log/tests/__init__.py create mode 100644 document_page_distribution_download_log/tests/test_document_page_distribution_download_log.py create mode 100644 document_page_distribution_download_log/tests/test_document_page_distribution_download_log_controller.py create mode 100644 document_page_distribution_download_log/views/document_page_history_recipient_views.xml create mode 120000 setup/document_page_distribution_download_log/odoo/addons/document_page_distribution_download_log create mode 100644 setup/document_page_distribution_download_log/setup.py diff --git a/document_page_distribution_download_log/README.rst b/document_page_distribution_download_log/README.rst new file mode 100644 index 000000000..734ba4b78 --- /dev/null +++ b/document_page_distribution_download_log/README.rst @@ -0,0 +1,132 @@ +.. 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. + +**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..e87865400 --- /dev/null +++ b/document_page_distribution_download_log/i18n/ca.po @@ -0,0 +1,167 @@ +# 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 +#, 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..3c7137b8e --- /dev/null +++ b/document_page_distribution_download_log/i18n/es.po @@ -0,0 +1,167 @@ +# 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 +#, 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..aed5967a4 --- /dev/null +++ b/document_page_distribution_download_log/models/document_page_history.py @@ -0,0 +1,152 @@ +# 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 + + def _download_link_attachment_id(self, href): + """Attachment id of a trackable PDF link, or ``False``. + + Recognises raw ``/web/content`` links (``/web/content/42``, + ``/web/content/ir.attachment/42/datas``, with an optional query string) + and already-tracked controller links. Inline images + (``/web/image/...``) are not trackable. URL parsing only, no regex. + """ + 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/") :] + segment = rest.split("/", 1)[0] + if segment.isdigit(): + return int(segment) + return False + + 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..fbad67ec3 --- /dev/null +++ b/document_page_distribution_download_log/readme/DESCRIPTION.rst @@ -0,0 +1,24 @@ +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. 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 0000000000000000000000000000000000000000..1cd641e792c30455187ca30940bc0f329ce8bbb0 GIT binary patch literal 6342 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 literal 0 HcmV?d00001 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..8d76bfc4d --- /dev/null +++ b/document_page_distribution_download_log/static/description/index.html @@ -0,0 +1,492 @@ + + + + + +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.
  • +
+

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..9f76d97d3 --- /dev/null +++ b/document_page_distribution_download_log/tests/test_document_page_distribution_download_log.py @@ -0,0 +1,290 @@ +# 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) 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..205448b78 --- /dev/null +++ b/document_page_distribution_download_log/views/document_page_history_recipient_views.xml @@ -0,0 +1,67 @@ + + + + + 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 + + + + + + + + +