Skip to content

Commit e4b81a4

Browse files
kneckinatorgonzalesedwin1123
authored andcommitted
perf(spp_programs): replace cycle computed fields with SQL aggregation
total_amount, total_entitlements_count, show_approve_entitlements_button, and all_entitlements_approved now use SQL queries instead of iterating over loaded entitlement recordsets. This avoids loading all entitlements into memory just to compute summary statistics.
1 parent 264f081 commit e4b81a4

File tree

3 files changed

+235
-18
lines changed

3 files changed

+235
-18
lines changed

spp_programs/models/cycle.py

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,22 @@ def _check_unique_name_per_program(self):
238238

239239
@api.depends("entitlement_ids")
240240
def _compute_total_amount(self):
241+
if not self.ids:
242+
for rec in self:
243+
rec.total_amount = 0
244+
return
245+
self.env.cr.execute(
246+
"""
247+
SELECT cycle_id, COALESCE(SUM(initial_amount), 0)
248+
FROM spp_entitlement
249+
WHERE cycle_id IN %s
250+
GROUP BY cycle_id
251+
""",
252+
(tuple(self.ids),),
253+
)
254+
totals = dict(self.env.cr.fetchall())
241255
for rec in self:
242-
rec.total_amount = sum(entitlement.initial_amount for entitlement in rec.entitlement_ids)
256+
rec.total_amount = totals.get(rec.id, 0)
243257

244258
@api.depends("total_amount", "currency_id")
245259
def _compute_total_amount_in_words(self):
@@ -263,8 +277,33 @@ def _compute_entitlements_count(self):
263277

264278
@api.depends("entitlement_ids", "inkind_entitlement_ids")
265279
def _compute_total_entitlements_count(self):
280+
if not self.ids:
281+
for rec in self:
282+
rec.total_entitlements_count = 0
283+
return
284+
cycle_ids = tuple(self.ids)
285+
self.env.cr.execute(
286+
"""
287+
SELECT cycle_id, COUNT(*)
288+
FROM spp_entitlement
289+
WHERE cycle_id IN %s
290+
GROUP BY cycle_id
291+
""",
292+
(cycle_ids,),
293+
)
294+
cash_counts = dict(self.env.cr.fetchall())
295+
self.env.cr.execute(
296+
"""
297+
SELECT cycle_id, COUNT(*)
298+
FROM spp_entitlement_inkind
299+
WHERE cycle_id IN %s
300+
GROUP BY cycle_id
301+
""",
302+
(cycle_ids,),
303+
)
304+
inkind_counts = dict(self.env.cr.fetchall())
266305
for rec in self:
267-
rec.total_entitlements_count = len(rec.entitlement_ids) + len(rec.inkind_entitlement_ids)
306+
rec.total_entitlements_count = cash_counts.get(rec.id, 0) + inkind_counts.get(rec.id, 0)
268307

269308
def _compute_payments_count(self):
270309
for rec in self:
@@ -274,11 +313,24 @@ def _compute_payments_count(self):
274313
@api.depends("entitlement_ids.state", "inkind_entitlement_ids.state")
275314
def _compute_show_approve_entitlement(self):
276315
"""Show the 'Validate Entitlements' button when there are entitlements pending validation."""
316+
if not self.ids:
317+
for rec in self:
318+
rec.show_approve_entitlements_button = False
319+
return
320+
cycle_ids = tuple(self.ids)
321+
self.env.cr.execute(
322+
"""
323+
SELECT DISTINCT cycle_id FROM spp_entitlement
324+
WHERE cycle_id IN %s AND state = 'pending_validation'
325+
UNION
326+
SELECT DISTINCT cycle_id FROM spp_entitlement_inkind
327+
WHERE cycle_id IN %s AND state = 'pending_validation'
328+
""",
329+
(cycle_ids, cycle_ids),
330+
)
331+
pending_cycle_ids = {row[0] for row in self.env.cr.fetchall()}
277332
for rec in self:
278-
# Show button if there are any cash or in-kind entitlements in pending_validation state
279-
cash_pending = any(ent.state == "pending_validation" for ent in rec.entitlement_ids)
280-
inkind_pending = any(ent.state == "pending_validation" for ent in rec.inkind_entitlement_ids)
281-
rec.show_approve_entitlements_button = cash_pending or inkind_pending
333+
rec.show_approve_entitlements_button = rec.id in pending_cycle_ids
282334

283335
@api.depends("program_id", "entitlement_ids.state", "inkind_entitlement_ids.state")
284336
def _compute_can_approve_entitlements(self):
@@ -377,20 +429,40 @@ def _compute_has_payment_manager(self):
377429
@api.depends("entitlement_ids.state", "inkind_entitlement_ids.state")
378430
def _compute_all_entitlements_approved(self):
379431
"""Check if all entitlements have been approved."""
432+
if not self.ids:
433+
for rec in self:
434+
rec.all_entitlements_approved = False
435+
return
436+
cycle_ids = tuple(self.ids)
437+
# Find cycles that have at least one entitlement (cash or inkind)
438+
self.env.cr.execute(
439+
"""
440+
SELECT DISTINCT cycle_id FROM spp_entitlement
441+
WHERE cycle_id IN %s
442+
UNION
443+
SELECT DISTINCT cycle_id FROM spp_entitlement_inkind
444+
WHERE cycle_id IN %s
445+
""",
446+
(cycle_ids, cycle_ids),
447+
)
448+
cycles_with_entitlements = {row[0] for row in self.env.cr.fetchall()}
449+
# Find cycles that have any non-approved entitlement
450+
self.env.cr.execute(
451+
"""
452+
SELECT DISTINCT cycle_id FROM spp_entitlement
453+
WHERE cycle_id IN %s AND state != 'approved'
454+
UNION
455+
SELECT DISTINCT cycle_id FROM spp_entitlement_inkind
456+
WHERE cycle_id IN %s AND state != 'approved'
457+
""",
458+
(cycle_ids, cycle_ids),
459+
)
460+
cycles_with_unapproved = {row[0] for row in self.env.cr.fetchall()}
380461
for rec in self:
381-
has_entitlements = rec.entitlement_ids or rec.inkind_entitlement_ids
382-
if not has_entitlements:
462+
if rec.id not in cycles_with_entitlements:
383463
rec.all_entitlements_approved = False
384-
continue
385-
all_cash_approved = (
386-
all(ent.state == "approved" for ent in rec.entitlement_ids) if rec.entitlement_ids else True
387-
)
388-
all_inkind_approved = (
389-
all(ent.state == "approved" for ent in rec.inkind_entitlement_ids)
390-
if rec.inkind_entitlement_ids
391-
else True
392-
)
393-
rec.all_entitlements_approved = all_cash_approved and all_inkind_approved
464+
else:
465+
rec.all_entitlements_approved = rec.id not in cycles_with_unapproved
394466

395467
@api.depends("program_id")
396468
def _compute_entitlement_type(self):

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@
2020
from . import test_sql_constraints
2121
from . import test_stock_rule
2222
from . import test_composite_indexes
23+
from . import test_cycle_computed_fields
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
import uuid
3+
4+
from odoo import fields
5+
from odoo.tests import TransactionCase
6+
7+
8+
class TestCycleComputedFields(TransactionCase):
9+
"""Test that SQL-optimized cycle computed fields return correct results.
10+
11+
These fields were migrated from Python iteration over recordsets to
12+
SQL aggregation for O(1) instead of O(N) per cycle.
13+
"""
14+
15+
def setUp(self):
16+
super().setUp()
17+
self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
18+
self.cycle = self.env["spp.cycle"].create(
19+
{
20+
"name": "Test Cycle",
21+
"program_id": self.program.id,
22+
"start_date": fields.Date.today(),
23+
"end_date": fields.Date.today(),
24+
}
25+
)
26+
self.registrant1 = self.env["res.partner"].create({"name": "Registrant 1", "is_registrant": True})
27+
self.registrant2 = self.env["res.partner"].create({"name": "Registrant 2", "is_registrant": True})
28+
self.registrant3 = self.env["res.partner"].create({"name": "Registrant 3", "is_registrant": True})
29+
30+
def _create_entitlement(self, partner, amount, state="draft"):
31+
ent = self.env["spp.entitlement"].create(
32+
{
33+
"partner_id": partner.id,
34+
"cycle_id": self.cycle.id,
35+
"initial_amount": amount,
36+
}
37+
)
38+
if state != "draft":
39+
ent.write({"state": state})
40+
return ent
41+
42+
# -- total_amount --
43+
44+
def test_total_amount_empty(self):
45+
"""Cycle with no entitlements has total_amount = 0."""
46+
self.cycle.invalidate_recordset(["total_amount"])
47+
self.assertEqual(self.cycle.total_amount, 0)
48+
49+
def test_total_amount_sums_initial_amounts(self):
50+
"""total_amount must equal sum of all entitlement initial_amounts."""
51+
self._create_entitlement(self.registrant1, 100.0)
52+
self._create_entitlement(self.registrant2, 250.50)
53+
self._create_entitlement(self.registrant3, 49.50)
54+
self.cycle.invalidate_recordset(["total_amount"])
55+
self.assertAlmostEqual(self.cycle.total_amount, 400.0)
56+
57+
# -- total_entitlements_count --
58+
59+
def test_total_entitlements_count_empty(self):
60+
"""Cycle with no entitlements has count = 0."""
61+
self.cycle.invalidate_recordset(["total_entitlements_count"])
62+
self.assertEqual(self.cycle.total_entitlements_count, 0)
63+
64+
def test_total_entitlements_count_correct(self):
65+
"""Count must include all cash entitlements."""
66+
self._create_entitlement(self.registrant1, 100.0)
67+
self._create_entitlement(self.registrant2, 200.0)
68+
self.cycle.invalidate_recordset(["total_entitlements_count"])
69+
self.assertEqual(self.cycle.total_entitlements_count, 2)
70+
71+
# -- show_approve_entitlements_button --
72+
73+
def test_show_approve_no_entitlements(self):
74+
"""Button hidden when no entitlements exist."""
75+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
76+
self.assertFalse(self.cycle.show_approve_entitlements_button)
77+
78+
def test_show_approve_with_pending(self):
79+
"""Button shown when pending_validation entitlements exist."""
80+
self._create_entitlement(self.registrant1, 100.0, state="pending_validation")
81+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
82+
self.assertTrue(self.cycle.show_approve_entitlements_button)
83+
84+
def test_show_approve_only_draft(self):
85+
"""Button hidden when all entitlements are draft."""
86+
self._create_entitlement(self.registrant1, 100.0, state="draft")
87+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
88+
self.assertFalse(self.cycle.show_approve_entitlements_button)
89+
90+
def test_show_approve_only_approved(self):
91+
"""Button hidden when all entitlements are approved."""
92+
self._create_entitlement(self.registrant1, 100.0, state="approved")
93+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
94+
self.assertFalse(self.cycle.show_approve_entitlements_button)
95+
96+
# -- all_entitlements_approved --
97+
98+
def test_all_approved_empty(self):
99+
"""No entitlements => not all approved (nothing to approve)."""
100+
self.cycle.invalidate_recordset(["all_entitlements_approved"])
101+
self.assertFalse(self.cycle.all_entitlements_approved)
102+
103+
def test_all_approved_when_all_approved(self):
104+
"""True when every entitlement has state=approved."""
105+
self._create_entitlement(self.registrant1, 100.0, state="approved")
106+
self._create_entitlement(self.registrant2, 200.0, state="approved")
107+
self.cycle.invalidate_recordset(["all_entitlements_approved"])
108+
self.assertTrue(self.cycle.all_entitlements_approved)
109+
110+
def test_all_approved_mixed_states(self):
111+
"""False when some entitlements are not approved."""
112+
self._create_entitlement(self.registrant1, 100.0, state="approved")
113+
self._create_entitlement(self.registrant2, 200.0, state="draft")
114+
self.cycle.invalidate_recordset(["all_entitlements_approved"])
115+
self.assertFalse(self.cycle.all_entitlements_approved)
116+
117+
# -- multi-cycle batching --
118+
119+
def test_computed_fields_multi_cycle(self):
120+
"""SQL queries must handle multiple cycles in a single batch."""
121+
cycle2 = self.env["spp.cycle"].create(
122+
{
123+
"name": "Test Cycle 2",
124+
"program_id": self.program.id,
125+
"start_date": fields.Date.today(),
126+
"end_date": fields.Date.today(),
127+
}
128+
)
129+
self._create_entitlement(self.registrant1, 100.0)
130+
self.env["spp.entitlement"].create(
131+
{
132+
"partner_id": self.registrant2.id,
133+
"cycle_id": cycle2.id,
134+
"initial_amount": 300.0,
135+
}
136+
)
137+
138+
cycles = self.cycle | cycle2
139+
cycles.invalidate_recordset(["total_amount", "total_entitlements_count"])
140+
141+
self.assertAlmostEqual(self.cycle.total_amount, 100.0)
142+
self.assertAlmostEqual(cycle2.total_amount, 300.0)
143+
self.assertEqual(self.cycle.total_entitlements_count, 1)
144+
self.assertEqual(cycle2.total_entitlements_count, 1)

0 commit comments

Comments
 (0)