From f0414da8ae4be4f7d9a25f02452eea0590c6d35a Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Mon, 15 Jun 2026 13:13:01 +0200 Subject: [PATCH 01/18] Add the view to see the number of survival sponsorships for a partner --- .../__manifest__.py | 1 + .../models/res_partner.py | 75 ++++++++++++++++++- .../views/res_partner_view.xml | 26 +++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 survival_sponsorship_compassion/views/res_partner_view.xml diff --git a/survival_sponsorship_compassion/__manifest__.py b/survival_sponsorship_compassion/__manifest__.py index c4b348470..8f65ee81d 100644 --- a/survival_sponsorship_compassion/__manifest__.py +++ b/survival_sponsorship_compassion/__manifest__.py @@ -46,6 +46,7 @@ "views/product_template_view.xml", "views/recurring_contract.xml", "views/res_config_settings_view.xml", + "views/res_partner_view.xml", "data/product_warning_automation.xml", "data/survival_product_template.xml", ], diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index 7e1b7f6a2..d4e8a0b00 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -1,9 +1,46 @@ -from odoo import models +from odoo import _, models, fields class ResPartner(models.Model): _inherit = "res.partner" + survival_sponsorship_count = fields.Integer( + string="Survival sponsorship(s)", + compute="_compute_active_csp_count", + store=False, # Not stored in the database, computed on the fly + copy=False, + ) + + def _compute_active_csp_count(self): + # Guard clause in case self is empty + if not self: + return + + # Optimized batch SQL query using 'IN %s' to handle multiple IDs at once + query = """ + SELECT rp.id AS partner_id, + COUNT(rc.id) AS active_csp_contracts_count + FROM res_partner rp + LEFT JOIN + res_partner p ON (p.id = rp.id OR p.church_id = rp.id) + LEFT JOIN + recurring_contract rc ON rc.partner_id = p.id + AND rc.type = 'CSP' + AND rc.state = 'active' + WHERE rp.id IN %s + GROUP BY rp.id + """ + + # Execute the query passing the IDs of the current recordset as a tuple + self.env.cr.execute(query, (tuple(self.ids),)) + + # Transform the SQL result into a quick-lookup dictionary: {partner_id: count} + res_dict = {row['partner_id']: row['active_csp_contracts_count'] for row in self.env.cr.dictfetchall()} + + # Assign the values back to each record in the recordset + for partner in self: + partner.survival_sponsorship_count = res_dict.get(partner.id, 0) + def _compute_related_contracts(self): super()._compute_related_contracts() contract_obj = self.env["recurring.contract"] @@ -35,3 +72,39 @@ def _compute_related_contracts(self): partner.other_contract_ids = partner.other_contract_ids.filtered( lambda c: c.type != "CSP" ) + + def open_survival_sponsorships(self): + self.ensure_one() + + # 1. Fetch exact contract IDs using raw SQL. + # This bypasses Odoo's slow ORM domain expansion and targets indexes directly. + query = """ + SELECT rc.id + FROM recurring_contract rc + JOIN res_partner p ON rc.partner_id = p.id + WHERE rc.type = 'CSP' + AND rc.state = 'active' + AND (p.id = %s OR p.church_id = %s) \ + """ + self.env.cr.execute(query, (self.id, self.id)) + + # Extract a clean, lightweight list of integer IDs + contract_ids = [row[0] for row in self.env.cr.fetchall()] + + # 2. Build a super lightweight domain using the explicit IDs. + # If no contracts are found, use a fast failing domain [('id', '=', False)] + domain = [("id", "in", contract_ids)] if contract_ids else [("id", "=", False)] + + return { + "name": _("Survival Sponsorships"), + "type": "ir.actions.act_window", + "res_model": "recurring.contract", + "view_mode": "tree,form", + "domain": domain, + "context": { + "default_type": "CSP", + "default_state": "active", + "default_partner_id": self.id, + }, + "target": "current", + } \ No newline at end of file diff --git a/survival_sponsorship_compassion/views/res_partner_view.xml b/survival_sponsorship_compassion/views/res_partner_view.xml new file mode 100644 index 000000000..88b7991d9 --- /dev/null +++ b/survival_sponsorship_compassion/views/res_partner_view.xml @@ -0,0 +1,26 @@ + + + res.partner.survival.sponsorships.form + res.partner + + + + + + + + + + \ No newline at end of file From c4093dbfcb87410e712d8fb9cdb5a4a5889972bc Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Tue, 16 Jun 2026 11:13:00 +0200 Subject: [PATCH 02/18] Finalizing the golden ticket. What is left is to get the amount given by each partner for only CSP, not overall --- .../models/contracts_report.py | 2 +- .../views/contracts_report_view.xml | 10 +- .../__manifest__.py | 1 + .../models/__init__.py | 1 + .../models/contracts_report.py | 151 ++++++++++++++++++ .../models/res_partner.py | 63 +++++--- .../views/contracts_report_view.xml | 23 +++ 7 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 survival_sponsorship_compassion/models/contracts_report.py create mode 100644 survival_sponsorship_compassion/views/contracts_report_view.xml diff --git a/sponsorship_compassion/models/contracts_report.py b/sponsorship_compassion/models/contracts_report.py index ad117b293..40c58e536 100644 --- a/sponsorship_compassion/models/contracts_report.py +++ b/sponsorship_compassion/models/contracts_report.py @@ -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 " diff --git a/sponsorship_compassion/views/contracts_report_view.xml b/sponsorship_compassion/views/contracts_report_view.xml index e51c0bf6f..b3bf7522a 100644 --- a/sponsorship_compassion/views/contracts_report_view.xml +++ b/sponsorship_compassion/views/contracts_report_view.xml @@ -28,12 +28,12 @@ - + + name="sr_total_donation" + widget="monetary" + options="{'currency_field': 'currency_id'}" + /> diff --git a/survival_sponsorship_compassion/__manifest__.py b/survival_sponsorship_compassion/__manifest__.py index 8f65ee81d..30e11307e 100644 --- a/survival_sponsorship_compassion/__manifest__.py +++ b/survival_sponsorship_compassion/__manifest__.py @@ -47,6 +47,7 @@ "views/recurring_contract.xml", "views/res_config_settings_view.xml", "views/res_partner_view.xml", + "views/contracts_report_view.xml", "data/product_warning_automation.xml", "data/survival_product_template.xml", ], diff --git a/survival_sponsorship_compassion/models/__init__.py b/survival_sponsorship_compassion/models/__init__.py index 05d387b93..0e59447f2 100644 --- a/survival_sponsorship_compassion/models/__init__.py +++ b/survival_sponsorship_compassion/models/__init__.py @@ -6,3 +6,4 @@ from . import recurring_contract_line from . import wordpress_configuration from . import res_partner +from . import contracts_report diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py new file mode 100644 index 000000000..ce0a91100 --- /dev/null +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -0,0 +1,151 @@ +from odoo import fields, models + +from dateutil.relativedelta import relativedelta + +# For more readability we have split "res.partner" by functionality +# pylint: disable=R7980 +class PartnerSponsorshipReport(models.Model): + _inherit = "res.partner" + + # sr -> Sponsorship Report + 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_nb_moms_supported_for_a_year = fields.Integer( + "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): + # 1. Default initialization for all batch records + for partner in self: + partner.sr_survival_sponsorship_count = 0 + partner.sr_countries_current = "" + partner.sr_countries_previous = "" + partner.sr_nb_moms_supported_for_a_year = 0 + + if not self: + return + + churches = self.filtered("is_church") + partner_ids = tuple(self.ids) + + # 2. Unified Query Execution Path + if churches: + query = """ + 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 + """ + self.env.cr.execute(query, (partner_ids, tuple(churches.ids))) + else: + query = """ + 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 \ + """ + self.env.cr.execute(query, (partner_ids,)) + + # 3. Aggregate both datasets simultaneously with conditional logic + partner_data = {} + for row in self.env.cr.dictfetchall(): + pid = row["partner_id"] + cid = row["contract_id"] + state = row["state"] + country = row["csp_country"] + + stats = partner_data.setdefault(pid, { + "count": 0, + "current_countries": set(), + "previous_countries": set() + }) + + if cid: + # Condition A: Only increment the counter if the contract is active + if state == "active": + stats["count"] += 1 + if country: + stats["current_countries"].add(country) + + # Condition B: Collect the country regardless of what the contract state is + else: + if country: + stats["previous_countries"].add(country) + + # 4. Batched & Optimized Query for Donations (Moms Supported) + # Collect all unique target IDs (partners + their church members) + today = fields.Date.today() + start_date = today - relativedelta(months=12) + + all_donation_partner_ids = set(self.ids) + for church in churches: + if church.member_ids: + all_donation_partner_ids.update(church.member_ids.ids) + + donation_data = {} + if all_donation_partner_ids: + # We join account_move with res_partner to dynamically validate + # each record against its own start_period and end_period in a single sweep + # See the sponsorship_compassion.contracts_reports file for similar behavior + donation_query = """ + SELECT am.partner_id, COALESCE(SUM(am.amount_total), 0) AS total_amount + FROM account_move am + WHERE am.partner_id IN %s + AND am.move_type = 'out_invoice' + AND am.payment_state = 'paid' + AND am.invoice_category IN ('gift', 'sponsorship', 'fund') + AND am.last_payment < %s + AND am.last_payment > %s + GROUP BY am.partner_id + """ + self.env.cr.execute(donation_query, (tuple(all_donation_partner_ids), today, start_date)) + donation_data = {row["partner_id"]: row["total_amount"] for row in self.env.cr.dictfetchall()} + + # 4. Write back values to the Odoo recordset cache cleanly + for partner in self: + data = partner_data.get(partner.id) + if data: + partner.sr_survival_sponsorship_count = data["count"] + + current_set = data["current_countries"] + previous_set = data["previous_countries"] - current_set + if current_set: + partner.sr_countries_current = ", ".join(sorted(current_set)) + if previous_set: + partner.sr_countries_previous = ", ".join(sorted(previous_set)) + + # Accumulate and write donation metrics + total_donation = donation_data.get(partner.id, 0.0) + if partner.is_church: + for member in partner.member_ids: + total_donation += donation_data.get(member.id, 0.0) + + # Divide total calculation by 744 + # (62 is the amount for a mom for 1 month, so we multiple by 12) and cast to Integer + partner.sr_nb_moms_supported_for_a_year = int(total_donation / 744) \ No newline at end of file diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index d4e8a0b00..3e0aad7fc 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -12,32 +12,55 @@ class ResPartner(models.Model): ) def _compute_active_csp_count(self): - # Guard clause in case self is empty if not self: return - # Optimized batch SQL query using 'IN %s' to handle multiple IDs at once - query = """ - SELECT rp.id AS partner_id, - COUNT(rc.id) AS active_csp_contracts_count - FROM res_partner rp - LEFT JOIN - res_partner p ON (p.id = rp.id OR p.church_id = rp.id) - LEFT JOIN - recurring_contract rc ON rc.partner_id = p.id - AND rc.type = 'CSP' - AND rc.state = 'active' - WHERE rp.id IN %s - GROUP BY rp.id - """ + # 1. Extract just the churches from the batch + churches = self.filtered("is_church") + + # 2. Branch the queries safely based on whether churches are present in the batch + if churches: + query = """ + SELECT rp.id AS partner_id, COUNT(rc.id) AS active_csp_contracts_count + FROM res_partner rp + LEFT JOIN recurring_contract rc ON rc.partner_id = rp.id + AND rc.type = 'CSP' AND rc.state = 'active' + WHERE rp.id IN %s + GROUP BY rp.id + + UNION ALL + + SELECT p.church_id AS partner_id, COUNT(rc.id) AS active_csp_contracts_count + FROM res_partner p + JOIN recurring_contract rc ON rc.partner_id = p.id + AND rc.type = 'CSP' AND rc.state = 'active' + WHERE p.church_id IN %s + GROUP BY p.church_id \ + """ + self.env.cr.execute(query, (tuple(self.ids), tuple(churches.ids))) - # Execute the query passing the IDs of the current recordset as a tuple - self.env.cr.execute(query, (tuple(self.ids),)) + # Since UNION ALL can return two rows for a church, aggregate them here + res_dict = {} + for row in self.env.cr.dictfetchall(): + pid = row["partner_id"] + res_dict[pid] = res_dict.get(pid, 0) + row["active_csp_contracts_count"] - # Transform the SQL result into a quick-lookup dictionary: {partner_id: count} - res_dict = {row['partner_id']: row['active_csp_contracts_count'] for row in self.env.cr.dictfetchall()} + else: + # 3. Ultra-optimized query for regular partners (like standard form views) + # We completely cut out the middleman join on res_partner 'p' + query = """ + SELECT rp.id AS partner_id, COUNT(rc.id) AS active_csp_contracts_count + FROM res_partner rp + LEFT JOIN recurring_contract rc ON rc.partner_id = rp.id + AND rc.type = 'CSP' + AND rc.state = 'active' + WHERE rp.id IN %s + GROUP BY rp.id \ + """ + self.env.cr.execute(query, (tuple(self.ids),)) + res_dict = {row["partner_id"]: row["active_csp_contracts_count"] for row in self.env.cr.dictfetchall()} - # Assign the values back to each record in the recordset + # 4. Assign the values back safely for partner in self: partner.survival_sponsorship_count = res_dict.get(partner.id, 0) diff --git a/survival_sponsorship_compassion/views/contracts_report_view.xml b/survival_sponsorship_compassion/views/contracts_report_view.xml new file mode 100644 index 000000000..0b50c2405 --- /dev/null +++ b/survival_sponsorship_compassion/views/contracts_report_view.xml @@ -0,0 +1,23 @@ + + + + res.partner.sponsorship.report.survival.form.inherit + res.partner + + + + + + + + + + + + + + + + + + \ No newline at end of file From 6b1c249aed2780d65e4c9189d8cbfbd965dfc29c Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Tue, 16 Jun 2026 17:09:54 +0200 Subject: [PATCH 03/18] Add the last peace of the meal. THe number of moms and babies impacted for a year (all-in-all) --- survival_sponsorship_compassion/models/contracts_report.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index ce0a91100..06ddadfa1 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -114,12 +114,14 @@ def _compute_sponsorship_metrics(self): # each record against its own start_period and end_period in a single sweep # See the sponsorship_compassion.contracts_reports file for similar behavior donation_query = """ - SELECT am.partner_id, COALESCE(SUM(am.amount_total), 0) AS total_amount + 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 am.invoice_category IN ('gift', 'sponsorship', 'fund') + AND rc.type = 'CSP' AND am.last_payment < %s AND am.last_payment > %s GROUP BY am.partner_id From 8a246c3a5c2d9ff23b343ec3864472b8f905dc86 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 08:06:15 +0200 Subject: [PATCH 04/18] Add the final touch and use the annual cost of taking care of a mom and her baby using the product information --- .../models/contracts_report.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 06ddadfa1..f3e4057c2 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -44,6 +44,13 @@ def _compute_sponsorship_metrics(self): if not self: return + # DYNAMIC PRICING LOOKUP: Fetch the base monthly value from the template config + # If the template or price isn't initialized yet, safe-fallback to 62.0 + survival_tmpl = self.env.ref("survival_sponsorship_compassion.survival_product_template", + raise_if_not_found=False) + monthly_cost = (survival_tmpl.list_price or 62.0) if survival_tmpl else 62.0 + annual_cost_baseline = monthly_cost * 12 + churches = self.filtered("is_church") partner_ids = tuple(self.ids) @@ -150,4 +157,4 @@ def _compute_sponsorship_metrics(self): # Divide total calculation by 744 # (62 is the amount for a mom for 1 month, so we multiple by 12) and cast to Integer - partner.sr_nb_moms_supported_for_a_year = int(total_donation / 744) \ No newline at end of file + partner.sr_nb_moms_supported_for_a_year = int(total_donation / annual_cost_baseline) \ No newline at end of file From 15613a1d657107c5e52f714f88daa2237513d530 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 08:34:09 +0200 Subject: [PATCH 05/18] Clean code after review by greptile and gemini code --- .../models/contracts_report.py | 17 ++-- .../models/res_partner.py | 86 +++++-------------- .../views/contracts_report_view.xml | 2 +- .../views/res_partner_view.xml | 2 +- 4 files changed, 33 insertions(+), 74 deletions(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index f3e4057c2..0416ffcdd 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -34,6 +34,9 @@ class PartnerSponsorshipReport(models.Model): ) def _compute_sponsorship_metrics(self): + if not self: + return + # 1. Default initialization for all batch records for partner in self: partner.sr_survival_sponsorship_count = 0 @@ -41,9 +44,6 @@ def _compute_sponsorship_metrics(self): partner.sr_countries_previous = "" partner.sr_nb_moms_supported_for_a_year = 0 - if not self: - return - # DYNAMIC PRICING LOOKUP: Fetch the base monthly value from the template config # If the template or price isn't initialized yet, safe-fallback to 62.0 survival_tmpl = self.env.ref("survival_sponsorship_compassion.survival_product_template", @@ -136,7 +136,7 @@ def _compute_sponsorship_metrics(self): self.env.cr.execute(donation_query, (tuple(all_donation_partner_ids), today, start_date)) donation_data = {row["partner_id"]: row["total_amount"] for row in self.env.cr.dictfetchall()} - # 4. Write back values to the Odoo recordset cache cleanly + # 5. Write back values to the Odoo recordset cache cleanly for partner in self: data = partner_data.get(partner.id) if data: @@ -152,9 +152,8 @@ def _compute_sponsorship_metrics(self): # Accumulate and write donation metrics total_donation = donation_data.get(partner.id, 0.0) if partner.is_church: - for member in partner.member_ids: - total_donation += donation_data.get(member.id, 0.0) + total_donation += sum(donation_data.get(mid, 0.0) for mid in partner.member_ids.ids) - # Divide total calculation by 744 - # (62 is the amount for a mom for 1 month, so we multiple by 12) and cast to Integer - partner.sr_nb_moms_supported_for_a_year = int(total_donation / annual_cost_baseline) \ No newline at end of file + # Divide total by annual_cost_baseline + # (monthly_cost * 12, where monthly_cost is read from the product template, defaulting to 62.0) + partner.sr_nb_moms_supported_for_a_year = int(total_donation / annual_cost_baseline) diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index 3e0aad7fc..0ce396669 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -15,54 +15,27 @@ def _compute_active_csp_count(self): if not self: return - # 1. Extract just the churches from the batch churches = self.filtered("is_church") - - # 2. Branch the queries safely based on whether churches are present in the batch + partner_ids = self.ids if churches: - query = """ - SELECT rp.id AS partner_id, COUNT(rc.id) AS active_csp_contracts_count - FROM res_partner rp - LEFT JOIN recurring_contract rc ON rc.partner_id = rp.id - AND rc.type = 'CSP' AND rc.state = 'active' - WHERE rp.id IN %s - GROUP BY rp.id - - UNION ALL - - SELECT p.church_id AS partner_id, COUNT(rc.id) AS active_csp_contracts_count - FROM res_partner p - JOIN recurring_contract rc ON rc.partner_id = p.id - AND rc.type = 'CSP' AND rc.state = 'active' - WHERE p.church_id IN %s - GROUP BY p.church_id \ - """ - self.env.cr.execute(query, (tuple(self.ids), tuple(churches.ids))) + partner_ids = list(set(partner_ids + churches.member_ids.ids)) - # Since UNION ALL can return two rows for a church, aggregate them here - res_dict = {} - for row in self.env.cr.dictfetchall(): - pid = row["partner_id"] - res_dict[pid] = res_dict.get(pid, 0) + row["active_csp_contracts_count"] + contracts = self.env["recurring.contract"].search([ + ("partner_id", "in", partner_ids), + ("type", "=", "CSP"), + ("state", "=", "active"), + ]) - else: - # 3. Ultra-optimized query for regular partners (like standard form views) - # We completely cut out the middleman join on res_partner 'p' - query = """ - SELECT rp.id AS partner_id, COUNT(rc.id) AS active_csp_contracts_count - FROM res_partner rp - LEFT JOIN recurring_contract rc ON rc.partner_id = rp.id - AND rc.type = 'CSP' - AND rc.state = 'active' - WHERE rp.id IN %s - GROUP BY rp.id \ - """ - self.env.cr.execute(query, (tuple(self.ids),)) - res_dict = {row["partner_id"]: row["active_csp_contracts_count"] for row in self.env.cr.dictfetchall()} + contract_counts = {} + for contract in contracts: + pid = contract.partner_id.id + contract_counts[pid] = contract_counts.get(pid, 0) + 1 - # 4. Assign the values back safely for partner in self: - partner.survival_sponsorship_count = res_dict.get(partner.id, 0) + count = contract_counts.get(partner.id, 0) + if partner.is_church: + count += sum(contract_counts.get(mid, 0) for mid in partner.member_ids.ids) + partner.survival_sponsorship_count = count def _compute_related_contracts(self): super()._compute_related_contracts() @@ -99,35 +72,22 @@ def _compute_related_contracts(self): def open_survival_sponsorships(self): self.ensure_one() - # 1. Fetch exact contract IDs using raw SQL. - # This bypasses Odoo's slow ORM domain expansion and targets indexes directly. - query = """ - SELECT rc.id - FROM recurring_contract rc - JOIN res_partner p ON rc.partner_id = p.id - WHERE rc.type = 'CSP' - AND rc.state = 'active' - AND (p.id = %s OR p.church_id = %s) \ - """ - self.env.cr.execute(query, (self.id, self.id)) - - # Extract a clean, lightweight list of integer IDs - contract_ids = [row[0] for row in self.env.cr.fetchall()] - - # 2. Build a super lightweight domain using the explicit IDs. - # If no contracts are found, use a fast failing domain [('id', '=', False)] - domain = [("id", "in", contract_ids)] if contract_ids else [("id", "=", False)] - return { "name": _("Survival Sponsorships"), "type": "ir.actions.act_window", "res_model": "recurring.contract", "view_mode": "tree,form", - "domain": domain, + "domain": [ + ("type", "=", "CSP"), + ("state", "=", "active"), + "|", + ("partner_id", "=", self.id), + ("partner_id.church_id", "=", self.id), + ], "context": { "default_type": "CSP", "default_state": "active", "default_partner_id": self.id, }, "target": "current", - } \ No newline at end of file + } diff --git a/survival_sponsorship_compassion/views/contracts_report_view.xml b/survival_sponsorship_compassion/views/contracts_report_view.xml index 0b50c2405..57e887501 100644 --- a/survival_sponsorship_compassion/views/contracts_report_view.xml +++ b/survival_sponsorship_compassion/views/contracts_report_view.xml @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/survival_sponsorship_compassion/views/res_partner_view.xml b/survival_sponsorship_compassion/views/res_partner_view.xml index 88b7991d9..7143fff88 100644 --- a/survival_sponsorship_compassion/views/res_partner_view.xml +++ b/survival_sponsorship_compassion/views/res_partner_view.xml @@ -23,4 +23,4 @@ - \ No newline at end of file + From ae1b23b24ec4492395ed9440ef1cf6c0cb21fb7f Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 08:39:31 +0200 Subject: [PATCH 06/18] Add heading copyright for py files --- .../models/contracts_report.py | 9 +++++++++ survival_sponsorship_compassion/models/res_partner.py | 10 +++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 0416ffcdd..79bbe3656 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -1,3 +1,12 @@ +############################################################################## +# +# Copyright (C) 2018 Compassion CH (http://www.compassion.ch) +# @author: Daniel Palumbo +# +# The licence is in the file __manifest__.py +# +############################################################################## + from odoo import fields, models from dateutil.relativedelta import relativedelta diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index 0ce396669..fd4d33127 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -1,5 +1,13 @@ -from odoo import _, models, fields +############################################################################## +# +# Copyright (C) 2018 Compassion CH (http://www.compassion.ch) +# @author: Daniel Palumbo +# +# The licence is in the file __manifest__.py +# +############################################################################## +from odoo import _, models, fields class ResPartner(models.Model): _inherit = "res.partner" From 70c6d4de66f872ba10ca35d11bb9470fc882890c Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 10:38:37 +0200 Subject: [PATCH 07/18] Fixes after review --- .../models/contracts_report.py | 12 ----- .../models/res_partner.py | 46 ++++++++----------- 2 files changed, 19 insertions(+), 39 deletions(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 79bbe3656..0b7935062 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -1,12 +1,3 @@ -############################################################################## -# -# Copyright (C) 2018 Compassion CH (http://www.compassion.ch) -# @author: Daniel Palumbo -# -# The licence is in the file __manifest__.py -# -############################################################################## - from odoo import fields, models from dateutil.relativedelta import relativedelta @@ -43,9 +34,6 @@ class PartnerSponsorshipReport(models.Model): ) def _compute_sponsorship_metrics(self): - if not self: - return - # 1. Default initialization for all batch records for partner in self: partner.sr_survival_sponsorship_count = 0 diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index fd4d33127..dd6e450b7 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -1,12 +1,3 @@ -############################################################################## -# -# Copyright (C) 2018 Compassion CH (http://www.compassion.ch) -# @author: Daniel Palumbo -# -# The licence is in the file __manifest__.py -# -############################################################################## - from odoo import _, models, fields class ResPartner(models.Model): @@ -15,34 +6,35 @@ class ResPartner(models.Model): survival_sponsorship_count = fields.Integer( string="Survival sponsorship(s)", compute="_compute_active_csp_count", - store=False, # Not stored in the database, computed on the fly - copy=False, ) def _compute_active_csp_count(self): - if not self: - return - churches = self.filtered("is_church") - partner_ids = self.ids + all_partner_ids = set(self.ids) if churches: - partner_ids = list(set(partner_ids + churches.member_ids.ids)) + all_partner_ids.update(churches.mapped("member_ids.id")) - contracts = self.env["recurring.contract"].search([ - ("partner_id", "in", partner_ids), - ("type", "=", "CSP"), - ("state", "=", "active"), - ]) + group_data = self.env["recurring.contract"].read_group( + domain=[ + ("partner_id", "in", list(all_partner_ids)), + ("type", "=", "CSP"), + ("state", "=", "active"), + ], + fields=["partner_id"], + groupby=["partner_id"], + ) - contract_counts = {} - for contract in contracts: - pid = contract.partner_id.id - contract_counts[pid] = contract_counts.get(pid, 0) + 1 + contract_counts = { + item["partner_id"][0]: item["partner_id_count"] + for item in group_data + } + # Assign counts to each partner for partner in self: count = contract_counts.get(partner.id, 0) if partner.is_church: - count += sum(contract_counts.get(mid, 0) for mid in partner.member_ids.ids) + # Add counts for all associated members + count += sum(contract_counts.get(m.id, 0) for m in partner.member_ids) partner.survival_sponsorship_count = count def _compute_related_contracts(self): @@ -93,8 +85,8 @@ def open_survival_sponsorships(self): ("partner_id.church_id", "=", self.id), ], "context": { + "create": False, "default_type": "CSP", - "default_state": "active", "default_partner_id": self.id, }, "target": "current", From 2b47b078746dda398a321016bca048c21bb7a9c0 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 10:48:21 +0200 Subject: [PATCH 08/18] pre-commit run --- .../views/contracts_report_view.xml | 13 ++++--- .../models/contracts_report.py | 38 ++++++++++++------- .../models/res_partner.py | 6 +-- .../views/contracts_report_view.xml | 14 +++++-- .../views/res_partner_view.xml | 30 +++++++++------ 5 files changed, 64 insertions(+), 37 deletions(-) diff --git a/sponsorship_compassion/views/contracts_report_view.xml b/sponsorship_compassion/views/contracts_report_view.xml index b3bf7522a..305a4daf4 100644 --- a/sponsorship_compassion/views/contracts_report_view.xml +++ b/sponsorship_compassion/views/contracts_report_view.xml @@ -28,12 +28,15 @@ - + + name="sr_total_donation" + widget="monetary" + options="{'currency_field': 'currency_id'}" + /> diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 0b7935062..5cb88884a 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -1,6 +1,7 @@ +from dateutil.relativedelta import relativedelta + from odoo import fields, models -from dateutil.relativedelta import relativedelta # For more readability we have split "res.partner" by functionality # pylint: disable=R7980 @@ -11,8 +12,7 @@ class PartnerSponsorshipReport(models.Model): 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.", + help="Number of survival sponsorships " "for a church AND its members.", ) sr_nb_moms_supported_for_a_year = fields.Integer( @@ -43,8 +43,10 @@ def _compute_sponsorship_metrics(self): # DYNAMIC PRICING LOOKUP: Fetch the base monthly value from the template config # If the template or price isn't initialized yet, safe-fallback to 62.0 - survival_tmpl = self.env.ref("survival_sponsorship_compassion.survival_product_template", - raise_if_not_found=False) + survival_tmpl = self.env.ref( + "survival_sponsorship_compassion.survival_product_template", + raise_if_not_found=False, + ) monthly_cost = (survival_tmpl.list_price or 62.0) if survival_tmpl else 62.0 annual_cost_baseline = monthly_cost * 12 @@ -84,11 +86,10 @@ def _compute_sponsorship_metrics(self): state = row["state"] country = row["csp_country"] - stats = partner_data.setdefault(pid, { - "count": 0, - "current_countries": set(), - "previous_countries": set() - }) + stats = partner_data.setdefault( + pid, + {"count": 0, "current_countries": set(), "previous_countries": set()}, + ) if cid: # Condition A: Only increment the counter if the contract is active @@ -130,8 +131,13 @@ def _compute_sponsorship_metrics(self): AND am.last_payment > %s GROUP BY am.partner_id """ - self.env.cr.execute(donation_query, (tuple(all_donation_partner_ids), today, start_date)) - donation_data = {row["partner_id"]: row["total_amount"] for row in self.env.cr.dictfetchall()} + self.env.cr.execute( + donation_query, (tuple(all_donation_partner_ids), today, start_date) + ) + donation_data = { + row["partner_id"]: row["total_amount"] + for row in self.env.cr.dictfetchall() + } # 5. Write back values to the Odoo recordset cache cleanly for partner in self: @@ -149,8 +155,12 @@ def _compute_sponsorship_metrics(self): # Accumulate and write donation metrics total_donation = donation_data.get(partner.id, 0.0) if partner.is_church: - total_donation += sum(donation_data.get(mid, 0.0) for mid in partner.member_ids.ids) + total_donation += sum( + donation_data.get(mid, 0.0) for mid in partner.member_ids.ids + ) # Divide total by annual_cost_baseline # (monthly_cost * 12, where monthly_cost is read from the product template, defaulting to 62.0) - partner.sr_nb_moms_supported_for_a_year = int(total_donation / annual_cost_baseline) + partner.sr_nb_moms_supported_for_a_year = int( + total_donation / annual_cost_baseline + ) diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index dd6e450b7..ca54435c8 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -1,4 +1,5 @@ -from odoo import _, models, fields +from odoo import _, fields, models + class ResPartner(models.Model): _inherit = "res.partner" @@ -25,8 +26,7 @@ def _compute_active_csp_count(self): ) contract_counts = { - item["partner_id"][0]: item["partner_id_count"] - for item in group_data + item["partner_id"][0]: item["partner_id_count"] for item in group_data } # Assign counts to each partner diff --git a/survival_sponsorship_compassion/views/contracts_report_view.xml b/survival_sponsorship_compassion/views/contracts_report_view.xml index 57e887501..d6b0bd283 100644 --- a/survival_sponsorship_compassion/views/contracts_report_view.xml +++ b/survival_sponsorship_compassion/views/contracts_report_view.xml @@ -1,9 +1,17 @@ - - res.partner.sponsorship.report.survival.form.inherit + + res.partner.sponsorship.report.survival.form.inherit res.partner - + diff --git a/survival_sponsorship_compassion/views/res_partner_view.xml b/survival_sponsorship_compassion/views/res_partner_view.xml index 7143fff88..763a23814 100644 --- a/survival_sponsorship_compassion/views/res_partner_view.xml +++ b/survival_sponsorship_compassion/views/res_partner_view.xml @@ -2,22 +2,28 @@ res.partner.survival.sponsorships.form res.partner - + - + From a4f96abc2a4a9cd871803b83c30e9268d9145c7c Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 11:09:54 +0200 Subject: [PATCH 09/18] Ruff check --- .../models/contracts_report.py | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 5cb88884a..8638e3703 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -24,13 +24,15 @@ class PartnerSponsorshipReport(models.Model): 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.", + 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.", + help="List of previously impacted countries " + "by the church and its members by the CSP program.", ) def _compute_sponsorship_metrics(self): @@ -56,24 +58,34 @@ def _compute_sponsorship_metrics(self): # 2. Unified Query Execution Path if churches: query = """ - SELECT rp.id AS partner_id, rc.id AS contract_id, rc.state, rc.csp_country + 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' + 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 + 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' + JOIN recurring_contract rc + ON rc.partner_id = p.id AND rc.type = 'CSP' WHERE p.church_id IN %s """ self.env.cr.execute(query, (partner_ids, tuple(churches.ids))) else: query = """ - SELECT rp.id AS partner_id, rc.id AS contract_id, rc.state, rc.csp_country + 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' + LEFT JOIN recurring_contract rc + ON rc.partner_id = rp.id AND rc.type = 'CSP' WHERE rp.id IN %s \ """ self.env.cr.execute(query, (partner_ids,)) @@ -98,7 +110,8 @@ def _compute_sponsorship_metrics(self): if country: stats["current_countries"].add(country) - # Condition B: Collect the country regardless of what the contract state is + # Condition B: Collect the country + # regardless of what the contract state is else: if country: stats["previous_countries"].add(country) @@ -119,7 +132,8 @@ def _compute_sponsorship_metrics(self): # each record against its own start_period and end_period in a single sweep # See the sponsorship_compassion.contracts_reports file for similar behavior donation_query = """ - SELECT am.partner_id, COALESCE(SUM(aml.price_subtotal), 0) AS total_amount + 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 @@ -160,7 +174,8 @@ def _compute_sponsorship_metrics(self): ) # Divide total by annual_cost_baseline - # (monthly_cost * 12, where monthly_cost is read from the product template, defaulting to 62.0) + # (monthly_cost * 12, where monthly_cost is read from + # the product template, defaulting to 62.0) partner.sr_nb_moms_supported_for_a_year = int( total_donation / annual_cost_baseline ) From c280a993ca986d696416240fedd58e2d52bad42f Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Wed, 17 Jun 2026 11:14:16 +0200 Subject: [PATCH 10/18] Pre-commit fixes again... --- survival_sponsorship_compassion/models/contracts_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 8638e3703..f6e9dab4c 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -132,7 +132,7 @@ def _compute_sponsorship_metrics(self): # each record against its own start_period and end_period in a single sweep # See the sponsorship_compassion.contracts_reports file for similar behavior donation_query = """ - SELECT am.partner_id, + 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 From 4829784001e59c5da751152f6510a13c17367af6 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Thu, 18 Jun 2026 09:29:30 +0200 Subject: [PATCH 11/18] Fix calculation for year amount given for CSP, remove the hard coded default value for CSP product price, fix the unit from int to float for sr_nb_moms_supported_for_a_year computed value and optimize, clean and divide the code in smaller methods for fethcing data and metrics for maintenability and testing purposes --- .../data/survival_product_template.xml | 2 + .../models/contracts_report.py | 225 ++++++++---------- .../models/res_partner.py | 61 +++-- 3 files changed, 143 insertions(+), 145 deletions(-) diff --git a/survival_sponsorship_compassion/data/survival_product_template.xml b/survival_sponsorship_compassion/data/survival_product_template.xml index 0240fd30f..845130d2b 100644 --- a/survival_sponsorship_compassion/data/survival_product_template.xml +++ b/survival_sponsorship_compassion/data/survival_product_template.xml @@ -2,8 +2,10 @@ Survival Sponsorship + 62.0 service True + 62.0 diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index f6e9dab4c..492d8bcd4 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -15,7 +15,7 @@ class PartnerSponsorshipReport(models.Model): help="Number of survival sponsorships " "for a church AND its members.", ) - sr_nb_moms_supported_for_a_year = fields.Integer( + 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.", @@ -36,146 +36,115 @@ class PartnerSponsorshipReport(models.Model): ) def _compute_sponsorship_metrics(self): - # 1. Default initialization for all batch records + """Orchestrator method to calculate and apply all report metrics.""" + # 1. Initialize Default Values for partner in self: partner.sr_survival_sponsorship_count = 0 partner.sr_countries_current = "" partner.sr_countries_previous = "" partner.sr_nb_moms_supported_for_a_year = 0 - # DYNAMIC PRICING LOOKUP: Fetch the base monthly value from the template config - # If the template or price isn't initialized yet, safe-fallback to 62.0 + # 2. Fetch Data + annual_cost_baseline = self._get_annual_cost_baseline() + partner_stats = self._fetch_sponsorship_stats() + donation_stats = self._fetch_donation_stats() + + # 3. Apply calculated data to records + for partner in self: + # Apply Contract/Country Stats + 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"]) + ) + + # Apply Donation Stats (aggregating members for churches) + 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 + ) + + 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 (for CSP only) from product template.""" survival_tmpl = self.env.ref( "survival_sponsorship_compassion.survival_product_template", raise_if_not_found=False, ) - monthly_cost = (survival_tmpl.list_price or 62.0) if survival_tmpl else 62.0 - annual_cost_baseline = monthly_cost * 12 - + monthly_cost = survival_tmpl.list_price + return monthly_cost * 12 + + # This function is using raw sql and union all + # It might seem weird to not use the ORM, + # but for optimization purposes, raw SQL is better + # and lighter for the database + def _fetch_sponsorship_stats(self): + """Execute raw SQL for contract counts and country sets.""" churches = self.filtered("is_church") - partner_ids = tuple(self.ids) - - # 2. Unified Query Execution Path - if churches: - query = """ - 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 - """ - self.env.cr.execute(query, (partner_ids, tuple(churches.ids))) - else: - query = """ - 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 \ - """ - self.env.cr.execute(query, (partner_ids,)) - - # 3. Aggregate both datasets simultaneously with conditional logic - partner_data = {} + query = """ + 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 \ + """ + self.env.cr.execute( + query, (tuple(self.ids), tuple(churches.ids) if churches else (0,)) + ) + + stats = {} for row in self.env.cr.dictfetchall(): pid = row["partner_id"] - cid = row["contract_id"] - state = row["state"] - country = row["csp_country"] - - stats = partner_data.setdefault( + data = stats.setdefault( pid, {"count": 0, "current_countries": set(), "previous_countries": set()}, ) - - if cid: - # Condition A: Only increment the counter if the contract is active - if state == "active": - stats["count"] += 1 - if country: - stats["current_countries"].add(country) - - # Condition B: Collect the country - # regardless of what the contract state is - else: - if country: - stats["previous_countries"].add(country) - - # 4. Batched & Optimized Query for Donations (Moms Supported) - # Collect all unique target IDs (partners + their church members) - today = fields.Date.today() - start_date = today - relativedelta(months=12) - - all_donation_partner_ids = set(self.ids) - for church in churches: - if church.member_ids: - all_donation_partner_ids.update(church.member_ids.ids) - - donation_data = {} - if all_donation_partner_ids: - # We join account_move with res_partner to dynamically validate - # each record against its own start_period and end_period in a single sweep - # See the sponsorship_compassion.contracts_reports file for similar behavior - donation_query = """ - 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' - AND am.last_payment < %s - AND am.last_payment > %s - GROUP BY am.partner_id - """ - self.env.cr.execute( - donation_query, (tuple(all_donation_partner_ids), today, start_date) - ) - donation_data = { - row["partner_id"]: row["total_amount"] - for row in self.env.cr.dictfetchall() - } - - # 5. Write back values to the Odoo recordset cache cleanly - for partner in self: - data = partner_data.get(partner.id) - if data: - partner.sr_survival_sponsorship_count = data["count"] - - current_set = data["current_countries"] - previous_set = data["previous_countries"] - current_set - if current_set: - partner.sr_countries_current = ", ".join(sorted(current_set)) - if previous_set: - partner.sr_countries_previous = ", ".join(sorted(previous_set)) - - # Accumulate and write donation metrics - total_donation = donation_data.get(partner.id, 0.0) - if partner.is_church: - total_donation += sum( - donation_data.get(mid, 0.0) for mid in partner.member_ids.ids - ) - - # Divide total by annual_cost_baseline - # (monthly_cost * 12, where monthly_cost is read from - # the product template, defaulting to 62.0) - partner.sr_nb_moms_supported_for_a_year = int( - total_donation / annual_cost_baseline - ) + 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"]) + + # Cleanup: Ensure countries are only in one set + for data in stats.values(): + data["previous_countries"] -= data["current_countries"] + return stats + + def _fetch_donation_stats(self): + """Execute raw SQL for total donation amounts per partner.""" + all_ids = set(self.ids) + for church in self.filtered("is_church"): + all_ids.update(church.member_ids.ids) + + query = """ + 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 \ + """ + self.env.cr.execute(query, (tuple(all_ids),)) + return { + row["partner_id"]: row["total_amount"] for row in self.env.cr.dictfetchall() + } diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index ca54435c8..e819af648 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -9,32 +9,59 @@ class ResPartner(models.Model): compute="_compute_active_csp_count", ) - def _compute_active_csp_count(self): + def _get_survival_sponsorship_data(self): + """ + Returns a dictionary mapping partner_id to their survival sponsorship metrics. + This is the single source of truth for sponsorship calculations. + """ churches = self.filtered("is_church") - all_partner_ids = set(self.ids) - if churches: - all_partner_ids.update(churches.mapped("member_ids.id")) + all_ids = set(self.ids) + # Pre-fetch member IDs to avoid ORM overhead in loops + church_member_map = {c.id: c.member_ids.ids for c in churches} + for members in church_member_map.values(): + all_ids.update(members) + # Using your proven read_group logic group_data = self.env["recurring.contract"].read_group( - domain=[ - ("partner_id", "in", list(all_partner_ids)), - ("type", "=", "CSP"), - ("state", "=", "active"), - ], - fields=["partner_id"], - groupby=["partner_id"], + domain=[("partner_id", "in", list(all_ids)), ("type", "=", "CSP")], + fields=["partner_id", "state", "csp_country"], + groupby=["partner_id", "state", "csp_country"], + lazy=False, ) - contract_counts = { - item["partner_id"][0]: item["partner_id_count"] for item in group_data + # Structure: { + # partner_id: { + # 'active_count': X, + # 'current_countries': set(), + # 'previous_countries': set() + # } + # } + results = { + pid: {"active_count": 0, "curr": set(), "prev": set()} for pid in all_ids } - # Assign counts to each partner + 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"]) + + return results, church_member_map + + def _compute_active_csp_count(self): + data, member_map = self._get_survival_sponsorship_data() for partner in self: - count = contract_counts.get(partner.id, 0) + count = data.get(partner.id, {}).get('active_count', 0) if partner.is_church: - # Add counts for all associated members - count += sum(contract_counts.get(m.id, 0) for m in partner.member_ids) + count += sum(data.get(mid, {}) + .get('active_count', 0) + for mid in member_map.get(partner.id, [])) partner.survival_sponsorship_count = count def _compute_related_contracts(self): From 4ca433e2a3405014f71ae7e29385e58234bb993f Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Thu, 18 Jun 2026 09:44:21 +0200 Subject: [PATCH 12/18] Adding the translations (not sure if it is used) and applying pre-commit --- sponsorship_compassion/i18n/de.po | 2 +- sponsorship_compassion/i18n/fr_CH.po | 2 +- sponsorship_compassion/i18n/it.po | 2 +- .../models/contracts_report.py | 21 ++++++++++++------- .../models/res_partner.py | 9 ++++---- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/sponsorship_compassion/i18n/de.po b/sponsorship_compassion/i18n/de.po index 44ca8612f..b0a354a57 100644 --- a/sponsorship_compassion/i18n/de.po +++ b/sponsorship_compassion/i18n/de.po @@ -1002,7 +1002,7 @@ msgstr "Anzahl der servierten Mahlzeiten" #: 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" -msgstr "Anzahl der Patenschaften" +msgstr "Anzahl der Kinderpatenschaften" #. module: sponsorship_compassion #: model:ir.model.fields,field_description:sponsorship_compassion.field_res_partner__number_sponsorships diff --git a/sponsorship_compassion/i18n/fr_CH.po b/sponsorship_compassion/i18n/fr_CH.po index f778477e4..09e6d0270 100644 --- a/sponsorship_compassion/i18n/fr_CH.po +++ b/sponsorship_compassion/i18n/fr_CH.po @@ -1005,7 +1005,7 @@ msgstr "Nombre de repas servis" #: 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" -msgstr "Nombre de parrainages" +msgstr "Nombre de parrainages d'enfants" #. module: sponsorship_compassion #: model:ir.model.fields,field_description:sponsorship_compassion.field_res_partner__number_sponsorships diff --git a/sponsorship_compassion/i18n/it.po b/sponsorship_compassion/i18n/it.po index b55d9eddd..39ce7bf3c 100644 --- a/sponsorship_compassion/i18n/it.po +++ b/sponsorship_compassion/i18n/it.po @@ -1004,7 +1004,7 @@ msgstr "Numero di pasti serviti" #: 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" -msgstr "Numero di sponsorizzazioni" +msgstr "Numero di sponsorizzazioni per bambini" #. module: sponsorship_compassion #: model:ir.model.fields,field_description:sponsorship_compassion.field_res_partner__number_sponsorships diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 492d8bcd4..bb5f4ed8d 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -1,5 +1,3 @@ -from dateutil.relativedelta import relativedelta - from odoo import fields, models @@ -93,14 +91,22 @@ def _fetch_sponsorship_stats(self): """Execute raw SQL for contract counts and country sets.""" churches = self.filtered("is_church") query = """ - SELECT rp.id AS partner_id, rc.id AS contract_id, rc.state, rc.csp_country + 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' + 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 + 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' + JOIN recurring_contract rc + ON rc.partner_id = p.id AND rc.type = 'CSP' WHERE p.church_id IN %s \ """ self.env.cr.execute( @@ -134,7 +140,8 @@ def _fetch_donation_stats(self): all_ids.update(church.member_ids.ids) query = """ - SELECT am.partner_id, COALESCE(SUM(aml.price_subtotal), 0) AS total_amount + 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 diff --git a/survival_sponsorship_compassion/models/res_partner.py b/survival_sponsorship_compassion/models/res_partner.py index e819af648..cea625de4 100644 --- a/survival_sponsorship_compassion/models/res_partner.py +++ b/survival_sponsorship_compassion/models/res_partner.py @@ -57,11 +57,12 @@ def _get_survival_sponsorship_data(self): def _compute_active_csp_count(self): data, member_map = self._get_survival_sponsorship_data() for partner in self: - count = data.get(partner.id, {}).get('active_count', 0) + count = data.get(partner.id, {}).get("active_count", 0) if partner.is_church: - count += sum(data.get(mid, {}) - .get('active_count', 0) - for mid in member_map.get(partner.id, [])) + count += sum( + data.get(mid, {}).get("active_count", 0) + for mid in member_map.get(partner.id, []) + ) partner.survival_sponsorship_count = count def _compute_related_contracts(self): From 1c047f0745cf7bd8407d4141ba600409f2226dc5 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Thu, 18 Jun 2026 09:51:17 +0200 Subject: [PATCH 13/18] oups, remove data that was already there --- .../data/survival_product_template.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/survival_sponsorship_compassion/data/survival_product_template.xml b/survival_sponsorship_compassion/data/survival_product_template.xml index 845130d2b..8d1eb11c0 100644 --- a/survival_sponsorship_compassion/data/survival_product_template.xml +++ b/survival_sponsorship_compassion/data/survival_product_template.xml @@ -5,7 +5,6 @@ 62.0 service True - 62.0 From 49a874592f71985283c37a0c6ade3272f00b277f Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Thu, 18 Jun 2026 10:21:13 +0200 Subject: [PATCH 14/18] Fix after greptile comment --- .../models/contracts_report.py | 8 ++++++++ .../views/contracts_report_view.xml | 1 + 2 files changed, 9 insertions(+) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index bb5f4ed8d..911bca126 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -13,6 +13,12 @@ class PartnerSponsorshipReport(models.Model): 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.", + ) + 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", @@ -69,6 +75,8 @@ def _compute_sponsorship_metrics(self): 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 diff --git a/survival_sponsorship_compassion/views/contracts_report_view.xml b/survival_sponsorship_compassion/views/contracts_report_view.xml index d6b0bd283..c92f5f195 100644 --- a/survival_sponsorship_compassion/views/contracts_report_view.xml +++ b/survival_sponsorship_compassion/views/contracts_report_view.xml @@ -20,6 +20,7 @@ + From c3122c6af4b61ee685ae4cf8c2968df2b346bb50 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Mon, 22 Jun 2026 09:51:58 +0200 Subject: [PATCH 15/18] Fix greptil --- survival_sponsorship_compassion/models/contracts_report.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 911bca126..2d4c8a464 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -40,6 +40,10 @@ class PartnerSponsorshipReport(models.Model): ) def _compute_sponsorship_metrics(self): + # Early exit for empty recordsets + if not self: + return + """Orchestrator method to calculate and apply all report metrics.""" # 1. Initialize Default Values for partner in self: From 8c9bc2a35e4e4687be7a333c3f6704690fa9f15d Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Mon, 22 Jun 2026 10:24:15 +0200 Subject: [PATCH 16/18] Fix after greptil review --- sponsorship_compassion/i18n/de.po | 2 +- sponsorship_compassion/i18n/fr_CH.po | 2 +- sponsorship_compassion/i18n/it.po | 2 +- survival_sponsorship_compassion/models/contracts_report.py | 7 +++++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sponsorship_compassion/i18n/de.po b/sponsorship_compassion/i18n/de.po index b0a354a57..ac63141bc 100644 --- a/sponsorship_compassion/i18n/de.po +++ b/sponsorship_compassion/i18n/de.po @@ -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 Kinderpatenschaften" #. module: sponsorship_compassion diff --git a/sponsorship_compassion/i18n/fr_CH.po b/sponsorship_compassion/i18n/fr_CH.po index 09e6d0270..169384c50 100644 --- a/sponsorship_compassion/i18n/fr_CH.po +++ b/sponsorship_compassion/i18n/fr_CH.po @@ -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 d'enfants" #. module: sponsorship_compassion diff --git a/sponsorship_compassion/i18n/it.po b/sponsorship_compassion/i18n/it.po index 39ce7bf3c..7498fdc0e 100644 --- a/sponsorship_compassion/i18n/it.po +++ b/sponsorship_compassion/i18n/it.po @@ -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 per bambini" #. module: sponsorship_compassion diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 2d4c8a464..6112e889b 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -92,6 +92,13 @@ def _get_annual_cost_baseline(self): "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." + ) + monthly_cost = survival_tmpl.list_price return monthly_cost * 12 From 29dbd150de67e4a0a5c3f89f0109a4a95cd500fa Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Mon, 22 Jun 2026 10:35:30 +0200 Subject: [PATCH 17/18] Fix pre-commit... --- survival_sponsorship_compassion/models/contracts_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 6112e889b..661c82961 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -40,11 +40,11 @@ class PartnerSponsorshipReport(models.Model): ) def _compute_sponsorship_metrics(self): + """Orchestrator method to calculate and apply all report metrics.""" # Early exit for empty recordsets if not self: return - """Orchestrator method to calculate and apply all report metrics.""" # 1. Initialize Default Values for partner in self: partner.sr_survival_sponsorship_count = 0 From 573ad321e16f8b139252e9c6897488d9a9c49f79 Mon Sep 17 00:00:00 2001 From: Daniel Palumbo Date: Mon, 22 Jun 2026 11:07:01 +0200 Subject: [PATCH 18/18] Please greptil, stop adding things --- survival_sponsorship_compassion/models/contracts_report.py | 1 + 1 file changed, 1 insertion(+) diff --git a/survival_sponsorship_compassion/models/contracts_report.py b/survival_sponsorship_compassion/models/contracts_report.py index 661c82961..5d1157067 100644 --- a/survival_sponsorship_compassion/models/contracts_report.py +++ b/survival_sponsorship_compassion/models/contracts_report.py @@ -48,6 +48,7 @@ def _compute_sponsorship_metrics(self): # 1. Initialize Default Values 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