T3227 - new info about survival program for churches in odoo#2109
T3227 - new info about survival program for churches in odoo#2109danpa32 wants to merge 2 commits into
Conversation
…porting to v18 - Add sr_survival_* computed fields on res.partner (contracts_report.py) - Add survival_sponsorship_count stat button and open_survival_sponsorships action - Add contracts_report_view.xml with xpath targeting //group[@name='last12months'] - Add res_partner_view.xml with survival stat button after open_contracts - Add list_price=62.0 to survival product template - sponsorship_compassion: add name="last12months" to group, rename sr_sponsorship string to "Number of child sponsorships", restore "Sponsorship impact" stat button, update de/fr_CH/it translations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…utton Add back the "Details of donations" stat button and method that was dropped during the v18 migration. Also fix FA4 icon (fa-file-text-o → fa-file-alt) on both the donations details and sponsorship impact buttons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces survival sponsorship metrics and reporting features, including new fields, views, and actions to track CSP sponsorships, donations, and supported moms and babies. The review feedback focuses on aligning the implementation with Odoo best practices and security standards. Key recommendations include replacing raw SQL queries with ORM-compliant methods to respect multi-company record rules, avoiding unhandled exceptions in compute methods to prevent UI crashes, defining monetary fields with the correct type and widget, and adding defensive checks to prevent potential runtime errors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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 | ||
| ) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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_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() | ||
| } |
There was a problem hiding this comment.
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")
}| sr_total_donation_for_csp = fields.Float( | ||
| "Total donation given", | ||
| compute="_compute_sponsorship_metrics", | ||
| help="Total donation given for CSP.", | ||
| ) |
There was a problem hiding this comment.
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.
| 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.", | |
| ) |
| 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 |
There was a problem hiding this comment.
This method can be completely removed since the sponsorship statistics are now fetched using the ORM-compliant _get_survival_sponsorship_data() method.
| 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() |
| for row in group_data: | ||
| pid = row["partner_id"][0] | ||
| if pid not in results: | ||
| continue | ||
| if row["state"] == "active": | ||
| results[pid]["active_count"] += row["__count"] | ||
| if row["csp_country"]: | ||
| results[pid]["curr"].add(row["csp_country"]) | ||
| elif row["csp_country"]: | ||
| results[pid]["prev"].add(row["csp_country"]) |
There was a problem hiding this comment.
If row["partner_id"] is False (e.g., due to database inconsistency or draft contracts), accessing row["partner_id"][0] will raise a TypeError. Adding a defensive check prevents potential runtime crashes.
| for row in group_data: | |
| pid = row["partner_id"][0] | |
| if pid not in results: | |
| continue | |
| if row["state"] == "active": | |
| results[pid]["active_count"] += row["__count"] | |
| if row["csp_country"]: | |
| results[pid]["curr"].add(row["csp_country"]) | |
| elif row["csp_country"]: | |
| results[pid]["prev"].add(row["csp_country"]) | |
| for row in group_data: | |
| if not row.get("partner_id"): | |
| continue | |
| pid = row["partner_id"][0] | |
| if pid not in results: | |
| continue | |
| if row["state"] == "active": | |
| results[pid]["active_count"] += row["__count"] | |
| if row["csp_country"]: | |
| results[pid]["curr"].add(row["csp_country"]) | |
| elif row["csp_country"]: | |
| results[pid]["prev"].add(row["csp_country"]) |
|
|
||
| <xpath expr="//group[@name='last12months']" position="after"> | ||
| <group string="Supported moms and babies"> | ||
| <field name="sr_total_donation_for_csp" /> |
There was a problem hiding this comment.
Since sr_total_donation_for_csp is a monetary field, it should be displayed with the monetary widget and the correct currency field option to ensure proper formatting.
| <field name="sr_total_donation_for_csp" /> | |
| <field name="sr_total_donation_for_csp" widget="monetary" options="{'currency_field': 'currency_id'}" /> |
Confidence Score: 5/5The changes are narrowly scoped to Odoo view wiring, report helper fields, partner actions, translations, and survival sponsorship metadata, with no outstanding code issues identified. The modified areas are cohesive and align with the migration goal; no blocking behavior or unsafe contract changes were found in the reviewed diff.
What T-Rex did
Reviews (1): Last reviewed commit: "[MIG] sponsorship_compassion: restore mi..." | Re-trigger Greptile |
|
T-Rex pricing update — T-Rex was free through June 2026. Effective July 1, 2026, T-Rex adds 2 credits on top of the standard 1-credit review (3 total). T-Rex settings |
Migrate the golden ticket to v18.0
See #2102 for more info