Skip to content

Commit fbaf3a3

Browse files
Merge pull request #119 from OpenSPP/ken/optimize_spp_program_phase_3
perf(spp_programs): replace cycle computed fields with SQL aggregation
2 parents 264f081 + e0fa087 commit fbaf3a3

File tree

7 files changed

+256
-21
lines changed

7 files changed

+256
-21
lines changed

spp_programs/README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,12 @@ Dependencies
254254
Changelog
255255
=========
256256

257+
19.0.2.0.3
258+
~~~~~~~~~~
259+
260+
- Replace cycle computed fields (total_amount, entitlements_count,
261+
approval flags) with SQL aggregation queries
262+
257263
19.0.2.0.2
258264
~~~~~~~~~~
259265

spp_programs/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "OpenSPP Programs",
55
"summary": "Manage cash and in-kind entitlements, integrate with inventory, and enhance program management features for comprehensive social protection and agricultural support.",
66
"category": "OpenSPP/Core",
7-
"version": "19.0.2.0.2",
7+
"version": "19.0.2.0.3",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",

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/readme/HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 19.0.2.0.3
2+
3+
- Replace cycle computed fields (total_amount, entitlements_count, approval flags) with SQL aggregation queries
4+
15
### 19.0.2.0.2
26

37
- Add composite indexes for frequent query patterns on entitlements and program memberships

spp_programs/static/description/index.html

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,13 +658,20 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
658658
</div>
659659
</div>
660660
<div class="section" id="section-1">
661+
<h1>19.0.2.0.3</h1>
662+
<ul class="simple">
663+
<li>Replace cycle computed fields (total_amount, entitlements_count,
664+
approval flags) with SQL aggregation queries</li>
665+
</ul>
666+
</div>
667+
<div class="section" id="section-2">
661668
<h1>19.0.2.0.2</h1>
662669
<ul class="simple">
663670
<li>Add composite indexes for frequent query patterns on entitlements and
664671
program memberships</li>
665672
</ul>
666673
</div>
667-
<div class="section" id="section-2">
674+
<div class="section" id="section-3">
668675
<h1>19.0.2.0.1</h1>
669676
<ul class="simple">
670677
<li>Replace Python-level uniqueness checks with SQL UNIQUE constraints for
@@ -673,7 +680,7 @@ <h1>19.0.2.0.1</h1>
673680
constraint creation</li>
674681
</ul>
675682
</div>
676-
<div class="section" id="section-3">
683+
<div class="section" id="section-4">
677684
<h1>19.0.2.0.0</h1>
678685
<ul class="simple">
679686
<li>Initial migration to OpenSPP2</li>

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: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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.state = state
40+
ent.flush_recordset()
41+
return ent
42+
43+
# -- total_amount --
44+
45+
def test_total_amount_empty(self):
46+
"""Cycle with no entitlements has total_amount = 0."""
47+
self.cycle.invalidate_recordset(["total_amount"])
48+
self.assertEqual(self.cycle.total_amount, 0)
49+
50+
def test_total_amount_sums_initial_amounts(self):
51+
"""total_amount must equal sum of all entitlement initial_amounts."""
52+
self._create_entitlement(self.registrant1, 100.0)
53+
self._create_entitlement(self.registrant2, 250.50)
54+
self._create_entitlement(self.registrant3, 49.50)
55+
self.cycle.invalidate_recordset(["total_amount"])
56+
self.assertAlmostEqual(self.cycle.total_amount, 400.0)
57+
58+
# -- total_entitlements_count --
59+
60+
def test_total_entitlements_count_empty(self):
61+
"""Cycle with no entitlements has count = 0."""
62+
self.cycle.invalidate_recordset(["total_entitlements_count"])
63+
self.assertEqual(self.cycle.total_entitlements_count, 0)
64+
65+
def test_total_entitlements_count_correct(self):
66+
"""Count must include all cash entitlements."""
67+
self._create_entitlement(self.registrant1, 100.0)
68+
self._create_entitlement(self.registrant2, 200.0)
69+
self.cycle.invalidate_recordset(["total_entitlements_count"])
70+
self.assertEqual(self.cycle.total_entitlements_count, 2)
71+
72+
# -- show_approve_entitlements_button --
73+
74+
def test_show_approve_no_entitlements(self):
75+
"""Button hidden when no entitlements exist."""
76+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
77+
self.assertFalse(self.cycle.show_approve_entitlements_button)
78+
79+
def test_show_approve_with_pending(self):
80+
"""Button shown when pending_validation entitlements exist."""
81+
self._create_entitlement(self.registrant1, 100.0, state="pending_validation")
82+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
83+
self.assertTrue(self.cycle.show_approve_entitlements_button)
84+
85+
def test_show_approve_only_draft(self):
86+
"""Button hidden when all entitlements are draft."""
87+
self._create_entitlement(self.registrant1, 100.0, state="draft")
88+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
89+
self.assertFalse(self.cycle.show_approve_entitlements_button)
90+
91+
def test_show_approve_only_approved(self):
92+
"""Button hidden when all entitlements are approved."""
93+
self._create_entitlement(self.registrant1, 100.0, state="approved")
94+
self.cycle.invalidate_recordset(["show_approve_entitlements_button"])
95+
self.assertFalse(self.cycle.show_approve_entitlements_button)
96+
97+
# -- all_entitlements_approved --
98+
99+
def test_all_approved_empty(self):
100+
"""No entitlements => not all approved (nothing to approve)."""
101+
self.cycle.invalidate_recordset(["all_entitlements_approved"])
102+
self.assertFalse(self.cycle.all_entitlements_approved)
103+
104+
def test_all_approved_when_all_approved(self):
105+
"""True when every entitlement has state=approved."""
106+
self._create_entitlement(self.registrant1, 100.0, state="approved")
107+
self._create_entitlement(self.registrant2, 200.0, state="approved")
108+
self.cycle.invalidate_recordset(["all_entitlements_approved"])
109+
self.assertTrue(self.cycle.all_entitlements_approved)
110+
111+
def test_all_approved_mixed_states(self):
112+
"""False when some entitlements are not approved."""
113+
self._create_entitlement(self.registrant1, 100.0, state="approved")
114+
self._create_entitlement(self.registrant2, 200.0, state="draft")
115+
self.cycle.invalidate_recordset(["all_entitlements_approved"])
116+
self.assertFalse(self.cycle.all_entitlements_approved)
117+
118+
# -- multi-cycle batching --
119+
120+
def test_computed_fields_multi_cycle(self):
121+
"""SQL queries must handle multiple cycles in a single batch."""
122+
cycle2 = self.env["spp.cycle"].create(
123+
{
124+
"name": "Test Cycle 2",
125+
"program_id": self.program.id,
126+
"start_date": fields.Date.today(),
127+
"end_date": fields.Date.today(),
128+
}
129+
)
130+
self._create_entitlement(self.registrant1, 100.0)
131+
self.env["spp.entitlement"].create(
132+
{
133+
"partner_id": self.registrant2.id,
134+
"cycle_id": cycle2.id,
135+
"initial_amount": 300.0,
136+
}
137+
)
138+
139+
cycles = self.cycle | cycle2
140+
cycles.invalidate_recordset(["total_amount", "total_entitlements_count"])
141+
142+
self.assertAlmostEqual(self.cycle.total_amount, 100.0)
143+
self.assertAlmostEqual(cycle2.total_amount, 300.0)
144+
self.assertEqual(self.cycle.total_entitlements_count, 1)
145+
self.assertEqual(cycle2.total_entitlements_count, 1)

0 commit comments

Comments
 (0)