diff --git a/spp_programs/models/cycle.py b/spp_programs/models/cycle.py index 4dbade7f..3cf0b6f5 100644 --- a/spp_programs/models/cycle.py +++ b/spp_programs/models/cycle.py @@ -238,8 +238,22 @@ def _check_unique_name_per_program(self): @api.depends("entitlement_ids") def _compute_total_amount(self): + if not self.ids: + for rec in self: + rec.total_amount = 0 + return + self.env.cr.execute( + """ + SELECT cycle_id, COALESCE(SUM(initial_amount), 0) + FROM spp_entitlement + WHERE cycle_id IN %s + GROUP BY cycle_id + """, + (tuple(self.ids),), + ) + totals = dict(self.env.cr.fetchall()) for rec in self: - rec.total_amount = sum(entitlement.initial_amount for entitlement in rec.entitlement_ids) + rec.total_amount = totals.get(rec.id, 0) @api.depends("total_amount", "currency_id") def _compute_total_amount_in_words(self): @@ -263,8 +277,33 @@ def _compute_entitlements_count(self): @api.depends("entitlement_ids", "inkind_entitlement_ids") def _compute_total_entitlements_count(self): + if not self.ids: + for rec in self: + rec.total_entitlements_count = 0 + return + cycle_ids = tuple(self.ids) + self.env.cr.execute( + """ + SELECT cycle_id, COUNT(*) + FROM spp_entitlement + WHERE cycle_id IN %s + GROUP BY cycle_id + """, + (cycle_ids,), + ) + cash_counts = dict(self.env.cr.fetchall()) + self.env.cr.execute( + """ + SELECT cycle_id, COUNT(*) + FROM spp_entitlement_inkind + WHERE cycle_id IN %s + GROUP BY cycle_id + """, + (cycle_ids,), + ) + inkind_counts = dict(self.env.cr.fetchall()) for rec in self: - rec.total_entitlements_count = len(rec.entitlement_ids) + len(rec.inkind_entitlement_ids) + rec.total_entitlements_count = cash_counts.get(rec.id, 0) + inkind_counts.get(rec.id, 0) def _compute_payments_count(self): for rec in self: @@ -274,11 +313,24 @@ def _compute_payments_count(self): @api.depends("entitlement_ids.state", "inkind_entitlement_ids.state") def _compute_show_approve_entitlement(self): """Show the 'Validate Entitlements' button when there are entitlements pending validation.""" + if not self.ids: + for rec in self: + rec.show_approve_entitlements_button = False + return + cycle_ids = tuple(self.ids) + self.env.cr.execute( + """ + SELECT DISTINCT cycle_id FROM spp_entitlement + WHERE cycle_id IN %s AND state = 'pending_validation' + UNION + SELECT DISTINCT cycle_id FROM spp_entitlement_inkind + WHERE cycle_id IN %s AND state = 'pending_validation' + """, + (cycle_ids, cycle_ids), + ) + pending_cycle_ids = {row[0] for row in self.env.cr.fetchall()} for rec in self: - # Show button if there are any cash or in-kind entitlements in pending_validation state - cash_pending = any(ent.state == "pending_validation" for ent in rec.entitlement_ids) - inkind_pending = any(ent.state == "pending_validation" for ent in rec.inkind_entitlement_ids) - rec.show_approve_entitlements_button = cash_pending or inkind_pending + rec.show_approve_entitlements_button = rec.id in pending_cycle_ids @api.depends("program_id", "entitlement_ids.state", "inkind_entitlement_ids.state") def _compute_can_approve_entitlements(self): @@ -377,20 +429,40 @@ def _compute_has_payment_manager(self): @api.depends("entitlement_ids.state", "inkind_entitlement_ids.state") def _compute_all_entitlements_approved(self): """Check if all entitlements have been approved.""" + if not self.ids: + for rec in self: + rec.all_entitlements_approved = False + return + cycle_ids = tuple(self.ids) + # Find cycles that have at least one entitlement (cash or inkind) + self.env.cr.execute( + """ + SELECT DISTINCT cycle_id FROM spp_entitlement + WHERE cycle_id IN %s + UNION + SELECT DISTINCT cycle_id FROM spp_entitlement_inkind + WHERE cycle_id IN %s + """, + (cycle_ids, cycle_ids), + ) + cycles_with_entitlements = {row[0] for row in self.env.cr.fetchall()} + # Find cycles that have any non-approved entitlement + self.env.cr.execute( + """ + SELECT DISTINCT cycle_id FROM spp_entitlement + WHERE cycle_id IN %s AND state != 'approved' + UNION + SELECT DISTINCT cycle_id FROM spp_entitlement_inkind + WHERE cycle_id IN %s AND state != 'approved' + """, + (cycle_ids, cycle_ids), + ) + cycles_with_unapproved = {row[0] for row in self.env.cr.fetchall()} for rec in self: - has_entitlements = rec.entitlement_ids or rec.inkind_entitlement_ids - if not has_entitlements: + if rec.id not in cycles_with_entitlements: rec.all_entitlements_approved = False - continue - all_cash_approved = ( - all(ent.state == "approved" for ent in rec.entitlement_ids) if rec.entitlement_ids else True - ) - all_inkind_approved = ( - all(ent.state == "approved" for ent in rec.inkind_entitlement_ids) - if rec.inkind_entitlement_ids - else True - ) - rec.all_entitlements_approved = all_cash_approved and all_inkind_approved + else: + rec.all_entitlements_approved = rec.id not in cycles_with_unapproved @api.depends("program_id") def _compute_entitlement_type(self): diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py index c56ebc92..7808e9b6 100644 --- a/spp_programs/tests/__init__.py +++ b/spp_programs/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_eligibility_cel_integration from . import test_compliance_cel from . import test_create_program_wizard_cel +from . import test_cycle_computed_fields diff --git a/spp_programs/tests/test_cycle_computed_fields.py b/spp_programs/tests/test_cycle_computed_fields.py new file mode 100644 index 00000000..943c955c --- /dev/null +++ b/spp_programs/tests/test_cycle_computed_fields.py @@ -0,0 +1,144 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import uuid + +from odoo import fields +from odoo.tests import TransactionCase + + +class TestCycleComputedFields(TransactionCase): + """Test that SQL-optimized cycle computed fields return correct results. + + These fields were migrated from Python iteration over recordsets to + SQL aggregation for O(1) instead of O(N) per cycle. + """ + + def setUp(self): + super().setUp() + self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"}) + self.cycle = self.env["spp.cycle"].create( + { + "name": "Test Cycle", + "program_id": self.program.id, + "start_date": fields.Date.today(), + "end_date": fields.Date.today(), + } + ) + self.registrant1 = self.env["res.partner"].create({"name": "Registrant 1", "is_registrant": True}) + self.registrant2 = self.env["res.partner"].create({"name": "Registrant 2", "is_registrant": True}) + self.registrant3 = self.env["res.partner"].create({"name": "Registrant 3", "is_registrant": True}) + + def _create_entitlement(self, partner, amount, state="draft"): + ent = self.env["spp.entitlement"].create( + { + "partner_id": partner.id, + "cycle_id": self.cycle.id, + "initial_amount": amount, + } + ) + if state != "draft": + ent.write({"state": state}) + return ent + + # -- total_amount -- + + def test_total_amount_empty(self): + """Cycle with no entitlements has total_amount = 0.""" + self.cycle.invalidate_recordset(["total_amount"]) + self.assertEqual(self.cycle.total_amount, 0) + + def test_total_amount_sums_initial_amounts(self): + """total_amount must equal sum of all entitlement initial_amounts.""" + self._create_entitlement(self.registrant1, 100.0) + self._create_entitlement(self.registrant2, 250.50) + self._create_entitlement(self.registrant3, 49.50) + self.cycle.invalidate_recordset(["total_amount"]) + self.assertAlmostEqual(self.cycle.total_amount, 400.0) + + # -- total_entitlements_count -- + + def test_total_entitlements_count_empty(self): + """Cycle with no entitlements has count = 0.""" + self.cycle.invalidate_recordset(["total_entitlements_count"]) + self.assertEqual(self.cycle.total_entitlements_count, 0) + + def test_total_entitlements_count_correct(self): + """Count must include all cash entitlements.""" + self._create_entitlement(self.registrant1, 100.0) + self._create_entitlement(self.registrant2, 200.0) + self.cycle.invalidate_recordset(["total_entitlements_count"]) + self.assertEqual(self.cycle.total_entitlements_count, 2) + + # -- show_approve_entitlements_button -- + + def test_show_approve_no_entitlements(self): + """Button hidden when no entitlements exist.""" + self.cycle.invalidate_recordset(["show_approve_entitlements_button"]) + self.assertFalse(self.cycle.show_approve_entitlements_button) + + def test_show_approve_with_pending(self): + """Button shown when pending_validation entitlements exist.""" + self._create_entitlement(self.registrant1, 100.0, state="pending_validation") + self.cycle.invalidate_recordset(["show_approve_entitlements_button"]) + self.assertTrue(self.cycle.show_approve_entitlements_button) + + def test_show_approve_only_draft(self): + """Button hidden when all entitlements are draft.""" + self._create_entitlement(self.registrant1, 100.0, state="draft") + self.cycle.invalidate_recordset(["show_approve_entitlements_button"]) + self.assertFalse(self.cycle.show_approve_entitlements_button) + + def test_show_approve_only_approved(self): + """Button hidden when all entitlements are approved.""" + self._create_entitlement(self.registrant1, 100.0, state="approved") + self.cycle.invalidate_recordset(["show_approve_entitlements_button"]) + self.assertFalse(self.cycle.show_approve_entitlements_button) + + # -- all_entitlements_approved -- + + def test_all_approved_empty(self): + """No entitlements => not all approved (nothing to approve).""" + self.cycle.invalidate_recordset(["all_entitlements_approved"]) + self.assertFalse(self.cycle.all_entitlements_approved) + + def test_all_approved_when_all_approved(self): + """True when every entitlement has state=approved.""" + self._create_entitlement(self.registrant1, 100.0, state="approved") + self._create_entitlement(self.registrant2, 200.0, state="approved") + self.cycle.invalidate_recordset(["all_entitlements_approved"]) + self.assertTrue(self.cycle.all_entitlements_approved) + + def test_all_approved_mixed_states(self): + """False when some entitlements are not approved.""" + self._create_entitlement(self.registrant1, 100.0, state="approved") + self._create_entitlement(self.registrant2, 200.0, state="draft") + self.cycle.invalidate_recordset(["all_entitlements_approved"]) + self.assertFalse(self.cycle.all_entitlements_approved) + + # -- multi-cycle batching -- + + def test_computed_fields_multi_cycle(self): + """SQL queries must handle multiple cycles in a single batch.""" + cycle2 = self.env["spp.cycle"].create( + { + "name": "Test Cycle 2", + "program_id": self.program.id, + "start_date": fields.Date.today(), + "end_date": fields.Date.today(), + } + ) + self._create_entitlement(self.registrant1, 100.0) + self.env["spp.entitlement"].create( + { + "partner_id": self.registrant2.id, + "cycle_id": cycle2.id, + "initial_amount": 300.0, + } + ) + + cycles = self.cycle | cycle2 + cycles.invalidate_recordset(["total_amount", "total_entitlements_count"]) + + self.assertAlmostEqual(self.cycle.total_amount, 100.0) + self.assertAlmostEqual(cycle2.total_amount, 300.0) + self.assertEqual(self.cycle.total_entitlements_count, 1) + self.assertEqual(cycle2.total_entitlements_count, 1)