[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.
This commit is contained in:
212
tax_receipt_in_portal/controllers/portal.py
Normal file
212
tax_receipt_in_portal/controllers/portal.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user