diff --git a/spp_programs/README.rst b/spp_programs/README.rst
index 891c63fa..61b3d267 100644
--- a/spp_programs/README.rst
+++ b/spp_programs/README.rst
@@ -254,6 +254,16 @@ Dependencies
Changelog
=========
+19.0.2.0.8
+~~~~~~~~~~
+
+- Replace OFFSET pagination with NTILE-based ID-range batching in all
+ async job dispatchers
+- Add ``compute_id_ranges()`` utility using PostgreSQL NTILE window
+ function
+- Add ``min_id``/``max_id`` support to ``get_beneficiaries()`` on
+ program and cycle
+
19.0.2.0.7
~~~~~~~~~~
diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py
index f84a56ae..1de62a0c 100644
--- a/spp_programs/__manifest__.py
+++ b/spp_programs/__manifest__.py
@@ -4,7 +4,7 @@
"name": "OpenSPP Programs",
"summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.",
"category": "OpenSPP/Core",
- "version": "19.0.2.0.7",
+ "version": "19.0.2.0.8",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_programs/models/cycle.py b/spp_programs/models/cycle.py
index 3cf0b6f5..d4ae4351 100644
--- a/spp_programs/models/cycle.py
+++ b/spp_programs/models/cycle.py
@@ -614,7 +614,9 @@ def _get_beneficiaries_domain(self, states=None):
return domain
@api.model
- def get_beneficiaries(self, state, offset=0, limit=None, order=None, count=False, last_id=None):
+ def get_beneficiaries(
+ self, state, offset=0, limit=None, order=None, count=False, last_id=None, min_id=None, max_id=None
+ ):
"""
Get beneficiaries by state with pagination support.
@@ -624,9 +626,12 @@ def get_beneficiaries(self, state, offset=0, limit=None, order=None, count=False
:param order: Sort order
:param count: If True, return count instead of records
:param last_id: For cursor-based pagination - ID of last record from previous batch (more efficient)
+ :param min_id: For ID-range pagination - minimum record ID (inclusive)
+ :param max_id: For ID-range pagination - maximum record ID (inclusive)
:return: Recordset or count
- Note: For large datasets, use cursor-based pagination with last_id parameter instead of offset.
+ Note: For large datasets, prefer min_id/max_id (ID-range) or last_id (cursor)
+ pagination over offset-based pagination.
"""
if isinstance(state, str):
state = [state]
@@ -635,7 +640,12 @@ def get_beneficiaries(self, state, offset=0, limit=None, order=None, count=False
if count:
return self.env["spp.cycle.membership"].search_count(domain, limit=limit)
- # Use cursor-based pagination if last_id is provided (more efficient)
+ # ID-range pagination (best for parallel job dispatch)
+ if min_id is not None and max_id is not None:
+ domain = domain + [("id", ">=", min_id), ("id", "<=", max_id)]
+ return self.env["spp.cycle.membership"].search(domain, order=order or "id")
+
+ # Cursor-based pagination (good for sequential iteration)
if last_id is not None:
domain = domain + [("id", ">", last_id)]
return self.env["spp.cycle.membership"].search(domain, limit=limit, order=order or "id")
diff --git a/spp_programs/models/managers/cycle_manager.py b/spp_programs/models/managers/cycle_manager.py
index 66f08349..c46c7534 100644
--- a/spp_programs/models/managers/cycle_manager.py
+++ b/spp_programs/models/managers/cycle_manager.py
@@ -23,17 +23,19 @@ def mark_prepare_entitlement_as_done(self, cycle, msg):
cycle._compute_total_entitlements_count()
return
- def _prepare_entitlements(self, cycle, offset=0, limit=None, do_count=False):
+ def _prepare_entitlements(self, cycle, offset=0, limit=None, min_id=None, max_id=None, do_count=False):
"""Prepare Entitlements
Get the beneficiaries and generate their entitlements.
:param cycle: The cycle
- :param offset: Optional integer value for the ORM search offset
- :param limit: Optional integer value for the ORM search limit
+ :param offset: Optional integer value for the ORM search offset (deprecated, use min_id/max_id)
+ :param limit: Optional integer value for the ORM search limit (deprecated, use min_id/max_id)
+ :param min_id: Minimum record ID for ID-range pagination (inclusive)
+ :param max_id: Maximum record ID for ID-range pagination (inclusive)
:param do_count: Boolean - set to False to not run compute function
:return:
"""
- super()._prepare_entitlements(cycle, offset, limit, do_count)
+ super()._prepare_entitlements(cycle, offset, limit, min_id=min_id, max_id=max_id, do_count=do_count)
if do_count:
# Update Statistics
cycle._compute_inkind_entitlements_count()
diff --git a/spp_programs/models/managers/cycle_manager_base.py b/spp_programs/models/managers/cycle_manager_base.py
index 11c161d1..22e6a0b1 100644
--- a/spp_programs/models/managers/cycle_manager_base.py
+++ b/spp_programs/models/managers/cycle_manager_base.py
@@ -11,6 +11,7 @@
from odoo.addons.job_worker.delay import group
from .. import constants
+from .pagination_utils import compute_id_ranges
_logger = logging.getLogger(__name__)
@@ -515,21 +516,32 @@ def _check_eligibility_async(self, cycle, beneficiaries_count):
cycle.message_post(body=_("Eligibility check of %s beneficiaries started.", beneficiaries_count))
cycle.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ states = ("draft", "enrolled", "not_eligible")
+ id_ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_cycle_membership",
+ "cycle_id = %s AND state IN %s",
+ (cycle.id, states),
+ self.MAX_ROW_JOB_QUEUE,
+ )
+
jobs = []
- for i in range(0, beneficiaries_count, self.MAX_ROW_JOB_QUEUE):
- jobs.append(
- self.delayable(channel="cycle")._check_eligibility(cycle, offset=i, limit=self.MAX_ROW_JOB_QUEUE)
- )
+ for min_id, max_id in id_ranges:
+ jobs.append(self.delayable(channel="cycle")._check_eligibility(cycle, min_id=min_id, max_id=max_id))
main_job = group(*jobs)
main_job.on_done(self.delayable(channel="cycle").mark_check_eligibility_as_done(cycle))
main_job.delay()
- def _check_eligibility(self, cycle, beneficiaries=None, offset=0, limit=None, do_count=False):
+ def _check_eligibility(
+ self, cycle, beneficiaries=None, offset=0, limit=None, min_id=None, max_id=None, do_count=False
+ ):
if beneficiaries is None:
beneficiaries = cycle.get_beneficiaries(
["draft", "enrolled", "not_eligible"],
offset=offset,
limit=limit,
+ min_id=min_id,
+ max_id=max_id,
order="id",
)
@@ -585,26 +597,38 @@ def _prepare_entitlements_async(self, cycle, beneficiaries_count):
}
)
+ id_ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_cycle_membership",
+ "cycle_id = %s AND state IN %s",
+ (cycle.id, ("enrolled",)),
+ self.MAX_ROW_JOB_QUEUE,
+ )
+
jobs = []
- for i in range(0, beneficiaries_count, self.MAX_ROW_JOB_QUEUE):
- jobs.append(self.delayable(channel="cycle")._prepare_entitlements(cycle, i, self.MAX_ROW_JOB_QUEUE))
+ for min_id, max_id in id_ranges:
+ jobs.append(self.delayable(channel="cycle")._prepare_entitlements(cycle, min_id=min_id, max_id=max_id))
main_job = group(*jobs)
main_job.on_done(
self.delayable(channel="cycle").mark_prepare_entitlement_as_done(cycle, _("Entitlement Ready."))
)
main_job.delay()
- def _prepare_entitlements(self, cycle, offset=0, limit=None, do_count=False):
+ def _prepare_entitlements(self, cycle, offset=0, limit=None, min_id=None, max_id=None, do_count=False):
"""Prepare Entitlements
Get the beneficiaries and generate their entitlements.
:param cycle: The cycle
- :param offset: Optional integer value for the ORM search offset
- :param limit: Optional integer value for the ORM search limit
+ :param offset: Optional integer value for the ORM search offset (deprecated, use min_id/max_id)
+ :param limit: Optional integer value for the ORM search limit (deprecated, use min_id/max_id)
+ :param min_id: Minimum record ID for ID-range pagination (inclusive)
+ :param max_id: Maximum record ID for ID-range pagination (inclusive)
:param do_count: Boolean - set to False to not run compute function
:return:
"""
- beneficiaries = cycle.get_beneficiaries(["enrolled"], offset=offset, limit=limit, order="id")
+ beneficiaries = cycle.get_beneficiaries(
+ ["enrolled"], offset=offset, limit=limit, min_id=min_id, max_id=max_id, order="id"
+ )
ent_manager = self.program_id.get_manager(constants.MANAGER_ENTITLEMENT)
if not ent_manager:
raise UserError(_("No Entitlement Manager defined."))
diff --git a/spp_programs/models/managers/pagination_utils.py b/spp_programs/models/managers/pagination_utils.py
new file mode 100644
index 00000000..b7878046
--- /dev/null
+++ b/spp_programs/models/managers/pagination_utils.py
@@ -0,0 +1,63 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Keyset pagination utilities for async job dispatch.
+
+OFFSET-based pagination causes PostgreSQL to scan and discard N rows for
+OFFSET N, making later batches progressively slower (O(N) per batch).
+
+This module provides ID-range batching using the NTILE window function,
+which pre-computes (min_id, max_id) boundaries in a single query. Each
+job then uses WHERE id BETWEEN min_id AND max_id, which is O(1) via the
+primary key index regardless of batch position.
+"""
+
+import math
+
+
+def compute_id_ranges(cr, table, where_clause, params, batch_size):
+ """Compute ID-range boundaries for parallel job dispatch.
+
+ Uses PostgreSQL's NTILE window function to split matching rows into
+ roughly equal-sized buckets, then returns the (min_id, max_id) of each.
+
+ :param cr: Database cursor
+ :param table: Table name (e.g. 'spp_program_membership')
+ :param where_clause: SQL WHERE clause without 'WHERE' keyword
+ (e.g. 'program_id = %s AND state IN %s')
+ :param params: Tuple of parameters for the WHERE clause
+ :param batch_size: Target number of rows per batch
+ :return: List of (min_id, max_id) tuples, ordered by min_id
+ """
+ # Get total count to calculate number of batches
+ cr.execute(
+ f"SELECT COUNT(*) FROM {table} WHERE {where_clause}", # noqa: S608 # nosec B608
+ params,
+ )
+ total = cr.fetchone()[0]
+ if total == 0:
+ return []
+
+ num_batches = math.ceil(total / batch_size)
+ if num_batches <= 1:
+ cr.execute(
+ f"SELECT MIN(id), MAX(id) FROM {table} WHERE {where_clause}", # noqa: S608 # nosec B608
+ params,
+ )
+ row = cr.fetchone()
+ return [(row[0], row[1])] if row and row[0] is not None else []
+
+ # Use NTILE to split rows into equal-sized buckets, then get
+ # the min/max ID of each bucket as the range boundaries.
+ cr.execute(
+ f"""
+ SELECT MIN(id) AS min_id, MAX(id) AS max_id
+ FROM (
+ SELECT id, NTILE(%s) OVER (ORDER BY id) AS tile
+ FROM {table}
+ WHERE {where_clause}
+ ) sub
+ GROUP BY tile
+ ORDER BY min_id
+ """, # noqa: S608 # nosec B608
+ (num_batches, *params),
+ )
+ return cr.fetchall()
diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py
index eccb41a2..e309e360 100644
--- a/spp_programs/models/managers/program_manager.py
+++ b/spp_programs/models/managers/program_manager.py
@@ -8,6 +8,7 @@
from odoo.addons.job_worker.delay import group
from ..programs import SPPProgram
+from .pagination_utils import compute_id_ranges
_logger = logging.getLogger(__name__)
@@ -184,28 +185,43 @@ def _enroll_eligible_registrants_async(self, states, members_count):
program.message_post(body=_("Eligibility check of %s beneficiaries started.", members_count))
program.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ if isinstance(states, str):
+ states = [states]
+
+ id_ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_program_membership",
+ "program_id = %s AND state IN %s",
+ (program.id, tuple(states)),
+ self.MAX_ROW_JOB_QUEUE,
+ )
+
jobs = []
- for i in range(0, members_count, self.MAX_ROW_JOB_QUEUE):
+ for min_id, max_id in id_ranges:
jobs.append(
self.delayable(channel="program_manager")._enroll_eligible_registrants(
- states, i, self.MAX_ROW_JOB_QUEUE
+ states, min_id=min_id, max_id=max_id
)
)
main_job = group(*jobs)
main_job.on_done(self.delayable(channel="program_manager").mark_enroll_eligible_as_done())
main_job.delay()
- def _enroll_eligible_registrants(self, states, offset=0, limit=None, do_count=False):
+ def _enroll_eligible_registrants(self, states, offset=0, limit=None, min_id=None, max_id=None, do_count=False):
"""Enroll Eligible Registrants
:param states: List of states to be used in domain filter
- :param offset: Optional integer value for the ORM search offset
- :param limit: Optional integer value for the ORM search limit
+ :param offset: Optional integer value for the ORM search offset (deprecated, use min_id/max_id)
+ :param limit: Optional integer value for the ORM search limit (deprecated, use min_id/max_id)
+ :param min_id: Minimum record ID for ID-range pagination (inclusive)
+ :param max_id: Maximum record ID for ID-range pagination (inclusive)
:param do_count: Boolean - set to False to not run compute functions
:return: Integer - count of not enrolled members
"""
program = self.program_id
- members = program.get_beneficiaries(state=states, offset=offset, limit=limit, order="id")
+ members = program.get_beneficiaries(
+ state=states, offset=offset, limit=limit, min_id=min_id, max_id=max_id, order="id"
+ )
member_before = members
diff --git a/spp_programs/models/programs.py b/spp_programs/models/programs.py
index e086c6d2..0aa25ec9 100644
--- a/spp_programs/models/programs.py
+++ b/spp_programs/models/programs.py
@@ -314,7 +314,9 @@ def get_managers(self, kind):
return [el.manager_ref_id for el in managers]
@api.model
- def get_beneficiaries(self, state=None, offset=0, limit=None, order=None, count=False, last_id=None):
+ def get_beneficiaries(
+ self, state=None, offset=0, limit=None, order=None, count=False, last_id=None, min_id=None, max_id=None
+ ):
"""
Get program beneficiaries with pagination support.
@@ -324,9 +326,12 @@ def get_beneficiaries(self, state=None, offset=0, limit=None, order=None, count=
:param order: Sort order
:param count: If True, return count instead of records
:param last_id: For cursor-based pagination - ID of last record from previous batch (more efficient)
+ :param min_id: For ID-range pagination - minimum record ID (inclusive)
+ :param max_id: For ID-range pagination - maximum record ID (inclusive)
:return: Recordset or count
- Note: For large datasets, use cursor-based pagination with last_id parameter instead of offset.
+ Note: For large datasets, prefer min_id/max_id (ID-range) or last_id (cursor)
+ pagination over offset-based pagination.
"""
self.ensure_one()
if isinstance(state, str):
@@ -337,7 +342,12 @@ def get_beneficiaries(self, state=None, offset=0, limit=None, order=None, count=
if count:
return self.env["spp.program.membership"].search_count(domain, limit=limit)
- # Use cursor-based pagination if last_id is provided (more efficient)
+ # ID-range pagination (best for parallel job dispatch)
+ if min_id is not None and max_id is not None:
+ domain = domain + [("id", ">=", min_id), ("id", "<=", max_id)]
+ return self.env["spp.program.membership"].search(domain, order=order or "id")
+
+ # Cursor-based pagination (good for sequential iteration)
if last_id is not None:
domain = domain + [("id", ">", last_id)]
return self.env["spp.program.membership"].search(domain, limit=limit, order=order or "id")
diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md
index f7990cef..2e335856 100644
--- a/spp_programs/readme/HISTORY.md
+++ b/spp_programs/readme/HISTORY.md
@@ -1,3 +1,9 @@
+### 19.0.2.0.8
+
+- Replace OFFSET pagination with NTILE-based ID-range batching in all async job dispatchers
+- Add `compute_id_ranges()` utility using PostgreSQL NTILE window function
+- Add `min_id`/`max_id` support to `get_beneficiaries()` on program and cycle
+
### 19.0.2.0.7
- Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING for program and cycle memberships
diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html
index 8006ba87..a11c13ed 100644
--- a/spp_programs/static/description/index.html
+++ b/spp_programs/static/description/index.html
@@ -658,6 +658,17 @@
+
19.0.2.0.8
+
+- Replace OFFSET pagination with NTILE-based ID-range batching in all
+async job dispatchers
+- Add compute_id_ranges() utility using PostgreSQL NTILE window
+function
+- Add min_id/max_id support to get_beneficiaries() on
+program and cycle
+
+
+
19.0.2.0.7
- Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING
@@ -666,7 +677,7 @@
19.0.2.0.7
_add_beneficiaries with bulk SQL path
-
+
19.0.2.0.6
- Remove unused entitlement_base_model.py (dead code, never imported)
@@ -675,34 +686,34 @@ 19.0.2.0.6
payment, and fund tests (172 → 492 tests)
-
+
19.0.2.0.5
- Batch create entitlements and payments instead of one-by-one ORM
creates
-
+
19.0.2.0.4
- Fetch fund balance once per approval batch instead of per entitlement
-
+
19.0.2.0.3
- Replace cycle computed fields (total_amount, entitlements_count,
approval flags) with SQL aggregation queries
-
+
19.0.2.0.2
- Add composite indexes for frequent query patterns on entitlements and
program memberships
-
+
19.0.2.0.1
- Replace Python-level uniqueness checks with SQL UNIQUE constraints for
@@ -711,7 +722,7 @@
19.0.2.0.1
constraint creation
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py
index 344574d9..acb863ea 100644
--- a/spp_programs/tests/__init__.py
+++ b/spp_programs/tests/__init__.py
@@ -33,3 +33,4 @@
from . import test_managers
from . import test_cycle_auto_approve_fund_check
from . import test_bulk_membership
+from . import test_keyset_pagination
diff --git a/spp_programs/tests/test_keyset_pagination.py b/spp_programs/tests/test_keyset_pagination.py
new file mode 100644
index 00000000..9d05a84b
--- /dev/null
+++ b/spp_programs/tests/test_keyset_pagination.py
@@ -0,0 +1,499 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Tests for Phase 6: ID-based keyset pagination.
+
+These tests verify that async job dispatch uses ID-range batching (via NTILE)
+instead of OFFSET-based pagination. OFFSET N causes PostgreSQL to scan N rows
+then discard them, making later batches O(N) slower.
+"""
+
+import uuid
+from unittest.mock import patch
+
+from odoo import fields
+from odoo.tests import TransactionCase
+
+
+class TestComputeIdRanges(TransactionCase):
+ """Test the compute_id_ranges helper function."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
+
+ def _create_memberships(self, count):
+ """Create program memberships and return their IDs sorted."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(count)]
+ )
+ memberships = self.env["spp.program.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "program_id": self.program.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+ return sorted(memberships.ids)
+
+ def test_compute_id_ranges_returns_covering_ranges(self):
+ """All records must be covered by exactly one range."""
+ from ..models.managers.pagination_utils import (
+ compute_id_ranges,
+ )
+
+ ids = self._create_memberships(10)
+ ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_program_membership",
+ "program_id = %s AND state IN %s",
+ (self.program.id, tuple(["draft"])),
+ batch_size=3,
+ )
+ # Every original ID should fall within exactly one range
+ covered = set()
+ for min_id, max_id in ranges:
+ covered.update(i for i in ids if min_id <= i <= max_id)
+ self.assertEqual(covered, set(ids), "All IDs must be covered by ranges")
+
+ def test_compute_id_ranges_batch_count(self):
+ """Number of ranges should be ceil(total / batch_size)."""
+ from ..models.managers.pagination_utils import (
+ compute_id_ranges,
+ )
+
+ self._create_memberships(10)
+ ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_program_membership",
+ "program_id = %s AND state IN %s",
+ (self.program.id, tuple(["draft"])),
+ batch_size=3,
+ )
+ # 10 records / batch_size 3 = ceil(10/3) = 4 ranges
+ self.assertEqual(len(ranges), 4)
+
+ def test_compute_id_ranges_single_batch(self):
+ """When total <= batch_size, return a single range."""
+ from ..models.managers.pagination_utils import (
+ compute_id_ranges,
+ )
+
+ ids = self._create_memberships(3)
+ ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_program_membership",
+ "program_id = %s AND state IN %s",
+ (self.program.id, tuple(["draft"])),
+ batch_size=10,
+ )
+ self.assertEqual(len(ranges), 1)
+ self.assertEqual(ranges[0], (min(ids), max(ids)))
+
+ def test_compute_id_ranges_empty_table(self):
+ """Empty result set should return empty list."""
+ from ..models.managers.pagination_utils import (
+ compute_id_ranges,
+ )
+
+ ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_program_membership",
+ "program_id = %s AND state IN %s",
+ (self.program.id, tuple(["draft"])),
+ batch_size=10,
+ )
+ self.assertEqual(ranges, [])
+
+ def test_compute_id_ranges_no_overlap(self):
+ """Ranges must not overlap (each ID in exactly one range)."""
+ from ..models.managers.pagination_utils import (
+ compute_id_ranges,
+ )
+
+ self._create_memberships(20)
+ ranges = compute_id_ranges(
+ self.env.cr,
+ "spp_program_membership",
+ "program_id = %s AND state IN %s",
+ (self.program.id, tuple(["draft"])),
+ batch_size=5,
+ )
+ for i in range(len(ranges) - 1):
+ self.assertLess(
+ ranges[i][1],
+ ranges[i + 1][0],
+ f"Range {i} max_id must be less than range {i + 1} min_id",
+ )
+
+
+class TestGetBeneficiariesIdRange(TransactionCase):
+ """Test min_id/max_id support in get_beneficiaries()."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(10)]
+ )
+ self.memberships = self.env["spp.program.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "program_id": self.program.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+ self.sorted_ids = sorted(self.memberships.ids)
+
+ # Also create a cycle for cycle-level tests
+ 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.cycle_memberships = self.env["spp.cycle.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "cycle_id": self.cycle.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+ self.cycle_sorted_ids = sorted(self.cycle_memberships.ids)
+
+ def test_program_get_beneficiaries_with_id_range(self):
+ """get_beneficiaries with min_id/max_id returns only records in range."""
+ mid = self.sorted_ids[4] # 5th record
+ end = self.sorted_ids[7] # 8th record
+ result = self.program.get_beneficiaries(state="draft", min_id=mid, max_id=end)
+ result_ids = sorted(result.ids)
+ expected = [i for i in self.sorted_ids if mid <= i <= end]
+ self.assertEqual(result_ids, expected)
+
+ def test_program_get_beneficiaries_id_range_no_offset(self):
+ """min_id/max_id should not use offset internally."""
+ # If offset were used, we'd get wrong results
+ result = self.program.get_beneficiaries(
+ state="draft",
+ min_id=self.sorted_ids[0],
+ max_id=self.sorted_ids[-1],
+ )
+ self.assertEqual(len(result), 10)
+
+ def test_cycle_get_beneficiaries_with_id_range(self):
+ """Cycle get_beneficiaries with min_id/max_id returns only records in range."""
+ mid = self.cycle_sorted_ids[3]
+ end = self.cycle_sorted_ids[6]
+ result = self.cycle.get_beneficiaries(state="draft", min_id=mid, max_id=end)
+ result_ids = sorted(result.ids)
+ expected = [i for i in self.cycle_sorted_ids if mid <= i <= end]
+ self.assertEqual(result_ids, expected)
+
+
+class TestAsyncDispatchUsesIdRanges(TransactionCase):
+ """Verify async dispatch methods use ID ranges, not OFFSET."""
+
+ 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(),
+ }
+ )
+
+ def test_enroll_eligible_async_uses_compute_id_ranges(self):
+ """_enroll_eligible_registrants_async must use compute_id_ranges for dispatch."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(10)]
+ )
+ self.env["spp.program.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "program_id": self.program.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+
+ manager = self.env["spp.program.manager.default"].create(
+ {
+ "name": "Test Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ # Verify compute_id_ranges is called by the async method
+ with patch(
+ "odoo.addons.spp_programs.models.managers.program_manager.compute_id_ranges",
+ wraps=None,
+ return_value=[(1, 5), (6, 10)],
+ ) as mock_ranges:
+ # Also patch delayable to avoid actual job creation
+ with patch.object(type(manager), "delayable", return_value=manager):
+ try:
+ manager._enroll_eligible_registrants_async(["draft"], 10)
+ except Exception: # pylint: disable=except-pass
+ pass
+
+ mock_ranges.assert_called_once()
+ call_args = mock_ranges.call_args
+ # Verify it was called with the right table
+ self.assertEqual(call_args[0][1], "spp_program_membership")
+
+ def test_enroll_eligible_registrants_accepts_id_range(self):
+ """_enroll_eligible_registrants must accept min_id/max_id params."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
+ )
+ memberships = self.env["spp.program.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "program_id": self.program.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+ sorted_ids = sorted(memberships.ids)
+
+ manager = self.env["spp.program.manager.default"].create(
+ {
+ "name": "Test Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ # Create a simple eligibility manager that passes everyone through
+ elig_manager = self.env["spp.program.membership.manager.default"].create(
+ {
+ "name": "Test Elig Manager",
+ "program_id": self.program.id,
+ }
+ )
+ self.env["spp.eligibility.manager"].create(
+ {
+ "program_id": self.program.id,
+ "manager_ref_id": f"spp.program.membership.manager.default,{elig_manager.id}",
+ }
+ )
+
+ # Call with min_id/max_id - should only process records in range
+ mid = sorted_ids[1]
+ end = sorted_ids[3]
+ manager._enroll_eligible_registrants(["draft"], min_id=mid, max_id=end)
+ # Should have enrolled records in range
+ in_range = [i for i in sorted_ids if mid <= i <= end]
+ enrolled = self.env["spp.program.membership"].browse(in_range).filtered(lambda m: m.state == "enrolled")
+ self.assertEqual(len(enrolled), len(in_range))
+
+ def test_check_eligibility_accepts_id_range(self):
+ """_check_eligibility must accept min_id/max_id params."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
+ )
+ cycle_memberships = self.env["spp.cycle.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "cycle_id": self.cycle.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+ sorted_ids = sorted(cycle_memberships.ids)
+
+ # Create eligibility manager
+ elig_manager = self.env["spp.program.membership.manager.default"].create(
+ {
+ "name": "Test Elig Manager",
+ "program_id": self.program.id,
+ }
+ )
+ self.env["spp.eligibility.manager"].create(
+ {
+ "program_id": self.program.id,
+ "manager_ref_id": f"spp.program.membership.manager.default,{elig_manager.id}",
+ }
+ )
+
+ cycle_manager = self.env["spp.cycle.manager.default"].create(
+ {
+ "name": "Test Cycle Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ # Call with min_id/max_id
+ mid = sorted_ids[1]
+ end = sorted_ids[3]
+ count = cycle_manager._check_eligibility(self.cycle, min_id=mid, max_id=end)
+ # Should have processed only records in range
+ in_range = [i for i in sorted_ids if mid <= i <= end]
+ self.assertEqual(count, len(in_range))
+
+ def test_prepare_entitlements_accepts_id_range(self):
+ """_prepare_entitlements must accept min_id/max_id params."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
+ )
+ self.env["spp.cycle.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "cycle_id": self.cycle.id,
+ "state": "enrolled",
+ }
+ for p in partners
+ ]
+ )
+
+ cycle_manager = self.env["spp.cycle.manager.default"].create(
+ {
+ "name": "Test Cycle Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ # Verify the method accepts min_id/max_id without TypeError.
+ # UserError is expected since no entitlement manager is configured.
+ from odoo.exceptions import UserError
+
+ try:
+ cycle_manager._prepare_entitlements(
+ self.cycle,
+ min_id=0,
+ max_id=999999999,
+ )
+ except TypeError as e:
+ if "min_id" in str(e) or "max_id" in str(e):
+ self.fail("_prepare_entitlements must accept min_id/max_id params")
+ except UserError:
+ pass # Expected: no entitlement manager configured
+
+ def test_check_eligibility_async_uses_compute_id_ranges(self):
+ """_check_eligibility_async must use compute_id_ranges for dispatch."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
+ )
+ self.env["spp.cycle.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "cycle_id": self.cycle.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+
+ cycle_manager = self.env["spp.cycle.manager.default"].create(
+ {
+ "name": "Test Cycle Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ with patch(
+ "odoo.addons.spp_programs.models.managers.cycle_manager_base.compute_id_ranges",
+ return_value=[(1, 3), (4, 6)],
+ ) as mock_ranges:
+ with patch.object(type(cycle_manager), "delayable", return_value=cycle_manager):
+ try:
+ cycle_manager._check_eligibility_async(self.cycle, 5)
+ except Exception: # pylint: disable=except-pass
+ pass
+
+ mock_ranges.assert_called_once()
+ self.assertEqual(mock_ranges.call_args[0][1], "spp_cycle_membership")
+
+ def test_prepare_entitlements_async_uses_compute_id_ranges(self):
+ """_prepare_entitlements_async must use compute_id_ranges for dispatch."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
+ )
+ self.env["spp.cycle.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "cycle_id": self.cycle.id,
+ "state": "enrolled",
+ }
+ for p in partners
+ ]
+ )
+
+ cycle_manager = self.env["spp.cycle.manager.default"].create(
+ {
+ "name": "Test Cycle Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ with patch(
+ "odoo.addons.spp_programs.models.managers.cycle_manager_base.compute_id_ranges",
+ return_value=[(1, 3), (4, 6)],
+ ) as mock_ranges:
+ with patch.object(type(cycle_manager), "delayable", return_value=cycle_manager):
+ try:
+ cycle_manager._prepare_entitlements_async(self.cycle, 5)
+ except Exception: # pylint: disable=except-pass
+ pass
+
+ mock_ranges.assert_called_once()
+ self.assertEqual(mock_ranges.call_args[0][1], "spp_cycle_membership")
+
+ def test_enroll_eligible_async_handles_string_state(self):
+ """_enroll_eligible_registrants_async must handle string state arg."""
+ partners = self.env["res.partner"].create(
+ [{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
+ )
+ self.env["spp.program.membership"].create(
+ [
+ {
+ "partner_id": p.id,
+ "program_id": self.program.id,
+ "state": "draft",
+ }
+ for p in partners
+ ]
+ )
+
+ manager = self.env["spp.program.manager.default"].create(
+ {
+ "name": "Test Manager",
+ "program_id": self.program.id,
+ }
+ )
+
+ # Pass a string instead of list — the isinstance branch should convert it
+ with patch(
+ "odoo.addons.spp_programs.models.managers.program_manager.compute_id_ranges",
+ return_value=[(1, 5)],
+ ) as mock_ranges:
+ with patch.object(type(manager), "delayable", return_value=manager):
+ try:
+ manager._enroll_eligible_registrants_async("draft", 5)
+ except Exception: # pylint: disable=except-pass
+ pass
+
+ mock_ranges.assert_called_once()
+ # Verify the states param was converted from string to tuple
+ call_params = mock_ranges.call_args[0][3]
+ self.assertIsInstance(call_params[1], tuple)