diff --git a/sponsorship_compassion/i18n/de.po b/sponsorship_compassion/i18n/de.po
index 44ca8612f..ac63141bc 100644
--- a/sponsorship_compassion/i18n/de.po
+++ b/sponsorship_compassion/i18n/de.po
@@ -1001,8 +1001,8 @@ 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"
-msgstr "Anzahl der Patenschaften"
+msgid "Number of child sponsorships"
+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..169384c50 100644
--- a/sponsorship_compassion/i18n/fr_CH.po
+++ b/sponsorship_compassion/i18n/fr_CH.po
@@ -1004,8 +1004,8 @@ 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"
-msgstr "Nombre de parrainages"
+msgid "Number of child sponsorships"
+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..7498fdc0e 100644
--- a/sponsorship_compassion/i18n/it.po
+++ b/sponsorship_compassion/i18n/it.po
@@ -1003,8 +1003,8 @@ 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"
-msgstr "Numero di sponsorizzazioni"
+msgid "Number of child sponsorships"
+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/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..305a4daf4 100644
--- a/sponsorship_compassion/views/contracts_report_view.xml
+++ b/sponsorship_compassion/views/contracts_report_view.xml
@@ -28,7 +28,10 @@
-
+
Survival Sponsorship
+ 62.0
service
True
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..5d1157067
--- /dev/null
+++ b/survival_sponsorship_compassion/models/contracts_report.py
@@ -0,0 +1,177 @@
+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 -> 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_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",
+ 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."""
+ # Early exit for empty recordsets
+ if not self:
+ return
+
+ # 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
+
+ # 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
+ )
+
+ 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 (for CSP only) from 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."
+ )
+
+ 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")
+ 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"]
+ 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"])
+
+ # 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 7e1b7f6a2..cea625de4 100644
--- a/survival_sponsorship_compassion/models/res_partner.py
+++ b/survival_sponsorship_compassion/models/res_partner.py
@@ -1,9 +1,70 @@
-from odoo import models
+from odoo import _, fields, models
class ResPartner(models.Model):
_inherit = "res.partner"
+ survival_sponsorship_count = fields.Integer(
+ string="Survival sponsorship(s)",
+ compute="_compute_active_csp_count",
+ )
+
+ 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_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_ids)), ("type", "=", "CSP")],
+ fields=["partner_id", "state", "csp_country"],
+ groupby=["partner_id", "state", "csp_country"],
+ lazy=False,
+ )
+
+ # 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
+ }
+
+ 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 = 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, [])
+ )
+ partner.survival_sponsorship_count = count
+
def _compute_related_contracts(self):
super()._compute_related_contracts()
contract_obj = self.env["recurring.contract"]
@@ -35,3 +96,26 @@ 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()
+
+ return {
+ "name": _("Survival Sponsorships"),
+ "type": "ir.actions.act_window",
+ "res_model": "recurring.contract",
+ "view_mode": "tree,form",
+ "domain": [
+ ("type", "=", "CSP"),
+ ("state", "=", "active"),
+ "|",
+ ("partner_id", "=", self.id),
+ ("partner_id.church_id", "=", self.id),
+ ],
+ "context": {
+ "create": False,
+ "default_type": "CSP",
+ "default_partner_id": self.id,
+ },
+ "target": "current",
+ }
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..c92f5f195
--- /dev/null
+++ b/survival_sponsorship_compassion/views/contracts_report_view.xml
@@ -0,0 +1,32 @@
+
+
+
+
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..763a23814
--- /dev/null
+++ b/survival_sponsorship_compassion/views/res_partner_view.xml
@@ -0,0 +1,32 @@
+
+
+ res.partner.survival.sponsorships.form
+ res.partner
+
+
+
+
+
+
+
+
+
+