Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 90 additions & 18 deletions spp_programs/models/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +241 to +256

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation for handling new records (without IDs) is a bit complex. A more robust and common pattern in Odoo for computed fields that use SQL is to first set a default value for all records, then filter for records with IDs to perform the query. This makes the code clearer and safer, especially for empty recordsets. This same feedback applies to _compute_total_entitlements_count, _compute_show_approve_entitlement, and _compute_all_entitlements_approved as well.

Suggested change
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)
self.total_amount = 0
recs_with_ids = self.filtered('id')
if not recs_with_ids:
return
# Query for records with IDs
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(recs_with_ids.ids),),
)
totals = dict(self.env.cr.fetchall())
for rec in recs_with_ids:
rec.total_amount = totals.get(rec.id, 0)


@api.depends("total_amount", "currency_id")
def _compute_total_amount_in_words(self):
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions spp_programs/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
144 changes: 144 additions & 0 deletions spp_programs/tests/test_cycle_computed_fields.py
Original file line number Diff line number Diff line change
@@ -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)
Loading