Skip to content

Commit 622e4b8

Browse files
committed
perf(spp_programs): replace Python uniqueness checks with SQL constraints
Python @api.constrains methods performed per-record search() calls during bulk create, causing O(N^2) behavior. SQL UNIQUE constraints enforce uniqueness at the database level in O(1) per row. Also add the entitlement code constraint to entitlement.py (the imported model) since entitlement_base_model.py is not imported in __init__.py. Includes pre-migration to deduplicate existing data before constraints apply.
1 parent 66c1264 commit 622e4b8

8 files changed

Lines changed: 369 additions & 69 deletions

File tree

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.0",
7+
"version": "19.0.2.1.0",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
import logging
3+
4+
_logger = logging.getLogger(__name__)
5+
6+
7+
def migrate(cr, version):
8+
"""Deduplicate data before adding SQL UNIQUE constraints.
9+
10+
Removes duplicate rows using ROW_NUMBER() OVER (PARTITION BY ...),
11+
keeping the earliest record (lowest id) for each unique combination.
12+
"""
13+
if not version:
14+
return
15+
16+
_deduplicate_program_memberships(cr)
17+
_deduplicate_cycle_memberships(cr)
18+
_deduplicate_entitlement_codes(cr)
19+
20+
21+
def _deduplicate_program_memberships(cr):
22+
"""Remove duplicate (partner_id, program_id) rows from spp_program_membership."""
23+
cr.execute(
24+
"""
25+
DELETE FROM spp_program_membership
26+
WHERE id IN (
27+
SELECT id FROM (
28+
SELECT id,
29+
ROW_NUMBER() OVER (
30+
PARTITION BY partner_id, program_id
31+
ORDER BY id
32+
) AS rn
33+
FROM spp_program_membership
34+
) sub
35+
WHERE rn > 1
36+
)
37+
"""
38+
)
39+
if cr.rowcount:
40+
_logger.info("Deduplicated %d duplicate program membership rows", cr.rowcount)
41+
42+
43+
def _deduplicate_cycle_memberships(cr):
44+
"""Remove duplicate (partner_id, cycle_id) rows from spp_cycle_membership."""
45+
cr.execute(
46+
"""
47+
DELETE FROM spp_cycle_membership
48+
WHERE id IN (
49+
SELECT id FROM (
50+
SELECT id,
51+
ROW_NUMBER() OVER (
52+
PARTITION BY partner_id, cycle_id
53+
ORDER BY id
54+
) AS rn
55+
FROM spp_cycle_membership
56+
) sub
57+
WHERE rn > 1
58+
)
59+
"""
60+
)
61+
if cr.rowcount:
62+
_logger.info("Deduplicated %d duplicate cycle membership rows", cr.rowcount)
63+
64+
65+
def _deduplicate_entitlement_codes(cr):
66+
"""Remove duplicate code values from spp_entitlement.
67+
68+
For duplicate codes, regenerates codes for the newer records rather
69+
than deleting them, since entitlements may have financial significance.
70+
"""
71+
cr.execute(
72+
"""
73+
UPDATE spp_entitlement
74+
SET code = code || '-' || id::text
75+
WHERE id IN (
76+
SELECT id FROM (
77+
SELECT id,
78+
ROW_NUMBER() OVER (
79+
PARTITION BY code
80+
ORDER BY id
81+
) AS rn
82+
FROM spp_entitlement
83+
WHERE code IS NOT NULL
84+
) sub
85+
WHERE rn > 1
86+
)
87+
"""
88+
)
89+
if cr.rowcount:
90+
_logger.info("Deduplicated %d entitlement rows with duplicate codes", cr.rowcount)

spp_programs/models/cycle_membership.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2-
from odoo import _, api, fields, models
2+
from odoo import _, fields, models
33
from odoo.exceptions import ValidationError
44

55

@@ -8,6 +8,11 @@ class SPPCycleMembership(models.Model):
88
_description = "Cycle Membership"
99
_order = "partner_id asc,id desc"
1010

11+
_unique_partner_cycle = models.Constraint(
12+
"UNIQUE(partner_id, cycle_id)",
13+
"Beneficiary must be unique per cycle.",
14+
)
15+
1116
partner_id = fields.Many2one("res.partner", "Registrant", help="A beneficiary", required=True, index=True)
1217
cycle_id = fields.Many2one("spp.cycle", "Cycle", help="A cycle", required=True, index=True)
1318
enrollment_date = fields.Date(default=lambda self: fields.Datetime.now())
@@ -24,25 +29,6 @@ class SPPCycleMembership(models.Model):
2429
copy=False,
2530
)
2631

27-
@api.constrains("partner_id", "cycle_id")
28-
def _check_unique_partner_per_cycle(self):
29-
# Prefetch partner_id and cycle_id to avoid N+1 queries in loop
30-
self.mapped("partner_id")
31-
self.mapped("cycle_id")
32-
33-
for record in self:
34-
if record.partner_id and record.cycle_id:
35-
existing = self.search(
36-
[
37-
("partner_id", "=", record.partner_id.id),
38-
("cycle_id", "=", record.cycle_id.id),
39-
("id", "!=", record.id),
40-
],
41-
limit=1,
42-
)
43-
if existing:
44-
raise ValidationError(_("Beneficiary must be unique per cycle."))
45-
4632
def _compute_display_name(self):
4733
res = super()._compute_display_name()
4834
# Prefetch cycle_id and partner_id to avoid N+1 queries in loop

spp_programs/models/entitlement.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ class SPPEntitlement(models.Model):
2626
_order = "partner_id asc,id desc"
2727
_check_company_auto = True
2828

29+
_unique_code = models.Constraint(
30+
"UNIQUE(code)",
31+
"Entitlement code must be unique.",
32+
)
33+
2934
# Cached model ID for performance (class-level cache)
3035
_entitlement_model_id_cache = None
3136

@@ -139,20 +144,6 @@ def _generate_code(self):
139144
payment_status = fields.Selection([("paid", "Paid"), ("notpaid", "Not Paid")], compute="_compute_payment_status")
140145
payment_date = fields.Date(compute="_compute_payment_status")
141146

142-
@api.constrains("code")
143-
def _check_unique_code(self):
144-
for record in self:
145-
if record.code:
146-
existing = self.search(
147-
[
148-
("code", "=", record.code),
149-
("id", "!=", record.id),
150-
],
151-
limit=1,
152-
)
153-
if existing:
154-
raise ValidationError(_("The entitlement code must be unique."))
155-
156147
@api.constrains("valid_from", "valid_until")
157148
def _check_valid_dates(self):
158149
for record in self:

spp_programs/models/entitlement_base_model.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ class SPPEntitlement(models.Model):
1717
_order = "partner_id asc,id desc"
1818
_check_company_auto = True
1919

20+
_unique_code = models.Constraint(
21+
"UNIQUE(code)",
22+
"Entitlement code must be unique.",
23+
)
24+
2025
@api.model
2126
def _generate_code(self):
2227
return str(uuid4())[4:-8][3:]
@@ -87,20 +92,6 @@ def _generate_code(self):
8792
payment_status = fields.Selection([("paid", "Paid"), ("notpaid", "Not Paid")], compute="_compute_payment_status")
8893
payment_date = fields.Date(compute="_compute_payment_status")
8994

90-
@api.constrains("code")
91-
def _check_unique_code(self):
92-
for record in self:
93-
if record.code:
94-
existing = self.search(
95-
[
96-
("code", "=", record.code),
97-
("id", "!=", record.id),
98-
],
99-
limit=1,
100-
)
101-
if existing:
102-
raise ValidationError(_("The entitlement code must be unique."))
103-
10495
@api.constrains("valid_from", "valid_until")
10596
def _check_valid_dates(self):
10697
for record in self:

spp_programs/models/program_membership.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from lxml import etree
44

55
from odoo import _, api, fields, models
6-
from odoo.exceptions import UserError, ValidationError
6+
from odoo.exceptions import UserError
77

88
from . import constants
99

@@ -19,6 +19,11 @@ class SPPProgramMembership(models.Model):
1919
_inherits = {"res.partner": "partner_id"}
2020
_order = "id desc"
2121

22+
_unique_partner_program = models.Constraint(
23+
"UNIQUE(partner_id, program_id)",
24+
"Beneficiary must be unique per program.",
25+
)
26+
2227
partner_id = fields.Many2one(
2328
"res.partner",
2429
"Registrant",
@@ -52,25 +57,6 @@ class SPPProgramMembership(models.Model):
5257

5358
registrant_id = fields.Integer(string="Registrant ID", related="partner_id.id")
5459

55-
@api.constrains("partner_id", "program_id")
56-
def _check_unique_partner_per_program(self):
57-
# Prefetch partner_id and program_id to avoid N+1 queries in loop
58-
self.mapped("partner_id")
59-
self.mapped("program_id")
60-
61-
for record in self:
62-
if record.partner_id and record.program_id:
63-
existing = self.search(
64-
[
65-
("partner_id", "=", record.partner_id.id),
66-
("program_id", "=", record.program_id.id),
67-
("id", "!=", record.id),
68-
],
69-
limit=1,
70-
)
71-
if existing:
72-
raise ValidationError(_("Beneficiary must be unique per program."))
73-
7460
# TODO: Implement exit reasons
7561
# exit_reason_id = fields.Many2one("Exit Reason") Default: Completed, Opt-Out, Other
7662

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
from . import test_eligibility_cel_integration
1616
from . import test_compliance_cel
1717
from . import test_create_program_wizard_cel
18+
from . import test_sql_constraints

0 commit comments

Comments
 (0)