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.
232 lines
9.1 KiB
Python
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)
|