Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sponsorship_compassion/i18n/de.po
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ msgstr "Anzahl der servierten Mahlzeiten"
#. module: sponsorship_compassion
#: model:ir.model.fields,field_description:sponsorship_compassion.field_res_partner__sr_sponsorship
#: model:ir.model.fields,field_description:sponsorship_compassion.field_res_users__sr_sponsorship
msgid "Number of sponsorship"
msgid "Number of child sponsorships"
msgstr "Anzahl der Patenschaften"

#. module: sponsorship_compassion
Expand Down
2 changes: 1 addition & 1 deletion sponsorship_compassion/i18n/fr_CH.po
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ msgstr "Nombre de repas servis"
#. module: sponsorship_compassion
#: model:ir.model.fields,field_description:sponsorship_compassion.field_res_partner__sr_sponsorship
#: model:ir.model.fields,field_description:sponsorship_compassion.field_res_users__sr_sponsorship
msgid "Number of sponsorship"
msgid "Number of child sponsorships"
msgstr "Nombre de parrainages"

#. module: sponsorship_compassion
Expand Down
2 changes: 1 addition & 1 deletion sponsorship_compassion/i18n/it.po
Original file line number Diff line number Diff line change
Expand Up @@ -1003,7 +1003,7 @@ msgstr "Numero di pasti serviti"
#. module: sponsorship_compassion
#: model:ir.model.fields,field_description:sponsorship_compassion.field_res_partner__sr_sponsorship
#: model:ir.model.fields,field_description:sponsorship_compassion.field_res_users__sr_sponsorship
msgid "Number of sponsorship"
msgid "Number of child sponsorships"
msgstr "Numero di sponsorizzazioni"

#. module: sponsorship_compassion
Expand Down
31 changes: 30 additions & 1 deletion sponsorship_compassion/models/contracts_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class PartnerSponsorshipReport(models.Model):

# sr -> Sponsorship Report
sr_sponsorship = fields.Integer(
"Number of sponsorship",
"Number of child sponsorships",
compute="_compute_sr_sponsorship",
help="Count only the sponsorships who "
"are fully managed or those who are "
Expand Down Expand Up @@ -182,6 +182,35 @@ def get_nb_gift(_partner):
sr_total_gift += get_nb_gift(member)
partner.sr_total_gift = sr_total_gift

def open_donation_details(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": "Donations details",
"res_model": "account.move.line",
"views": [
[
self.env.ref(
"sponsorship_compassion.view_invoice_line_partner_tree"
).id,
"list",
]
],
"context": self.with_context(
search_default_group_product=1,
).env.context,
"domain": [
"|",
("partner_id", "=", self.id),
("partner_id.church_id", "=", self.id),
("move_id.invoice_category", "in", ["gift", "sponsorship", "fund"]),
("move_id.move_type", "=", "out_invoice"),
("payment_state", "=", "paid"),
("last_payment", "<", self.end_period),
("last_payment", ">=", self.start_period),
],
}

def open_sponsorship_report(self):
action = {
"type": "ir.actions.act_window",
Expand Down
15 changes: 14 additions & 1 deletion sponsorship_compassion/views/contracts_report_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button
class="oe_inline oe_stat_button"
type="object"
string="Details of donations"
groups="child_compassion.group_sponsorship"
name="open_donation_details"
icon="fa-file-alt"
/>
</div>
<group col='2' string="Sponsorship Report">
<group string="Active sponsorships">
<field name="sr_sponsorship" />
Expand All @@ -18,7 +28,10 @@
<field name="sr_nb_bible" />
<field name="sr_nb_medic_check" />
</group>
<group string="During the last 12 months">
<group
name="last12months"
string="During the last 12 months"
>
<field
name="sr_total_donation"
widget="monetary"
Expand Down
8 changes: 8 additions & 0 deletions sponsorship_compassion/views/res_partner_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@
widget="statinfo"
/>
</button>
<button
class="oe_stat_button"
type="object"
name="open_sponsorship_report"
icon="fa-file-alt"
string="Sponsorship impact"
groups="child_compassion.group_sponsorship"
/>
</xpath>
<xpath expr="//page[1]" position="before">
<page
Expand Down
2 changes: 2 additions & 0 deletions survival_sponsorship_compassion/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"views/res_config_settings_view.xml",
"data/product_warning_automation.xml",
"data/survival_product_template.xml",
"views/contracts_report_view.xml",
"views/res_partner_view.xml",
],
"demo": [],
"installable": True,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<field name="name">Survival Sponsorship</field>
<field name="type">service</field>
<field name="survival_sponsorship_sale">True</field>
<field name="list_price">62.0</field>
</record>
<function name="create_missing_products" model="product.product" />
</odoo>
1 change: 1 addition & 0 deletions survival_sponsorship_compassion/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from . import res_config_settings
from . import recurring_contract_line
from . import res_partner
from . import contracts_report
186 changes: 186 additions & 0 deletions survival_sponsorship_compassion/models/contracts_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
##############################################################################
#
# ______ Releasing children from poverty _
# / ____/___ ____ ___ ____ ____ ___________(_)___ ____
# / / / __ \/ __ `__ \/ __ \/ __ `/ ___/ ___/ / __ \/ __ \
# / /___/ /_/ / / / / / / /_/ / /_/ (__ |__ ) / /_/ / / / /
# \____/\____/_/ /_/ /_/ .___/\__,_/____/____/_/\____/_/ /_/
# /_/
# in Jesus' name
#
# Copyright (C) 2024 Compassion CH (http://www.compassion.ch)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################

from odoo import fields, models


# For more readability we have split "res.partner" by functionality
# pylint: disable=R7980
class PartnerSponsorshipReport(models.Model):
_inherit = "res.partner"

sr_survival_sponsorship_count = fields.Integer(
"Number of survival sponsorships",
compute="_compute_sponsorship_metrics",
help="Number of survival sponsorships for a church AND its members.",
)
sr_total_donation_for_csp = fields.Float(
"Total donation given",
compute="_compute_sponsorship_metrics",
help="Total donation given for CSP.",
)
Comment on lines +41 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The field sr_total_donation_for_csp represents a monetary amount but is defined as a Float. In Odoo, fields representing currency values should be defined as Monetary to ensure proper formatting and currency symbol display in the user interface.

Suggested change
sr_total_donation_for_csp = fields.Float(
"Total donation given",
compute="_compute_sponsorship_metrics",
help="Total donation given for CSP.",
)
sr_total_donation_for_csp = fields.Monetary(
"Total donation given",
compute="_compute_sponsorship_metrics",
help="Total donation given for CSP.",
)

sr_nb_moms_supported_for_a_year = fields.Float(
"Number of moms and babies supported for 1 year (all-in-all)",
compute="_compute_sponsorship_metrics",
help="Number of moms and babies supported for a year.",
)
sr_countries_current = fields.Char(
"Countries currently impacted",
compute="_compute_sponsorship_metrics",
help="List of current countries impacted by the church and its members "
"by the CSP program.",
)
sr_countries_previous = fields.Char(
"Countries previously impacted",
compute="_compute_sponsorship_metrics",
help="List of previously impacted countries by the church and its members "
"by the CSP program.",
)

def _compute_sponsorship_metrics(self):
"""Orchestrator method to calculate and apply all report metrics."""
if not self:
return

for partner in self:
partner.sr_survival_sponsorship_count = 0
partner.sr_total_donation_for_csp = 0.0
partner.sr_countries_current = ""
partner.sr_countries_previous = ""
partner.sr_nb_moms_supported_for_a_year = 0

annual_cost_baseline = self._get_annual_cost_baseline()
partner_stats = self._fetch_sponsorship_stats()
donation_stats = self._fetch_donation_stats()

for partner in self:
stats = partner_stats.get(partner.id, {})
if stats:
partner.sr_survival_sponsorship_count = stats["count"]
if stats["current_countries"]:
partner.sr_countries_current = ", ".join(
sorted(stats["current_countries"])
)
if stats["previous_countries"]:
partner.sr_countries_previous = ", ".join(
sorted(stats["previous_countries"])
)

total_donation = donation_stats.get(partner.id, 0.0)
if partner.is_church:
total_donation += sum(
donation_stats.get(mid, 0.0) for mid in partner.member_ids.ids
)
partner.sr_total_donation_for_csp = total_donation

if annual_cost_baseline > 0:
partner.sr_nb_moms_supported_for_a_year = round(
total_donation / annual_cost_baseline, 2
)
Comment on lines +64 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instead of executing raw SQL queries to fetch sponsorship statistics, we can reuse the existing _get_survival_sponsorship_data() method defined on res.partner. Reusing the ORM-based method is much safer, respects Odoo's multi-company record rules, and is easier to maintain.

    def _compute_sponsorship_metrics(self):
        """Orchestrator method to calculate and apply all report metrics."""
        if not self:
            return

        for partner in self:
            partner.sr_survival_sponsorship_count = 0
            partner.sr_total_donation_for_csp = 0.0
            partner.sr_countries_current = ""
            partner.sr_countries_previous = ""
            partner.sr_nb_moms_supported_for_a_year = 0

        annual_cost_baseline = self._get_annual_cost_baseline()
        data, member_map = self._get_survival_sponsorship_data()
        donation_stats = self._fetch_donation_stats()

        for partner in self:
            partner_data = data.get(partner.id, {})
            count = partner_data.get("active_count", 0)
            curr_countries = set(partner_data.get("curr", []))
            prev_countries = set(partner_data.get("prev", []))

            if partner.is_church:
                for mid in member_map.get(partner.id, []):
                    member_data = data.get(mid, {})
                    count += member_data.get("active_count", 0)
                    curr_countries.update(member_data.get("curr", []))
                    prev_countries.update(member_data.get("prev", []))

            prev_countries -= curr_countries

            partner.sr_survival_sponsorship_count = count
            if curr_countries:
                partner.sr_countries_current = ", ".join(sorted(curr_countries))
            if prev_countries:
                partner.sr_countries_previous = ", ".join(sorted(prev_countries))

            total_donation = donation_stats.get(partner.id, 0.0)
            if partner.is_church:
                total_donation += sum(
                    donation_stats.get(mid, 0.0) for mid in member_map.get(partner.id, [])
                )
            partner.sr_total_donation_for_csp = total_donation

            if annual_cost_baseline > 0:
                partner.sr_nb_moms_supported_for_a_year = round(
                    total_donation / annual_cost_baseline, 2
                )


def _get_annual_cost_baseline(self):
"""Fetch base annual cost (CSP only) from the survival product template."""
survival_tmpl = self.env.ref(
"survival_sponsorship_compassion.survival_product_template",
raise_if_not_found=False,
)
if not survival_tmpl:
raise ValueError(
"Missing required external ID: "
"'survival_sponsorship_compassion.survival_product_template'. "
"Ensure the survival product template is installed."
)
return survival_tmpl.list_price * 12
Comment on lines +105 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Compute methods in Odoo should never raise unhandled exceptions like ValueError during normal execution, as this will crash the entire user interface when loading partner views if the record is missing. It is safer to return 0.0 or log a warning and fail gracefully.

    def _get_annual_cost_baseline(self):
        """Fetch base annual cost (CSP only) from the survival product template."""
        survival_tmpl = self.env.ref(
            "survival_sponsorship_compassion.survival_product_template",
            raise_if_not_found=False,
        )
        return (survival_tmpl.list_price * 12) if survival_tmpl else 0.0


def _fetch_sponsorship_stats(self):
"""Execute raw SQL for contract counts and country sets."""
churches = self.filtered("is_church")
self.env.cr.execute(
"""
SELECT rp.id AS partner_id,
rc.id AS contract_id,
rc.state,
rc.csp_country
FROM res_partner rp
LEFT JOIN recurring_contract rc
ON rc.partner_id = rp.id AND rc.type = 'CSP'
WHERE rp.id IN %s
UNION ALL
SELECT p.church_id AS partner_id,
rc.id AS contract_id,
rc.state,
rc.csp_country
FROM res_partner p
JOIN recurring_contract rc
ON rc.partner_id = p.id AND rc.type = 'CSP'
WHERE p.church_id IN %s
""",
(tuple(self.ids), tuple(churches.ids) if churches else (0,)),
)
stats = {}
for row in self.env.cr.dictfetchall():
pid = row["partner_id"]
data = stats.setdefault(
pid,
{"count": 0, "current_countries": set(), "previous_countries": set()},
)
if row["contract_id"]:
if row["state"] == "active":
data["count"] += 1
if row["csp_country"]:
data["current_countries"].add(row["csp_country"])
elif row["csp_country"]:
data["previous_countries"].add(row["csp_country"])

for data in stats.values():
data["previous_countries"] -= data["current_countries"]
return stats
Comment on lines +119 to +161

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method can be completely removed since the sponsorship statistics are now fetched using the ORM-compliant _get_survival_sponsorship_data() method.

Suggested change
def _fetch_sponsorship_stats(self):
"""Execute raw SQL for contract counts and country sets."""
churches = self.filtered("is_church")
self.env.cr.execute(
"""
SELECT rp.id AS partner_id,
rc.id AS contract_id,
rc.state,
rc.csp_country
FROM res_partner rp
LEFT JOIN recurring_contract rc
ON rc.partner_id = rp.id AND rc.type = 'CSP'
WHERE rp.id IN %s
UNION ALL
SELECT p.church_id AS partner_id,
rc.id AS contract_id,
rc.state,
rc.csp_country
FROM res_partner p
JOIN recurring_contract rc
ON rc.partner_id = p.id AND rc.type = 'CSP'
WHERE p.church_id IN %s
""",
(tuple(self.ids), tuple(churches.ids) if churches else (0,)),
)
stats = {}
for row in self.env.cr.dictfetchall():
pid = row["partner_id"]
data = stats.setdefault(
pid,
{"count": 0, "current_countries": set(), "previous_countries": set()},
)
if row["contract_id"]:
if row["state"] == "active":
data["count"] += 1
if row["csp_country"]:
data["current_countries"].add(row["csp_country"])
elif row["csp_country"]:
data["previous_countries"].add(row["csp_country"])
for data in stats.values():
data["previous_countries"] -= data["current_countries"]
return stats
# Deleted in favor of reusing _get_survival_sponsorship_data()


def _fetch_donation_stats(self):
"""Execute raw SQL for total CSP donation amounts per partner."""
all_ids = set(self.ids)
for church in self.filtered("is_church"):
all_ids.update(church.member_ids.ids)

self.env.cr.execute(
"""
SELECT am.partner_id,
COALESCE(SUM(aml.price_subtotal), 0) AS total_amount
FROM account_move am
JOIN account_move_line aml ON aml.move_id = am.id
JOIN recurring_contract rc ON aml.contract_id = rc.id
WHERE am.partner_id IN %s
AND am.move_type = 'out_invoice'
AND am.payment_state = 'paid'
AND rc.type = 'CSP'
GROUP BY am.partner_id
""",
(tuple(all_ids),),
)
return {
row["partner_id"]: row["total_amount"] for row in self.env.cr.dictfetchall()
}
Comment on lines +163 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Executing raw SQL to fetch donation statistics bypasses Odoo's multi-company record rules and security access controls. We should refactor this to use Odoo's ORM read_group on account.move.line to ensure security and multi-company compliance.

    def _fetch_donation_stats(self):
        """Fetch total CSP donation amounts per partner using ORM."""
        all_ids = set(self.ids)
        for church in self.filtered("is_church"):
            all_ids.update(church.member_ids.ids)

        domain = [
            ("partner_id", "in", list(all_ids)),
            ("move_id.move_type", "=", "out_invoice"),
            ("move_id.payment_state", "=", "paid"),
            ("contract_id.type", "=", "CSP"),
        ]
        group_data = self.env["account.move.line"].read_group(
            domain=domain,
            fields=["partner_id", "price_subtotal:sum"],
            groupby=["partner_id"],
        )
        return {
            row["partner_id"][0]: row["price_subtotal"]
            for row in group_data
            if row.get("partner_id")
        }

Loading
Loading