Files
account-tools/tax_receipt_in_portal/controllers/portal.py
Stéphan Sainléger 73c0684623
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m32s
tests / test (pull_request) Failing after 38s
[ADD] tax_receipt_in_portal: publish tax receipts on the portal
Let donors view and download their own tax receipts from
``/my/tax-receipts``: a paginated list, a detail page with the receipt
information, its invoices and the native portal chatter, and a PDF
download rendered on demand through the configured ``pdf_form`` report.

The PDF rendering is delegated to ``pdf_form_report`` and nothing is
archived: a confirmed receipt is frozen by ``l10n_fr_tax_receipt``, so
rendering it again yields the same document.

Two safety nets compensate third-party behaviors:

- report resolution by ``report_uuid`` (system parameter
  ``tax_receipt_in_portal.report_uuid``) with strict validation of
  report type and target model;
- a blank-field guard which resolves every mapped PDF field value
  before rendering, because ``pdf_form_report`` swallows resolution
  errors and would otherwise silently serve a receipt with a blank
  amount.

Access is enforced twice: a portal record rule (own commercial partner,
confirmed/sent state only) and an explicit controller domain that also
covers internal users browsing ``/my``.

Tested with 17 tests (portal isolation, draft exposure, chatter data
access, blank-field guard, report misconfiguration, private API
contract of ``pdf_form_report``), including a mutation check proving
the security tests fail when the record rule is opened.
2026-10-21 00:15:21 +02:00

213 lines
8.1 KiB
Python

# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""Portal routes exposing a donor's own tax receipts."""
from odoo import http
from odoo.exceptions import AccessError, MissingError
from odoo.http import content_disposition, request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.addons.portal.controllers.portal import pager as portal_pager
from ..models.l10n_fr_tax_receipt import PORTAL_VISIBLE_STATES
#: Technical name of the published model, kept in one place so the routes
#: and the access check cannot drift apart.
TAX_RECEIPT_MODEL = "l10n.fr.tax.receipt"
#: Session key storing the receipts most recently browsed, used by the
#: portal to render prev/next links on the detail page.
SESSION_HISTORY_KEY = "my_tax_receipts_history"
class TaxReceiptCustomerPortal(CustomerPortal):
"""Expose confirmed tax receipts to the donor they were issued to."""
def _get_tax_receipts_domain(self):
"""Return the domain restricting receipts to the connected donor.
The record rule shipped with this module already enforces the same
restriction for portal users, but an *internal* user browsing
``/my`` is not subject to it. Repeating the filter here keeps the
page honest for every kind of user.
Donations billed to a child contact are included, mirroring how
Odoo scopes portal invoices to the commercial entity.
:return: an Odoo domain (list of tuples).
"""
commercial_partner = request.env.user.partner_id.commercial_partner_id
return [
("partner_id", "child_of", commercial_partner.id),
("state", "in", list(PORTAL_VISIBLE_STATES)),
]
def _prepare_home_portal_values(self, counters):
"""Add the tax receipt counter to the portal home page.
:param counters: counter names requested by the portal home.
:return: the portal home values, augmented with
``tax_receipt_count`` when requested.
"""
values = super()._prepare_home_portal_values(counters)
if "tax_receipt_count" in counters:
receipts = request.env[TAX_RECEIPT_MODEL]
values["tax_receipt_count"] = (
receipts.search_count(self._get_tax_receipts_domain())
if receipts.has_access("read")
else 0
)
return values
def _tax_receipt_portal_invoices(self, receipt_sudo):
"""Return the receipt invoices the current visitor may read.
Receipt invoices are created in the back office by accountants, so
nothing guarantees the donor is allowed to read them: the portal
rule of ``account.move`` requires a posted move the partner
follows. Browsing the moves with the visitor's own environment and
filtering on the effective access right keeps the detail page free
of dead links.
:param receipt_sudo: ``l10n.fr.tax.receipt`` singleton, in sudo.
:return: an ``account.move`` recordset, possibly empty, readable
by the current user and restricted to posted moves.
"""
receipt_sudo.ensure_one()
posted_invoices = receipt_sudo.invoice_ids.filtered(
lambda move: move.state == "posted"
)
return (
request.env["account.move"]
.browse(posted_invoices.ids)
._filtered_access("read")
)
def _tax_receipt_get_page_view_values(self, receipt_sudo, access_token, **kwargs):
"""Build the rendering values of the tax receipt detail page.
:param receipt_sudo: ``l10n.fr.tax.receipt`` singleton, in sudo.
:param access_token: share token used to reach the page, or
``None``.
:param kwargs: extra values forwarded to ``_get_page_view_values``.
:return: dict of QWeb values, including the prev/next record
browsing keys computed by the portal from the session history.
"""
values = {
"page_name": "tax_receipt",
"tax_receipt": receipt_sudo,
"invoices": self._tax_receipt_portal_invoices(receipt_sudo),
}
return self._get_page_view_values(
receipt_sudo,
access_token,
values,
SESSION_HISTORY_KEY,
False,
**kwargs,
)
@http.route(
["/my/tax-receipts", "/my/tax-receipts/page/<int:page>"],
type="http",
auth="user",
website=True,
)
def portal_my_tax_receipts(self, page=1, **kw):
"""List the connected donor's tax receipts, most recent first.
:param page: 1-based page number for the pager.
:return: the rendered portal list page.
"""
values = self._prepare_portal_layout_values()
receipts = request.env[TAX_RECEIPT_MODEL]
domain = self._get_tax_receipts_domain()
total = receipts.search_count(domain)
pager = portal_pager(
url="/my/tax-receipts",
total=total,
page=page,
step=self._items_per_page,
)
receipts_page = receipts.search(
domain,
limit=self._items_per_page,
offset=pager["offset"],
order="year desc, name desc",
)
request.session[SESSION_HISTORY_KEY] = receipts_page.ids[:100]
values.update(
{
"tax_receipts": receipts_page,
"page_name": "tax_receipt",
"pager": pager,
"default_url": "/my/tax-receipts",
}
)
return request.render("tax_receipt_in_portal.portal_my_tax_receipts", values)
@http.route(
["/my/tax-receipts/<int:receipt_id>"],
type="http",
auth="public",
website=True,
)
def portal_my_tax_receipt_detail(self, receipt_id, access_token=None, **kw):
"""Render one tax receipt, by ownership or by share token.
The page shows the receipt details, its invoices and the portal
chatter, with a download link for the PDF.
:param receipt_id: database id of the receipt.
:param access_token: share token allowing access without
ownership.
:param kw: extra query string arguments forwarded to the view
values.
:return: the rendered detail page, or a redirect to ``/my`` when
the record is missing or not accessible.
"""
try:
receipt_sudo = self._document_check_access(
TAX_RECEIPT_MODEL, receipt_id, access_token
)
except (AccessError, MissingError):
return request.redirect("/my")
values = self._tax_receipt_get_page_view_values(
receipt_sudo, access_token, **kw
)
return request.render("tax_receipt_in_portal.portal_tax_receipt_page", values)
@http.route(
["/my/tax-receipts/<int:receipt_id>/download"],
type="http",
auth="public",
website=True,
)
def portal_tax_receipt_download(self, receipt_id, access_token=None, **kw):
"""Serve the PDF of one tax receipt belonging to the donor.
The PDF is rendered on demand; nothing is archived. Access is
delegated to the standard portal check, which relies on the record
rule or on the share token — so a draft receipt or another donor's
receipt is refused.
:param receipt_id: database id of the requested receipt.
:param access_token: share token allowing access without
ownership.
:return: an ``application/pdf`` response, or a redirect to
``/my`` when the receipt is not accessible.
"""
try:
receipt_sudo = self._document_check_access(
TAX_RECEIPT_MODEL, receipt_id, access_token
)
except (AccessError, MissingError):
return request.redirect("/my")
pdf_bytes, filename = receipt_sudo._render_tax_receipt_pdf()
headers = [
("Content-Type", "application/pdf"),
("Content-Length", len(pdf_bytes)),
("Content-Disposition", content_disposition(filename)),
]
return request.make_response(pdf_bytes, headers=headers)