Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions spp_programs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_programs/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions spp_programs/models/managers/program_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
5 changes: 5 additions & 0 deletions spp_programs/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 21 additions & 10 deletions spp_programs/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,17 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.0.11</h1>
<ul class="simple">
<li>Fix <tt class="docutils literal">TypeError: 'NoneType' object is not iterable</tt> when clicking
<strong>Enroll Eligible</strong> on programs with at least 200 beneficiaries (async
dispatch path)</li>
<li>Mirror <tt class="docutils literal">get_beneficiaries</tt> semantics in
<tt class="docutils literal">_enroll_eligible_registrants_async</tt>: when <tt class="docutils literal">state</tt> is <tt class="docutils literal">None</tt>,
omit the state filter instead of crashing on <tt class="docutils literal">tuple(None)</tt></li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.0.10</h1>
<ul class="simple">
<li>Increase parallel-safe channel limits (cycle, eligibility_manager,
Expand All @@ -670,7 +681,7 @@ <h1>19.0.2.0.10</h1>
submission on double-click</li>
</ul>
</div>
<div class="section" id="section-2">
<div class="section" id="section-3">
<h1>19.0.2.0.9</h1>
<ul class="simple">
<li>Add context flags (<tt class="docutils literal">skip_registrant_statistics</tt>,
Expand All @@ -683,7 +694,7 @@ <h1>19.0.2.0.9</h1>
<tt class="docutils literal">_compute_has_members</tt></li>
</ul>
</div>
<div class="section" id="section-3">
<div class="section" id="section-4">
<h1>19.0.2.0.8</h1>
<ul class="simple">
<li>Replace OFFSET pagination with NTILE-based ID-range batching in all
Expand All @@ -694,7 +705,7 @@ <h1>19.0.2.0.8</h1>
program and cycle</li>
</ul>
</div>
<div class="section" id="section-4">
<div class="section" id="section-5">
<h1>19.0.2.0.7</h1>
<ul class="simple">
<li>Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING
Expand All @@ -703,7 +714,7 @@ <h1>19.0.2.0.7</h1>
<tt class="docutils literal">_add_beneficiaries</tt> with bulk SQL path</li>
</ul>
</div>
<div class="section" id="section-5">
<div class="section" id="section-6">
<h1>19.0.2.0.6</h1>
<ul class="simple">
<li>Remove unused entitlement_base_model.py (dead code, never imported)</li>
Expand All @@ -712,34 +723,34 @@ <h1>19.0.2.0.6</h1>
payment, and fund tests (172 → 492 tests)</li>
</ul>
</div>
<div class="section" id="section-6">
<div class="section" id="section-7">
<h1>19.0.2.0.5</h1>
<ul class="simple">
<li>Batch create entitlements and payments instead of one-by-one ORM
creates</li>
</ul>
</div>
<div class="section" id="section-7">
<div class="section" id="section-8">
<h1>19.0.2.0.4</h1>
<ul class="simple">
<li>Fetch fund balance once per approval batch instead of per entitlement</li>
</ul>
</div>
<div class="section" id="section-8">
<div class="section" id="section-9">
<h1>19.0.2.0.3</h1>
<ul class="simple">
<li>Replace cycle computed fields (total_amount, entitlements_count,
approval flags) with SQL aggregation queries</li>
</ul>
</div>
<div class="section" id="section-9">
<div class="section" id="section-10">
<h1>19.0.2.0.2</h1>
<ul class="simple">
<li>Add composite indexes for frequent query patterns on entitlements and
program memberships</li>
</ul>
</div>
<div class="section" id="section-10">
<div class="section" id="section-11">
<h1>19.0.2.0.1</h1>
<ul class="simple">
<li>Replace Python-level uniqueness checks with SQL UNIQUE constraints for
Expand All @@ -748,7 +759,7 @@ <h1>19.0.2.0.1</h1>
constraint creation</li>
</ul>
</div>
<div class="section" id="section-11">
<div class="section" id="section-12">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
48 changes: 48 additions & 0 deletions spp_programs/tests/test_keyset_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,))
Loading