From 8fa3c4f4b4b5f0a3530ab67e4538b41c5318d0bc Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 27 Apr 2026 16:40:02 +0800 Subject: [PATCH 1/2] fix(spp_programs): handle state=None in enroll eligible async dispatch The "Enroll Eligible" button calls enroll_eligible_registrants() with no argument, so state defaults to None. When the program has at least MIN_ROW_JOB_QUEUE (200) beneficiaries, the async path runs and called tuple(states) on None, raising TypeError. Mirror get_beneficiaries semantics: when states is None/empty, omit the "state IN %s" filter from the SQL where-clause so all states are considered. Add a regression test that covers state=None. --- .../models/managers/program_manager.py | 13 ++++- spp_programs/tests/test_keyset_pagination.py | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) 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/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,)) From adcaa4a0b7bf3b0f7cf3f22ace4bbcc9628f563e Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 27 Apr 2026 17:19:56 +0800 Subject: [PATCH 2/2] docs(spp_programs): bump version to 19.0.2.0.11, add changelog entry --- spp_programs/README.rst | 10 +++++++ spp_programs/__manifest__.py | 2 +- spp_programs/readme/HISTORY.md | 5 ++++ spp_programs/static/description/index.html | 31 +++++++++++++++------- 4 files changed, 37 insertions(+), 11 deletions(-) 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/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 @@

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

  • 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