[IMP] tax_receipt_in_portal: pick report per company, hide missing PDFs
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m30s
tests / test (pull_request) Failing after 1m41s

Replace the ``tax_receipt_in_portal.report_uuid`` system parameter by a
per-company setting, in Settings > Invoicing > Tax Receipts
(``res.company.tax_receipt_report_id``). Each receipt is rendered with
the report of its issuing company, not the current one: in the portal,
the current company is the website's. The selector only offers PDF Form
reports on ``l10n.fr.tax.receipt``; type and model are still checked at
rendering time since the report can be edited after selection.

The download buttons of the list and detail pages are only shown when
the PDF can actually be rendered: a valid report is configured for the
issuing company and no mapped field resolves to an empty value. The
checks are those of the rendering itself, exposed as a filter
(``_get_pdf_available_receipts``) and run in sudo to avoid false
negatives on field paths the donor cannot read. Otherwise a discreet
"PDF available soon" mention replaces the button, and the download URL
redirects to the receipt page.

The detail page download link now also carries the share token, so a
visitor reaching the page by token can download the PDF.
This commit is contained in:
Stéphan Sainléger
2026-10-23 13:00:01 +02:00
parent 73c0684623
commit 5eb3fe766b
11 changed files with 397 additions and 93 deletions

View File

@@ -5,16 +5,15 @@
import base64
import inspect
import io
import re
from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.tools.safe_eval import safe_eval
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
from odoo.addons.tax_receipt_in_portal.models.l10n_fr_tax_receipt import (
REPORT_UUID_PARAM,
)
#: Logger of the third-party renderer, which downgrades field resolution
#: errors to warnings. Muted where a test triggers one on purpose.
@@ -62,9 +61,7 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
cls._map_field("NUMBER", "name")
cls._map_field("DONOR", "partner_id.name")
cls._map_field("AMOUNT", "amount_total_words")
cls.env["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, cls.report.report_uuid
)
cls.env.company.tax_receipt_report_id = cls.report
cls.donor_a, cls.user_a = cls._create_donor("donor_a")
cls.donor_b, cls.user_b = cls._create_donor("donor_b")
@@ -80,17 +77,19 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
# ------------------------------------------------------------------
@classmethod
def _create_pdf_form_report(cls, model_name):
def _create_pdf_form_report(cls, model_name, suffix=""):
"""Create a ``pdf_form`` report on ``model_name``.
:param model_name: technical name of the report target model.
:param suffix: distinguishes several reports on the same model,
whose name and ``report_name`` must stay unique.
:return: the created ``ir.actions.report`` record.
"""
report = cls.env["ir.actions.report"].create(
{
"name": f"Tax receipt test ({model_name})",
"name": f"Tax receipt test ({model_name}){suffix}",
"model": model_name,
"report_name": f"tax_receipt_in_portal.test_{model_name}",
"report_name": f"tax_receipt_in_portal.test_{model_name}{suffix}",
"report_type": "pdf_form",
"filename_pattern": "tax_receipt_{object.id}.pdf",
}
@@ -348,6 +347,84 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
)
self.assertEqual(result["tax_receipt_count"], 0)
# ------------------------------------------------------------------
# PDF availability
# ------------------------------------------------------------------
#: CSS class of the "PDF available soon" mention, used instead of the
#: translatable wording.
PENDING_CLASS = "o_tax_receipt_pdf_pending"
def test_no_report_hides_download_buttons(self):
"""Without a configured report, no download button is shown.
The receipt itself stays visible: only its PDF is unavailable.
"""
self.env.company.tax_receipt_report_id = False
self._login(self.user_a)
listing = self.url_open("/my/tax-receipts")
self.assertEqual(listing.status_code, 200)
self.assertIn(self.receipt_a.name, listing.text)
self.assertNotIn("/download", listing.text)
self.assertIn(self.PENDING_CLASS, listing.text)
page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}")
self.assertEqual(page.status_code, 200)
self.assertNotIn("/download", page.text)
self.assertIn(self.PENDING_CLASS, page.text)
def test_available_pdf_shows_buttons(self):
"""With a valid report, both buttons are shown, no pending mention."""
self._login(self.user_a)
listing = self.url_open("/my/tax-receipts")
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", listing.text)
self.assertNotIn(self.PENDING_CLASS, listing.text)
page = self.url_open(f"/my/tax-receipts/{self.receipt_a.id}")
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", page.text)
self.assertNotIn(self.PENDING_CLASS, page.text)
@mute_logger(PDF_FORM_LOGGER)
def test_blank_field_hides_button_for_that_receipt_only(self):
"""A receipt with a blank mapped field loses its button, not others."""
older_receipt = self._create_receipt(self.donor_a, 2023, 50.0)
older_receipt.action_confirm()
self.env["pdf.form.field.line"].create(
{
"report_id": self.report.id,
"pdf_field_name": "ONLY_2025",
"pdf_field_type": "text",
"is_acroform": False,
"page": 1,
"evaluation_type": "equation",
"python_expr": "'ok' if object.year == 2025 else ''",
}
)
self._login(self.user_a)
listing = self.url_open("/my/tax-receipts")
self.assertIn(f"/my/tax-receipts/{self.receipt_a.id}/download", listing.text)
self.assertNotIn(f"/my/tax-receipts/{older_receipt.id}/download", listing.text)
self.assertEqual(listing.text.count(self.PENDING_CLASS), 1)
def test_download_unavailable_redirects_to_receipt(self):
"""Downloading an unavailable PDF goes back to the receipt page."""
self.env.company.tax_receipt_report_id = False
self._login(self.user_a)
response = self.url_open(
f"/my/tax-receipts/{self.receipt_a.id}/download",
allow_redirects=False,
)
self.assertIn(response.status_code, (302, 303))
self.assertTrue(
response.headers["Location"].endswith(
f"/my/tax-receipts/{self.receipt_a.id}"
)
)
# ------------------------------------------------------------------
# Rendering safety net
# ------------------------------------------------------------------
@@ -397,35 +474,34 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
# Configuration safety net
# ------------------------------------------------------------------
def test_report_misconfigured_missing_param(self):
"""An unset report parameter raises instead of rendering blindly."""
self.env["ir.config_parameter"].sudo().set_param(REPORT_UUID_PARAM, "")
with self.assertRaisesRegex(UserError, REPORT_UUID_PARAM):
def test_report_misconfigured_missing(self):
"""No report on the issuing company raises a configuration error."""
self.env.company.tax_receipt_report_id = False
# Asserted on the company name, not on the translatable wording.
with self.assertRaisesRegex(UserError, re.escape(self.env.company.name)):
self.receipt_a._render_tax_receipt_pdf()
def test_report_misconfigured_unknown_uuid(self):
"""An unknown report UUID raises a configuration error."""
self.env["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, "no-such-uuid"
)
with self.assertRaisesRegex(UserError, "no-such-uuid"):
self.receipt_a._render_tax_receipt_pdf()
def test_report_deleted_unsets_setting(self):
"""Deleting the selected report empties the setting, no crash."""
report = self._create_pdf_form_report("l10n.fr.tax.receipt", " deleted")
self.env.company.tax_receipt_report_id = report
report.unlink()
self.assertFalse(self.env.company.tax_receipt_report_id)
self.assertFalse(self.receipt_a._get_pdf_available_receipts())
def test_report_misconfigured_wrong_model(self):
"""A report targeting another model is refused."""
"""A report retargeted to another model after selection is refused.
The settings domain prevents selecting it, but the report itself
can be edited afterwards; the ORM does not re-check the domain.
"""
other_report = self._create_pdf_form_report("res.partner")
self.env["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, other_report.report_uuid
)
self.env.company.tax_receipt_report_id = other_report
with self.assertRaisesRegex(UserError, "res.partner"):
self.receipt_a._render_tax_receipt_pdf()
def test_report_misconfigured_wrong_type(self):
"""A report that is not a PDF Form report is refused.
``pdf_form_report`` adds ``report_uuid`` to every report, so a
plain QWeb report can be pointed at by mistake.
"""
"""A report that is not a PDF Form report is refused."""
qweb_report = self.env["ir.actions.report"].create(
{
"name": "Not a PDF Form report",
@@ -434,12 +510,67 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
"report_type": "qweb-pdf",
}
)
self.env["ir.config_parameter"].sudo().set_param(
REPORT_UUID_PARAM, qweb_report.report_uuid
)
self.env.company.tax_receipt_report_id = qweb_report
with self.assertRaisesRegex(UserError, "qweb-pdf"):
self.receipt_a._render_tax_receipt_pdf()
def test_report_of_issuing_company(self):
"""A receipt uses its issuing company's report, not the current one.
In the portal, the current company is the website's; a receipt
issued by another company must still use that company's report.
"""
other_company = self.env["res.company"].create({"name": "Other issuer"})
other_report = self._create_pdf_form_report(
"l10n.fr.tax.receipt", " other company"
)
other_company.tax_receipt_report_id = other_report
other_receipt = self.env["l10n.fr.tax.receipt"].create(
{
"partner_id": self.donor_a.id,
"company_id": other_company.id,
"year": 2025,
}
)
self.assertEqual(self.env.company.tax_receipt_report_id, self.report)
self.assertEqual(other_receipt._get_tax_receipt_report(), other_report)
self.assertEqual(self.receipt_a._get_tax_receipt_report(), self.report)
def test_settings_domain(self):
"""The settings selector only offers PDF Form tax receipt reports."""
wrong_model = self._create_pdf_form_report("res.partner")
qweb_report = self.env["ir.actions.report"].create(
{
"name": "Not a PDF Form report",
"model": "l10n.fr.tax.receipt",
"report_name": "tax_receipt_in_portal.test_qweb_domain",
"report_type": "qweb-pdf",
}
)
field = self.env["res.company"]._fields["tax_receipt_report_id"]
domain = safe_eval(field.domain, {"id": self.env.company.id})
selectable = self.env["ir.actions.report"].search(domain)
self.assertIn(self.report, selectable)
self.assertNotIn(wrong_model, selectable)
self.assertNotIn(qweb_report, selectable)
def test_settings_write_company(self):
"""Saving the settings stores the report on the current company."""
self.env.company.tax_receipt_report_id = False
settings = self.env["res.config.settings"].create(
{"tax_receipt_report_id": self.report.id}
)
settings.execute()
self.assertEqual(self.env.company.tax_receipt_report_id, self.report)
def test_settings_view(self):
"""The Tax Receipts block is part of the Invoicing settings."""
arch = self.env["res.config.settings"].get_views([(False, "form")])["views"][
"form"
]["arch"]
self.assertIn('id="tax_receipt_settings"', arch)
self.assertIn('name="tax_receipt_report_id"', arch)
def test_compute_api_contract(self):
"""Guard the private renderer API this module depends on.