diff --git a/spp_programs/README.rst b/spp_programs/README.rst
index 269543fb..0ed5aed9 100644
--- a/spp_programs/README.rst
+++ b/spp_programs/README.rst
@@ -254,6 +254,16 @@ Dependencies
Changelog
=========
+19.0.2.0.11
+~~~~~~~~~~~
+
+- Fix ``TypeError: 'NoneType' object is not iterable`` when clicking
+ **Enroll Eligible** on programs with at least 200 beneficiaries (async
+ dispatch path)
+- Mirror ``get_beneficiaries`` semantics in
+ ``_enroll_eligible_registrants_async``: when ``state`` is ``None``,
+ omit the state filter instead of crashing on ``tuple(None)``
+
19.0.2.0.10
~~~~~~~~~~~
diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py
index 0f61ce1c..ce105507 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.10",
+ "version": "19.0.2.0.11",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py
index 7a799664..21196946 100644
--- a/spp_programs/models/managers/program_manager.py
+++ b/spp_programs/models/managers/program_manager.py
@@ -188,11 +188,20 @@ def _enroll_eligible_registrants_async(self, states, members_count):
if isinstance(states, str):
states = [states]
+ # Mirror get_beneficiaries: when states is None/empty, no state filter is
+ # applied (i.e. all states). Otherwise restrict to the given states.
+ if states:
+ where_clause = "program_id = %s AND state IN %s"
+ params = (program.id, tuple(states))
+ else:
+ where_clause = "program_id = %s"
+ params = (program.id,)
+
id_ranges = compute_id_ranges(
self.env.cr,
"spp_program_membership",
- "program_id = %s AND state IN %s",
- (program.id, tuple(states)),
+ where_clause,
+ params,
self.MAX_ROW_JOB_QUEUE,
)
diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md
index 2ca0bedb..9c79790f 100644
--- a/spp_programs/readme/HISTORY.md
+++ b/spp_programs/readme/HISTORY.md
@@ -1,3 +1,8 @@
+### 19.0.2.0.11
+
+- Fix `TypeError: 'NoneType' object is not iterable` when clicking **Enroll Eligible** on programs with at least 200 beneficiaries (async dispatch path)
+- Mirror `get_beneficiaries` semantics in `_enroll_eligible_registrants_async`: when `state` is `None`, omit the state filter instead of crashing on `tuple(None)`
+
### 19.0.2.0.10
- Increase parallel-safe channel limits (cycle, eligibility_manager, program_manager) from 1 to 4
diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html
index fc5367a3..1ed447a1 100644
--- a/spp_programs/static/description/index.html
+++ b/spp_programs/static/description/index.html
@@ -658,6 +658,17 @@
+
19.0.2.0.11
+
+- Fix TypeError: 'NoneType' object is not iterable when clicking
+Enroll Eligible on programs with at least 200 beneficiaries (async
+dispatch path)
+- Mirror get_beneficiaries semantics in
+_enroll_eligible_registrants_async: when state is None,
+omit the state filter instead of crashing on tuple(None)
+
+
+
19.0.2.0.10
- Increase parallel-safe channel limits (cycle, eligibility_manager,
@@ -670,7 +681,7 @@
19.0.2.0.10
submission on double-click
-
+
19.0.2.0.9
- Add context flags (skip_registrant_statistics,
@@ -683,7 +694,7 @@
19.0.2.0.9
_compute_has_members
-
+
19.0.2.0.8
- Replace OFFSET pagination with NTILE-based ID-range batching in all
@@ -694,7 +705,7 @@
19.0.2.0.8
program and cycle
-
+
19.0.2.0.7
- Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING
@@ -703,7 +714,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)
@@ -712,34 +723,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
@@ -748,7 +759,7 @@
19.0.2.0.1
constraint creation
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_programs/tests/test_keyset_pagination.py b/spp_programs/tests/test_keyset_pagination.py
index 9d05a84b..7bfd5ab3 100644
--- a/spp_programs/tests/test_keyset_pagination.py
+++ b/spp_programs/tests/test_keyset_pagination.py
@@ -497,3 +497,51 @@ def test_enroll_eligible_async_handles_string_state(self):
# Verify the states param was converted from string to tuple
call_params = mock_ranges.call_args[0][3]
self.assertIsInstance(call_params[1], tuple)
+
+ def test_enroll_eligible_async_handles_none_state(self):
+ """_enroll_eligible_registrants_async must handle state=None.
+
+ The UI "Enroll Eligible" button calls enroll_eligible_registrants() with no
+ argument. When the program has >= MIN_ROW_JOB_QUEUE beneficiaries, the async
+ path runs with state=None — it must not crash on `tuple(None)`.
+ """
+ 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,
+ }
+ )
+
+ 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(None, 5)
+ except TypeError as e:
+ self.fail(f"async dispatch must accept state=None, got TypeError: {e}")
+ except Exception: # pylint: disable=except-pass
+ pass
+
+ mock_ranges.assert_called_once()
+ # When states is None, the where clause must omit "state IN %s" and
+ # params must contain only the program id (no states tuple).
+ where_clause = mock_ranges.call_args[0][2]
+ call_params = mock_ranges.call_args[0][3]
+ self.assertNotIn("state IN", where_clause)
+ self.assertEqual(call_params, (self.program.id,))