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 @@ + + + + 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 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 + + + + + + + + + +