Files
account-tools/tax_receipt_in_portal/controllers/portal.py
Stéphan Sainléger 5eb3fe766b
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m30s
tests / test (pull_request) Failing after 1m41s
[IMP] tax_receipt_in_portal: pick report per company, hide missing PDFs
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.
2026-10-23 13:32:06 +02:00

232 lines
9.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 werkzeug.urls import url_encode
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),
"pdf_available": bool(receipt_sudo._get_pdf_available_receipts()),
}
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,
# Computed on the current page only: at most one page of
# receipts is checked per request.
"pdf_available_ids": set(
receipts_page._get_pdf_available_receipts().ids
),
"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.
When the PDF cannot be rendered (no report configured for the
issuing company, or a blank mapped field), the visitor is sent back
to the receipt page, which then shows no download button. This
covers a configuration change between page display and click.
:param receipt_id: database id of the requested receipt.
:param access_token: share token allowing access without
ownership.
:return: an ``application/pdf`` response, a redirect to the
receipt page when no PDF is available, 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")
if not receipt_sudo._get_pdf_available_receipts():
detail_url = f"/my/tax-receipts/{receipt_sudo.id}"
if access_token:
detail_url += "?" + url_encode({"access_token": access_token})
return request.redirect(detail_url)
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)