[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.
This commit is contained in:
@@ -57,24 +57,26 @@ installed automatically.
|
|||||||
Configuration
|
Configuration
|
||||||
=============
|
=============
|
||||||
|
|
||||||
One system parameter is required
|
Select the report rendering tax receipts in
|
||||||
(:menuselection:`Settings --> Technical --> System Parameters`):
|
:menuselection:`Settings --> Invoicing --> Tax Receipts --> Tax Receipt
|
||||||
|
Report`.
|
||||||
|
|
||||||
==================================== =========================================
|
The setting is **per company**: each receipt is rendered with the report of
|
||||||
Key Value
|
its *issuing* company (``company_id`` of the receipt), not the current one —
|
||||||
==================================== =========================================
|
in the portal, the current company is the website's.
|
||||||
``tax_receipt_in_portal.report_uuid`` The ``report_uuid`` of the PDF Form
|
|
||||||
report that renders tax receipts.
|
|
||||||
==================================== =========================================
|
|
||||||
|
|
||||||
A UUID is used rather than a database id so the configuration survives a
|
The selector only offers reports that:
|
||||||
restore on another database. The report must:
|
|
||||||
|
|
||||||
* be of type ``PDF Form`` (``report_type = pdf_form``);
|
* are of type ``PDF Form`` (``report_type = pdf_form``);
|
||||||
* target the model ``l10n.fr.tax.receipt``.
|
* target the model ``l10n.fr.tax.receipt``;
|
||||||
|
* belong to the company, or are shared (no company).
|
||||||
|
|
||||||
Anything else raises an explicit error at download time instead of serving
|
These conditions are checked again at rendering time, since a report can be
|
||||||
a wrong document.
|
edited after being selected. Deleting the selected report empties the
|
||||||
|
setting.
|
||||||
|
|
||||||
|
Without a valid report, donors still see their receipts, but no download
|
||||||
|
button: a discreet "PDF available soon" mention replaces it.
|
||||||
|
|
||||||
Bind the PDF fields to receipt field paths, for instance:
|
Bind the PDF fields to receipt field paths, for instance:
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ Usage
|
|||||||
Donors see only their own receipts, and only once **confirmed**. Confirming
|
Donors see only their own receipts, and only once **confirmed**. Confirming
|
||||||
a receipt in the back office is therefore what publishes it.
|
a receipt in the back office is therefore what publishes it.
|
||||||
|
|
||||||
|
A download button is shown only when the PDF can actually be rendered: a
|
||||||
|
report is configured for the issuing company and no mapped field resolves
|
||||||
|
to an empty value. Otherwise the button is replaced by a "PDF available
|
||||||
|
soon" mention, and a direct call to the download URL redirects to the
|
||||||
|
receipt page.
|
||||||
|
|
||||||
Security
|
Security
|
||||||
========
|
========
|
||||||
|
|
||||||
@@ -142,6 +150,10 @@ Known issues / Roadmap
|
|||||||
* **No archiving.** The PDF is rendered at each download and never stored.
|
* **No archiving.** The PDF is rendered at each download and never stored.
|
||||||
A receipt whose rendering becomes impossible (report deleted or
|
A receipt whose rendering becomes impossible (report deleted or
|
||||||
reconfigured) can no longer be served, even though it was already sent.
|
reconfigured) can no longer be served, even though it was already sent.
|
||||||
|
* **Availability is evaluated at each display.** Deciding whether to show
|
||||||
|
the download button resolves every mapped PDF field of each displayed
|
||||||
|
receipt — at most one page of the list. A report with costly Python
|
||||||
|
expressions slows the list down accordingly.
|
||||||
* **No email notification** and **no portal account invitation**: a donor
|
* **No email notification** and **no portal account invitation**: a donor
|
||||||
without a portal account cannot reach the page. Both are deliberately
|
without a portal account cannot reach the page. Both are deliberately
|
||||||
out of scope.
|
out of scope.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"security/ir.model.access.csv",
|
"security/ir.model.access.csv",
|
||||||
"security/ir_rule.xml",
|
"security/ir_rule.xml",
|
||||||
"views/portal_templates.xml",
|
"views/portal_templates.xml",
|
||||||
|
"views/res_config_settings_views.xml",
|
||||||
],
|
],
|
||||||
"installable": True,
|
"installable": True,
|
||||||
"application": False,
|
"application": False,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
"""Portal routes exposing a donor's own tax receipts."""
|
"""Portal routes exposing a donor's own tax receipts."""
|
||||||
|
|
||||||
|
from werkzeug.urls import url_encode
|
||||||
|
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.exceptions import AccessError, MissingError
|
from odoo.exceptions import AccessError, MissingError
|
||||||
from odoo.http import content_disposition, request
|
from odoo.http import content_disposition, request
|
||||||
@@ -97,6 +99,7 @@ class TaxReceiptCustomerPortal(CustomerPortal):
|
|||||||
"page_name": "tax_receipt",
|
"page_name": "tax_receipt",
|
||||||
"tax_receipt": receipt_sudo,
|
"tax_receipt": receipt_sudo,
|
||||||
"invoices": self._tax_receipt_portal_invoices(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(
|
return self._get_page_view_values(
|
||||||
receipt_sudo,
|
receipt_sudo,
|
||||||
@@ -139,6 +142,11 @@ class TaxReceiptCustomerPortal(CustomerPortal):
|
|||||||
values.update(
|
values.update(
|
||||||
{
|
{
|
||||||
"tax_receipts": receipts_page,
|
"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",
|
"page_name": "tax_receipt",
|
||||||
"pager": pager,
|
"pager": pager,
|
||||||
"default_url": "/my/tax-receipts",
|
"default_url": "/my/tax-receipts",
|
||||||
@@ -191,11 +199,17 @@ class TaxReceiptCustomerPortal(CustomerPortal):
|
|||||||
rule or on the share token — so a draft receipt or another donor's
|
rule or on the share token — so a draft receipt or another donor's
|
||||||
receipt is refused.
|
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 receipt_id: database id of the requested receipt.
|
||||||
:param access_token: share token allowing access without
|
:param access_token: share token allowing access without
|
||||||
ownership.
|
ownership.
|
||||||
:return: an ``application/pdf`` response, or a redirect to
|
:return: an ``application/pdf`` response, a redirect to the
|
||||||
``/my`` when the receipt is not accessible.
|
receipt page when no PDF is available, or a redirect to ``/my``
|
||||||
|
when the receipt is not accessible.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
receipt_sudo = self._document_check_access(
|
receipt_sudo = self._document_check_access(
|
||||||
@@ -203,6 +217,11 @@ class TaxReceiptCustomerPortal(CustomerPortal):
|
|||||||
)
|
)
|
||||||
except (AccessError, MissingError):
|
except (AccessError, MissingError):
|
||||||
return request.redirect("/my")
|
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()
|
pdf_bytes, filename = receipt_sudo._render_tax_receipt_pdf()
|
||||||
headers = [
|
headers = [
|
||||||
("Content-Type", "application/pdf"),
|
("Content-Type", "application/pdf"),
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Odoo Server 18.0\n"
|
"Project-Id-Version: Odoo Server 18.0\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-20 21:59+0000\n"
|
"POT-Creation-Date: 2026-10-23 10:55+0000\n"
|
||||||
"PO-Revision-Date: 2026-10-20 21:59+0000\n"
|
"PO-Revision-Date: 2026-10-23 10:55+0000\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: \n"
|
"Language-Team: \n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
@@ -27,6 +27,8 @@ msgid ""
|
|||||||
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
||||||
" <span>PDF</span>"
|
" <span>PDF</span>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
"<i class=\"fa fa-download me-1\" role=\"img\"/>\n"
|
||||||
|
" <span>PDF</span>"
|
||||||
|
|
||||||
#. module: tax_receipt_in_portal
|
#. module: tax_receipt_in_portal
|
||||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||||
@@ -92,6 +94,16 @@ msgstr "Avertissement d'accès"
|
|||||||
msgid "Amount"
|
msgid "Amount"
|
||||||
msgstr "Montant"
|
msgstr "Montant"
|
||||||
|
|
||||||
|
#. module: tax_receipt_in_portal
|
||||||
|
#: model:ir.model,name:tax_receipt_in_portal.model_res_company
|
||||||
|
msgid "Companies"
|
||||||
|
msgstr "Sociétés"
|
||||||
|
|
||||||
|
#. module: tax_receipt_in_portal
|
||||||
|
#: model:ir.model,name:tax_receipt_in_portal.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr "Paramètres de configuration"
|
||||||
|
|
||||||
#. module: tax_receipt_in_portal
|
#. module: tax_receipt_in_portal
|
||||||
#: model:ir.model.fields,help:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
|
#: model:ir.model.fields,help:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
|
||||||
msgid "Customer Portal URL"
|
msgid "Customer Portal URL"
|
||||||
@@ -110,20 +122,32 @@ msgstr "Date de facture"
|
|||||||
#. module: tax_receipt_in_portal
|
#. module: tax_receipt_in_portal
|
||||||
#. odoo-python
|
#. odoo-python
|
||||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
||||||
msgid "No report found with report_uuid %(uuid)s, configured in %(param)s."
|
msgid ""
|
||||||
|
"No tax receipt report configured for company %(company)s. Select one in "
|
||||||
|
"Settings > Invoicing > Tax Receipts."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Aucun rapport trouvé avec le report_uuid %(uuid)s, configuré dans %(param)s."
|
"Aucun rapport de reçu fiscal configuré pour la société %(company)s. Sélectionnez-en un dans Paramètres > Facturation > Reçus fiscaux."
|
||||||
|
|
||||||
#. module: tax_receipt_in_portal
|
#. module: tax_receipt_in_portal
|
||||||
#. odoo-python
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
|
||||||
#: code:addons/tax_receipt_in_portal/models/l10n_fr_tax_receipt.py:0
|
msgid "PDF Form report used to render tax receipts downloaded from the portal"
|
||||||
msgid ""
|
|
||||||
"No tax receipt report configured. Set the system parameter %(param)s to the "
|
|
||||||
"report_uuid of the PDF Form report that renders tax receipts."
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Aucun rapport de reçu fiscal configuré. Renseignez le paramètre système "
|
"Rapport PDF Form utilisé pour générer les reçus fiscaux téléchargés depuis le portail"
|
||||||
"%(param)s avec le report_uuid du rapport PDF Form qui génère les reçus "
|
|
||||||
"fiscaux."
|
#. module: tax_receipt_in_portal
|
||||||
|
#: model:ir.model.fields,help:tax_receipt_in_portal.field_res_company__tax_receipt_report_id
|
||||||
|
#: model:ir.model.fields,help:tax_receipt_in_portal.field_res_config_settings__tax_receipt_report_id
|
||||||
|
msgid ""
|
||||||
|
"PDF Form report used to render the tax receipts issued by this company in "
|
||||||
|
"the portal. Without it, donors can see their receipts but not download them."
|
||||||
|
msgstr ""
|
||||||
|
"Rapport PDF Form utilisé pour générer dans le portail les reçus fiscaux émis par cette société. Sans lui, les donateurs voient leurs reçus mais ne peuvent pas les télécharger."
|
||||||
|
|
||||||
|
#. module: tax_receipt_in_portal
|
||||||
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||||
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_tax_receipt_page
|
||||||
|
msgid "PDF available soon"
|
||||||
|
msgstr "PDF bientôt disponible"
|
||||||
|
|
||||||
#. module: tax_receipt_in_portal
|
#. module: tax_receipt_in_portal
|
||||||
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
|
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_l10n_fr_tax_receipt__access_url
|
||||||
@@ -165,10 +189,18 @@ msgstr "Jeton de sécurité"
|
|||||||
msgid "Tax Receipt -"
|
msgid "Tax Receipt -"
|
||||||
msgstr "Reçu fiscal -"
|
msgstr "Reçu fiscal -"
|
||||||
|
|
||||||
|
#. module: tax_receipt_in_portal
|
||||||
|
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_res_company__tax_receipt_report_id
|
||||||
|
#: model:ir.model.fields,field_description:tax_receipt_in_portal.field_res_config_settings__tax_receipt_report_id
|
||||||
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
|
||||||
|
msgid "Tax Receipt Report"
|
||||||
|
msgstr "Rapport de reçu fiscal"
|
||||||
|
|
||||||
#. module: tax_receipt_in_portal
|
#. module: tax_receipt_in_portal
|
||||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_breadcrumbs_tax_receipt
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_breadcrumbs_tax_receipt
|
||||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_home_tax_receipt
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_home_tax_receipt
|
||||||
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.portal_my_tax_receipts
|
||||||
|
#: model_terms:ir.ui.view,arch_db:tax_receipt_in_portal.res_config_settings_view_form
|
||||||
msgid "Tax Receipts"
|
msgid "Tax Receipts"
|
||||||
msgstr "Reçus fiscaux"
|
msgstr "Reçus fiscaux"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
from . import l10n_fr_tax_receipt
|
from . import l10n_fr_tax_receipt
|
||||||
|
from . import res_company
|
||||||
|
from . import res_config_settings
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ amounts, numbering and the invoice selection belong to
|
|||||||
from odoo import _, models
|
from odoo import _, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
#: ``ir.config_parameter`` key holding the ``report_uuid`` of the
|
|
||||||
#: ``pdf_form`` report used to render a tax receipt. A UUID is used rather
|
|
||||||
#: than a database id so the configuration survives a restore on another
|
|
||||||
#: database.
|
|
||||||
REPORT_UUID_PARAM = "tax_receipt_in_portal.report_uuid"
|
|
||||||
|
|
||||||
#: Receipt states a donor is allowed to see and download. A draft receipt
|
#: Receipt states a donor is allowed to see and download. A draft receipt
|
||||||
#: carries no legal number yet and must never reach the portal.
|
#: carries no legal number yet and must never reach the portal.
|
||||||
PORTAL_VISIBLE_STATES = ("confirmed", "sent")
|
PORTAL_VISIBLE_STATES = ("confirmed", "sent")
|
||||||
@@ -53,39 +47,32 @@ class L10nFrTaxReceipt(models.Model):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_tax_receipt_report(self):
|
def _get_tax_receipt_report(self):
|
||||||
"""Return the ``pdf_form`` report configured to render receipts.
|
"""Return the ``pdf_form`` report rendering this receipt.
|
||||||
|
|
||||||
The report is looked up by its ``report_uuid`` stored in the
|
The report is the one selected on the receipt's **issuing**
|
||||||
``tax_receipt_in_portal.report_uuid`` system parameter.
|
company (Settings > Invoicing > Tax Receipts), not on the current
|
||||||
|
company: in the portal, the current company is the website's,
|
||||||
|
which may differ.
|
||||||
|
|
||||||
|
The type and target model are checked again here although the
|
||||||
|
settings field restricts the choice: the report can be edited
|
||||||
|
after being selected.
|
||||||
|
|
||||||
:return: a singleton ``ir.actions.report`` recordset, sudoed.
|
:return: a singleton ``ir.actions.report`` recordset, sudoed.
|
||||||
:raises UserError: if the parameter is unset, if no report carries
|
:raises UserError: if the company has no report configured, if the
|
||||||
that UUID, if the report is not a ``pdf_form`` report, or if it
|
report is not a ``pdf_form`` report, or if it does not target
|
||||||
does not target ``l10n.fr.tax.receipt``.
|
``l10n.fr.tax.receipt``.
|
||||||
"""
|
"""
|
||||||
params = self.env["ir.config_parameter"].sudo()
|
self.ensure_one()
|
||||||
report_uuid = (params.get_param(REPORT_UUID_PARAM) or "").strip()
|
company = self.sudo().company_id
|
||||||
if not report_uuid:
|
report = company.tax_receipt_report_id
|
||||||
raise UserError(
|
|
||||||
_(
|
|
||||||
"No tax receipt report configured. Set the system "
|
|
||||||
"parameter %(param)s to the report_uuid of the PDF Form "
|
|
||||||
"report that renders tax receipts.",
|
|
||||||
param=REPORT_UUID_PARAM,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
report = (
|
|
||||||
self.env["ir.actions.report"]
|
|
||||||
.sudo()
|
|
||||||
.search([("report_uuid", "=", report_uuid)], limit=1)
|
|
||||||
)
|
|
||||||
if not report:
|
if not report:
|
||||||
raise UserError(
|
raise UserError(
|
||||||
_(
|
_(
|
||||||
"No report found with report_uuid %(uuid)s, configured "
|
"No tax receipt report configured for company "
|
||||||
"in %(param)s.",
|
"%(company)s. Select one in Settings > Invoicing > Tax "
|
||||||
uuid=report_uuid,
|
"Receipts.",
|
||||||
param=REPORT_UUID_PARAM,
|
company=company.name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if report.report_type != PDF_FORM_REPORT_TYPE:
|
if report.report_type != PDF_FORM_REPORT_TYPE:
|
||||||
@@ -153,6 +140,43 @@ class L10nFrTaxReceipt(models.Model):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_pdf_available_receipts(self):
|
||||||
|
"""Return the receipts of ``self`` whose PDF can be rendered.
|
||||||
|
|
||||||
|
Applies exactly the checks of ``_render_tax_receipt_pdf`` — visible
|
||||||
|
state, configured and valid report, no blank field — but as a
|
||||||
|
filter instead of raising, so the portal only offers a download
|
||||||
|
button that works.
|
||||||
|
|
||||||
|
The checks run in sudo, like the rendering itself: evaluated with
|
||||||
|
the donor's rights, a field path reaching data they cannot read
|
||||||
|
would resolve to nothing and hide a downloadable receipt.
|
||||||
|
|
||||||
|
The report is resolved once per issuing company.
|
||||||
|
|
||||||
|
:return: a subset of ``self``, in the caller's environment.
|
||||||
|
"""
|
||||||
|
reports_by_company = {}
|
||||||
|
available_ids = []
|
||||||
|
for receipt in self.sudo():
|
||||||
|
if receipt.state not in PORTAL_VISIBLE_STATES:
|
||||||
|
continue
|
||||||
|
company = receipt.company_id
|
||||||
|
if company not in reports_by_company:
|
||||||
|
try:
|
||||||
|
reports_by_company[company] = receipt._get_tax_receipt_report()
|
||||||
|
except UserError:
|
||||||
|
reports_by_company[company] = None
|
||||||
|
report = reports_by_company[company]
|
||||||
|
if not report:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
receipt._check_report_renderable(report)
|
||||||
|
except UserError:
|
||||||
|
continue
|
||||||
|
available_ids.append(receipt.id)
|
||||||
|
return self.browse(available_ids)
|
||||||
|
|
||||||
def _render_tax_receipt_pdf(self):
|
def _render_tax_receipt_pdf(self):
|
||||||
"""Render this tax receipt as a PDF through ``pdf_form_report``.
|
"""Render this tax receipt as a PDF through ``pdf_form_report``.
|
||||||
|
|
||||||
|
|||||||
29
tax_receipt_in_portal/models/res_company.py
Normal file
29
tax_receipt_in_portal/models/res_company.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Copyright 2026 Elabore
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
"""Per-company choice of the report rendering tax receipts."""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
#: Domain restricting the selectable reports to PDF Form reports on tax
|
||||||
|
#: receipts. Shared by the company field and its settings counterpart.
|
||||||
|
TAX_RECEIPT_REPORT_DOMAIN = (
|
||||||
|
"[('report_type', '=', 'pdf_form'),"
|
||||||
|
" ('model', '=', 'l10n.fr.tax.receipt'),"
|
||||||
|
" ('company_id', 'in', [False, {company}])]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResCompany(models.Model):
|
||||||
|
"""Store the tax receipt report of each issuing company."""
|
||||||
|
|
||||||
|
_inherit = "res.company"
|
||||||
|
|
||||||
|
tax_receipt_report_id = fields.Many2one(
|
||||||
|
"ir.actions.report",
|
||||||
|
string="Tax Receipt Report",
|
||||||
|
ondelete="set null",
|
||||||
|
domain=TAX_RECEIPT_REPORT_DOMAIN.format(company="id"),
|
||||||
|
help="PDF Form report used to render the tax receipts issued by "
|
||||||
|
"this company in the portal. Without it, donors can see their "
|
||||||
|
"receipts but not download them.",
|
||||||
|
)
|
||||||
19
tax_receipt_in_portal/models/res_config_settings.py
Normal file
19
tax_receipt_in_portal/models/res_config_settings.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Copyright 2026 Elabore
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
"""Expose the tax receipt report in the Invoicing settings."""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
from .res_company import TAX_RECEIPT_REPORT_DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
"""Add the tax receipt report selector to the settings."""
|
||||||
|
|
||||||
|
_inherit = "res.config.settings"
|
||||||
|
|
||||||
|
tax_receipt_report_id = fields.Many2one(
|
||||||
|
related="company_id.tax_receipt_report_id",
|
||||||
|
readonly=False,
|
||||||
|
domain=TAX_RECEIPT_REPORT_DOMAIN.format(company="company_id"),
|
||||||
|
)
|
||||||
@@ -5,16 +5,15 @@
|
|||||||
import base64
|
import base64
|
||||||
import inspect
|
import inspect
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
|
|
||||||
from odoo import Command
|
from odoo import Command
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
from odoo.tests import tagged
|
from odoo.tests import tagged
|
||||||
from odoo.tools import mute_logger
|
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.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
|
#: Logger of the third-party renderer, which downgrades field resolution
|
||||||
#: errors to warnings. Muted where a test triggers one on purpose.
|
#: 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("NUMBER", "name")
|
||||||
cls._map_field("DONOR", "partner_id.name")
|
cls._map_field("DONOR", "partner_id.name")
|
||||||
cls._map_field("AMOUNT", "amount_total_words")
|
cls._map_field("AMOUNT", "amount_total_words")
|
||||||
cls.env["ir.config_parameter"].sudo().set_param(
|
cls.env.company.tax_receipt_report_id = cls.report
|
||||||
REPORT_UUID_PARAM, cls.report.report_uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
cls.donor_a, cls.user_a = cls._create_donor("donor_a")
|
cls.donor_a, cls.user_a = cls._create_donor("donor_a")
|
||||||
cls.donor_b, cls.user_b = cls._create_donor("donor_b")
|
cls.donor_b, cls.user_b = cls._create_donor("donor_b")
|
||||||
@@ -80,17 +77,19 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
@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``.
|
"""Create a ``pdf_form`` report on ``model_name``.
|
||||||
|
|
||||||
:param model_name: technical name of the report target model.
|
: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.
|
:return: the created ``ir.actions.report`` record.
|
||||||
"""
|
"""
|
||||||
report = cls.env["ir.actions.report"].create(
|
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,
|
"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",
|
"report_type": "pdf_form",
|
||||||
"filename_pattern": "tax_receipt_{object.id}.pdf",
|
"filename_pattern": "tax_receipt_{object.id}.pdf",
|
||||||
}
|
}
|
||||||
@@ -348,6 +347,84 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["tax_receipt_count"], 0)
|
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
|
# Rendering safety net
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -397,35 +474,34 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
|
|||||||
# Configuration safety net
|
# Configuration safety net
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def test_report_misconfigured_missing_param(self):
|
def test_report_misconfigured_missing(self):
|
||||||
"""An unset report parameter raises instead of rendering blindly."""
|
"""No report on the issuing company raises a configuration error."""
|
||||||
self.env["ir.config_parameter"].sudo().set_param(REPORT_UUID_PARAM, "")
|
self.env.company.tax_receipt_report_id = False
|
||||||
with self.assertRaisesRegex(UserError, REPORT_UUID_PARAM):
|
# 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()
|
self.receipt_a._render_tax_receipt_pdf()
|
||||||
|
|
||||||
def test_report_misconfigured_unknown_uuid(self):
|
def test_report_deleted_unsets_setting(self):
|
||||||
"""An unknown report UUID raises a configuration error."""
|
"""Deleting the selected report empties the setting, no crash."""
|
||||||
self.env["ir.config_parameter"].sudo().set_param(
|
report = self._create_pdf_form_report("l10n.fr.tax.receipt", " deleted")
|
||||||
REPORT_UUID_PARAM, "no-such-uuid"
|
self.env.company.tax_receipt_report_id = report
|
||||||
)
|
report.unlink()
|
||||||
with self.assertRaisesRegex(UserError, "no-such-uuid"):
|
self.assertFalse(self.env.company.tax_receipt_report_id)
|
||||||
self.receipt_a._render_tax_receipt_pdf()
|
self.assertFalse(self.receipt_a._get_pdf_available_receipts())
|
||||||
|
|
||||||
def test_report_misconfigured_wrong_model(self):
|
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")
|
other_report = self._create_pdf_form_report("res.partner")
|
||||||
self.env["ir.config_parameter"].sudo().set_param(
|
self.env.company.tax_receipt_report_id = other_report
|
||||||
REPORT_UUID_PARAM, other_report.report_uuid
|
|
||||||
)
|
|
||||||
with self.assertRaisesRegex(UserError, "res.partner"):
|
with self.assertRaisesRegex(UserError, "res.partner"):
|
||||||
self.receipt_a._render_tax_receipt_pdf()
|
self.receipt_a._render_tax_receipt_pdf()
|
||||||
|
|
||||||
def test_report_misconfigured_wrong_type(self):
|
def test_report_misconfigured_wrong_type(self):
|
||||||
"""A report that is not a PDF Form report is refused.
|
"""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.
|
|
||||||
"""
|
|
||||||
qweb_report = self.env["ir.actions.report"].create(
|
qweb_report = self.env["ir.actions.report"].create(
|
||||||
{
|
{
|
||||||
"name": "Not a PDF Form report",
|
"name": "Not a PDF Form report",
|
||||||
@@ -434,12 +510,67 @@ class TestTaxReceiptPortal(AccountTestInvoicingHttpCommon):
|
|||||||
"report_type": "qweb-pdf",
|
"report_type": "qweb-pdf",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.env["ir.config_parameter"].sudo().set_param(
|
self.env.company.tax_receipt_report_id = qweb_report
|
||||||
REPORT_UUID_PARAM, qweb_report.report_uuid
|
|
||||||
)
|
|
||||||
with self.assertRaisesRegex(UserError, "qweb-pdf"):
|
with self.assertRaisesRegex(UserError, "qweb-pdf"):
|
||||||
self.receipt_a._render_tax_receipt_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):
|
def test_compute_api_contract(self):
|
||||||
"""Guard the private renderer API this module depends on.
|
"""Guard the private renderer API this module depends on.
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a
|
<a
|
||||||
|
t-if="receipt.id in pdf_available_ids"
|
||||||
class="btn btn-sm btn-secondary"
|
class="btn btn-sm btn-secondary"
|
||||||
t-attf-href="/my/tax-receipts/#{receipt.id}/download"
|
t-attf-href="/my/tax-receipts/#{receipt.id}/download"
|
||||||
t-att-title="receipt.name"
|
t-att-title="receipt.name"
|
||||||
@@ -106,6 +107,10 @@
|
|||||||
<i class="fa fa-download me-1" role="img" />
|
<i class="fa fa-download me-1" role="img" />
|
||||||
<span>PDF</span>
|
<span>PDF</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span
|
||||||
|
t-else=""
|
||||||
|
class="text-muted small o_tax_receipt_pdf_pending"
|
||||||
|
>PDF available soon</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -132,12 +137,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 text-md-end">
|
<div class="col-12 col-md-6 text-md-end">
|
||||||
<a
|
<a
|
||||||
|
t-if="pdf_available"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download"
|
t-attf-href="/my/tax-receipts/#{tax_receipt.id}/download#{'?access_token=%s' % access_token if access_token else ''}"
|
||||||
>
|
>
|
||||||
<i class="fa fa-download me-1" role="img" />
|
<i class="fa fa-download me-1" role="img" />
|
||||||
<span>Download PDF</span>
|
<span>Download PDF</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span
|
||||||
|
t-else=""
|
||||||
|
class="text-muted o_tax_receipt_pdf_pending"
|
||||||
|
>PDF available soon</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mt-4" id="tax_receipt_information">
|
<div class="row mt-4" id="tax_receipt_information">
|
||||||
|
|||||||
25
tax_receipt_in_portal/views/res_config_settings_views.xml
Normal file
25
tax_receipt_in_portal/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.tax.receipt.portal</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="account.res_config_settings_view_form" />
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//block[@id='invoicing_settings']" position="after">
|
||||||
|
<block title="Tax Receipts" id="tax_receipt_settings">
|
||||||
|
<setting
|
||||||
|
id="tax_receipt_report"
|
||||||
|
string="Tax Receipt Report"
|
||||||
|
company_dependent="1"
|
||||||
|
help="PDF Form report used to render tax receipts downloaded from the portal"
|
||||||
|
>
|
||||||
|
<field
|
||||||
|
name="tax_receipt_report_id"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user