diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py
index ce105507..99465c8e 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.11",
+ "version": "19.0.2.1.0",
"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 87e56183..a7b0052c 100644
--- a/spp_programs/models/cycle.py
+++ b/spp_programs/models/cycle.py
@@ -1062,6 +1062,27 @@ def _get_related_job_domain(self):
related_jobs = jobs.filtered(lambda r: self in r.args[0])
return [("id", "in", related_jobs.ids)]
+ def action_force_unlock(self):
+ """Manager-only escape hatch: clear a stuck "Operation in progress" lock.
+
+ Use when an async pipeline (entitlement processing, payment prep, etc.)
+ died without firing its on_done/on_error callback — for example after
+ a hard server restart or before this fix was deployed. Posts an audit
+ line to chatter so admins can see who unstuck the cycle.
+ """
+ for rec in self:
+ if not rec.is_locked:
+ continue
+ previous_reason = rec.locked_reason
+ rec.write({"is_locked": False, "locked_reason": False})
+ rec.message_post(
+ body=_(
+ "Lock manually cleared by %(user)s. Previous reason: %(reason)s",
+ user=self.env.user.display_name,
+ reason=previous_reason or _("(none)"),
+ )
+ )
+
def unlink(self):
# Admin also not able to delete the cycle bcz of beneficiaries mapped
# So this function common for who are all having delete access.
diff --git a/spp_programs/models/managers/cycle_manager_base.py b/spp_programs/models/managers/cycle_manager_base.py
index f9fb56e8..a12aafb2 100644
--- a/spp_programs/models/managers/cycle_manager_base.py
+++ b/spp_programs/models/managers/cycle_manager_base.py
@@ -322,13 +322,24 @@ def mark_import_as_done(self, cycle, msg):
:return:
"""
self.ensure_one()
- cycle.is_locked = False
- cycle.locked_reason = None
- cycle.message_post(body=msg)
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
# Refresh statistics after bulk operations
cycle.refresh_statistics()
+ def mark_import_as_failed(self, cycle, msg):
+ """Run via on_error() when async beneficiary import fails."""
+ self.ensure_one()
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
+
def mark_prepare_entitlement_as_done(self, cycle, msg):
"""Complete the preparation of entitlements.
Base :meth:`mark_prepare_entitlement_as_done`.
@@ -340,13 +351,24 @@ def mark_prepare_entitlement_as_done(self, cycle, msg):
:return:
"""
self.ensure_one()
- cycle.is_locked = False
- cycle.locked_reason = None
- cycle.message_post(body=msg)
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
# Update Statistics
cycle._compute_entitlements_count()
+ def mark_prepare_entitlement_as_failed(self, cycle, msg):
+ """Run via on_error() when async entitlement preparation fails."""
+ self.ensure_one()
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
+
def mark_check_eligibility_as_done(self, cycle):
"""Complete the enrollment of eligible beneficiaries.
Base :meth:`mark_check_eligibility_as_done`.
@@ -356,13 +378,25 @@ def mark_check_eligibility_as_done(self, cycle):
:param cycle: A recordset of cycle
:return:
"""
- cycle.is_locked = False
- cycle.locked_reason = None
- cycle.message_post(body=_("Eligibility check finished."))
+ self.ensure_one()
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=_("Eligibility check finished."))
+ except Exception:
+ _logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
# Compute Statistics
cycle._compute_members_count()
+ def mark_check_eligibility_as_failed(self, cycle):
+ """Run via on_error() when async eligibility check fails."""
+ self.ensure_one()
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=_("Eligibility check failed."))
+ except Exception:
+ _logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
+
class DefaultCycleManager(models.Model):
_name = "spp.cycle.manager.default"
@@ -535,6 +569,7 @@ def _check_eligibility_async(self, cycle, beneficiaries_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable(channel="statistics_refresh").mark_check_eligibility_as_done(cycle))
+ main_job.on_error(self.delayable(channel="statistics_refresh").mark_check_eligibility_as_failed(cycle))
main_job.delay()
def _check_eligibility(
@@ -624,6 +659,11 @@ def _prepare_entitlements_async(self, cycle, beneficiaries_count):
cycle, _("Entitlement Ready.")
)
)
+ main_job.on_error(
+ self.delayable(channel="statistics_refresh").mark_prepare_entitlement_as_failed(
+ cycle, _("Entitlement preparation failed.")
+ )
+ )
main_job.delay()
def _prepare_entitlements(self, cycle, offset=0, limit=None, min_id=None, max_id=None, do_count=False):
@@ -870,6 +910,9 @@ def _add_beneficiaries_async(self, cycle, beneficiaries, state):
main_job.on_done(
self.delayable(channel="statistics_refresh").mark_import_as_done(cycle, _("Beneficiary import finished."))
)
+ main_job.on_error(
+ self.delayable(channel="statistics_refresh").mark_import_as_failed(cycle, _("Beneficiary import failed."))
+ )
main_job.delay()
def _add_beneficiaries(self, cycle, beneficiaries, state="draft", do_count=False):
diff --git a/spp_programs/models/managers/entitlement_manager_base.py b/spp_programs/models/managers/entitlement_manager_base.py
index 5ddb61b6..a2d1a4ed 100644
--- a/spp_programs/models/managers/entitlement_manager_base.py
+++ b/spp_programs/models/managers/entitlement_manager_base.py
@@ -95,6 +95,9 @@ def _set_pending_validation_entitlements_async(self, cycle, entitlements):
)
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Set to Pending Validation.")))
+ main_job.on_error(
+ self.delayable().mark_job_as_failed(cycle, _("Setting entitlements to pending validation failed."))
+ )
main_job.delay()
def _set_pending_validation_entitlements(self, entitlements):
@@ -146,6 +149,9 @@ def _validate_entitlements_async(self, cycle, entitlements, entitlements_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Validated and Approved.")))
+ main_job.on_error(
+ self.delayable().mark_job_as_failed(cycle, _("Validation and approval of entitlements failed."))
+ )
main_job.delay()
def _validate_entitlements(self, entitlements):
@@ -210,6 +216,7 @@ def _cancel_entitlements_async(self, cycle, entitlements, entitlements_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Cancelled.")))
+ main_job.on_error(self.delayable().mark_job_as_failed(cycle, _("Cancelling entitlements failed.")))
main_job.delay()
def _cancel_entitlements(self, entitlements):
@@ -233,9 +240,30 @@ def mark_job_as_done(self, cycle, msg):
:return:
"""
self.ensure_one()
- cycle.is_locked = False
- cycle.locked_reason = None
- cycle.message_post(body=msg)
+ # Clear the lock first so a chatter-side failure can't leave the
+ # cycle stuck with "Operation in progress".
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
+
+ def mark_job_as_failed(self, cycle, msg):
+ """Run via on_error() when the async pipeline fails.
+
+ Clears the cycle lock and posts a failure note to chatter so the
+ user understands the operation finished without success — instead
+ of the lock remaining set indefinitely (the bug this fix targets).
+
+ :param cycle: A recordset of cycle
+ :param msg: A string to be posted in the chatter
+ """
+ self.ensure_one()
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
def open_entitlements_form(self, cycle):
"""
diff --git a/spp_programs/models/managers/entitlement_manager_cash.py b/spp_programs/models/managers/entitlement_manager_cash.py
index 7449263d..c0fb0f34 100644
--- a/spp_programs/models/managers/entitlement_manager_cash.py
+++ b/spp_programs/models/managers/entitlement_manager_cash.py
@@ -326,6 +326,9 @@ def _validate_entitlements_async(self, cycle, entitlements, entitlements_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Validated and Approved.")))
+ main_job.on_error(
+ self.delayable().mark_job_as_failed(cycle, _("Validation and approval of entitlements failed."))
+ )
main_job.delay()
def _validate_entitlements(self, cycle, entitlements):
diff --git a/spp_programs/models/managers/entitlement_manager_inkind.py b/spp_programs/models/managers/entitlement_manager_inkind.py
index f3630ee2..a0f57bc5 100644
--- a/spp_programs/models/managers/entitlement_manager_inkind.py
+++ b/spp_programs/models/managers/entitlement_manager_inkind.py
@@ -222,6 +222,9 @@ def _set_pending_validation_entitlements_async(self, cycle, entitlements_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Set to Pending Validation.")))
+ main_job.on_error(
+ self.delayable().mark_job_as_failed(cycle, _("Setting entitlements to pending validation failed."))
+ )
main_job.delay()
def _set_pending_validation_entitlements(self, cycle, offset=0, limit=None):
@@ -324,6 +327,9 @@ def _validate_entitlements_async(self, cycle, entitlements_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Validated and Approved.")))
+ main_job.on_error(
+ self.delayable().mark_job_as_failed(cycle, _("Validation and approval of entitlements failed."))
+ )
main_job.delay()
def _validate_entitlements(self, cycle, offset=0, limit=None):
diff --git a/spp_programs/models/managers/payment_manager.py b/spp_programs/models/managers/payment_manager.py
index 37ce2d17..12b133e3 100644
--- a/spp_programs/models/managers/payment_manager.py
+++ b/spp_programs/models/managers/payment_manager.py
@@ -71,9 +71,20 @@ def mark_job_as_done(self, cycle, msg):
:return:
"""
self.ensure_one()
- cycle.is_locked = False
- cycle.locked_reason = None
- cycle.message_post(body=msg)
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
+
+ def mark_job_as_failed(self, cycle, msg):
+ """Run via on_error() when the async payment pipeline fails."""
+ self.ensure_one()
+ cycle.write({"is_locked": False, "locked_reason": False})
+ try:
+ cycle.message_post(body=msg)
+ except Exception:
+ _logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
class DefaultFilePaymentManager(models.Model):
@@ -278,6 +289,7 @@ def _prepare_payments_async(self, cycle, entitlements, entitlements_count):
]
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Prepared payments.")))
+ main_job.on_error(self.delayable().mark_job_as_failed(cycle, _("Preparing payments failed.")))
main_job.delay()
def send_payments(self, batches):
@@ -392,6 +404,7 @@ def _send_payments_async(self, cycle, batches):
]
main_job = group(*jobs)
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Send payments completed.")))
+ main_job.on_error(self.delayable().mark_job_as_failed(cycle, _("Sending payments failed.")))
main_job.delay()
@api.model
diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py
index 21196946..6da85915 100644
--- a/spp_programs/models/managers/program_manager.py
+++ b/spp_programs/models/managers/program_manager.py
@@ -73,13 +73,26 @@ def mark_enroll_eligible_as_done(self):
:return:
"""
self.ensure_one()
- self.program_id.is_locked = False
- self.program_id.locked_reason = None
- self.program_id.message_post(body=_("Eligibility check finished."))
+ program = self.program_id
+ program.write({"is_locked": False, "locked_reason": False})
+ try:
+ program.message_post(body=_("Eligibility check finished."))
+ except Exception:
+ _logger.exception("Failed to post completion chatter on program %s", program.id)
# Compute Statistics
- self.program_id._compute_eligible_beneficiary_count()
- self.program_id._compute_beneficiary_count()
+ program._compute_eligible_beneficiary_count()
+ program._compute_beneficiary_count()
+
+ def mark_enroll_eligible_as_failed(self):
+ """Run via on_error() when async eligibility enrollment fails."""
+ self.ensure_one()
+ program = self.program_id
+ program.write({"is_locked": False, "locked_reason": False})
+ try:
+ program.message_post(body=_("Eligibility check failed."))
+ except Exception:
+ _logger.exception("Failed to post failure chatter on program %s", program.id)
class DefaultProgramManager(models.Model):
@@ -215,6 +228,7 @@ def _enroll_eligible_registrants_async(self, states, members_count):
)
main_job = group(*jobs)
main_job.on_done(self.delayable(channel="statistics_refresh").mark_enroll_eligible_as_done())
+ main_job.on_error(self.delayable(channel="statistics_refresh").mark_enroll_eligible_as_failed())
main_job.delay()
def _enroll_eligible_registrants(self, states, offset=0, limit=None, min_id=None, max_id=None, do_count=False):
diff --git a/spp_programs/models/programs.py b/spp_programs/models/programs.py
index 92f02766..d23254e6 100644
--- a/spp_programs/models/programs.py
+++ b/spp_programs/models/programs.py
@@ -725,6 +725,25 @@ def _get_related_job_domain(self):
related_jobs = jobs.filtered(lambda r: self in r.records.program_id)
return [("id", "in", related_jobs.ids)]
+ def action_force_unlock(self):
+ """Manager-only escape hatch: clear a stuck "Operation in progress" lock.
+
+ Use when an async pipeline died without firing its on_done/on_error
+ callback. Posts an audit line to chatter for traceability.
+ """
+ for rec in self:
+ if not rec.is_locked:
+ continue
+ previous_reason = rec.locked_reason
+ rec.write({"is_locked": False, "locked_reason": False})
+ rec.message_post(
+ body=_(
+ "Lock manually cleared by %(user)s. Previous reason: %(reason)s",
+ user=self.env.user.display_name,
+ reason=previous_reason or _("(none)"),
+ )
+ )
+
@api.constrains(
"entitlement_manager_ids",
"program_manager_ids",
diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py
index e010342c..e5682fe4 100644
--- a/spp_programs/tests/__init__.py
+++ b/spp_programs/tests/__init__.py
@@ -37,3 +37,4 @@
from . import test_keyset_pagination
from . import test_canary_patterns
from . import test_concurrency
+from . import test_async_lock_recovery
diff --git a/spp_programs/tests/test_async_lock_recovery.py b/spp_programs/tests/test_async_lock_recovery.py
new file mode 100644
index 00000000..da6406a0
--- /dev/null
+++ b/spp_programs/tests/test_async_lock_recovery.py
@@ -0,0 +1,357 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Tests for the async-pipeline lock-recovery fix.
+
+The async entitlement / cycle / payment pipelines acquire `is_locked=True`
+on the cycle (or program) before scheduling a queue.job group, and only
+clear it when the on_done callback runs. If anything in the group fails,
+the on_done is cascade-failed and the lock is never released — leaving the
+"Operation in progress" warning stuck on the UI.
+
+These tests exercise the recovery surface:
+- the new `mark_*_as_failed` companions clear the lock too
+- the existing `mark_*_as_done` paths clear the lock first (so a chatter
+ failure can't leave the lock set)
+- `action_force_unlock` is a manager-only escape hatch when no callback
+ fires at all (e.g. server killed mid-operation)
+"""
+
+import uuid
+
+from odoo import fields
+from odoo.tests import TransactionCase
+
+from odoo.addons.spp_programs.models import constants
+
+
+def _new_program(env):
+ """Create a program with default managers (entitlement / cycle / payment / program)
+ auto-attached so tests can call get_manager(...).
+ """
+ return (
+ env["spp.program"]
+ .with_context(create_default_managers=True)
+ .create({"name": f"Async Lock Recovery {uuid.uuid4().hex[:8]}"})
+ )
+
+
+def _new_cycle(env, program):
+ today = fields.Date.today()
+ return env["spp.cycle"].create(
+ {
+ "name": f"Async Lock Cycle {uuid.uuid4().hex[:8]}",
+ "program_id": program.id,
+ "sequence": 1,
+ "start_date": today,
+ "end_date": fields.Date.add(today, days=30),
+ }
+ )
+
+
+class TestEntitlementManagerLockRecovery(TransactionCase):
+ """`mark_job_as_failed` clears the cycle lock and posts failure chatter."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.cycle = _new_cycle(self.env, self.program)
+ # Reach the cash entitlement manager via the program (default manager)
+ self.manager = self.program.get_manager(constants.MANAGER_ENTITLEMENT)
+
+ def test_mark_job_as_failed_clears_lock_and_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Test lock"})
+ before = len(self.cycle.message_ids)
+
+ self.manager.mark_job_as_failed(self.cycle, "Setting entitlements failed.")
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self.assertGreater(len(self.cycle.message_ids), before)
+ self.assertIn("Setting entitlements failed", self.cycle.message_ids[0].body)
+
+ def test_mark_job_as_done_clears_lock_first(self):
+ """Lock is released even if message_post somehow raises."""
+ self.cycle.write({"is_locked": True, "locked_reason": "Test lock"})
+
+ self.manager.mark_job_as_done(self.cycle, "Done.")
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+
+
+class TestCycleManagerLockRecovery(TransactionCase):
+ """Cycle manager exposes failed-companion helpers for each async path."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.cycle = _new_cycle(self.env, self.program)
+ self.manager = self.program.get_manager(constants.MANAGER_CYCLE)
+
+ def test_mark_import_as_failed_clears_lock(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Importing beneficiaries."})
+ self.manager.mark_import_as_failed(self.cycle, "Import failed.")
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+
+ def test_mark_prepare_entitlement_as_failed_clears_lock(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Prepare entitlement for beneficiaries."})
+ self.manager.mark_prepare_entitlement_as_failed(self.cycle, "Prep failed.")
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+
+ def test_mark_check_eligibility_as_failed_clears_lock(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ self.manager.mark_check_eligibility_as_failed(self.cycle)
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+
+
+class TestProgramLockRecovery(TransactionCase):
+ """Programs share the same lock pattern; force-unlock works there too."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.manager = self.program.get_manager(constants.MANAGER_PROGRAM)
+
+ def test_mark_enroll_eligible_as_failed_clears_lock(self):
+ self.program.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ self.manager.mark_enroll_eligible_as_failed()
+ self.assertFalse(self.program.is_locked)
+ self.assertFalse(self.program.locked_reason)
+
+ def test_action_force_unlock_clears_program_lock_and_audits(self):
+ self.program.write({"is_locked": True, "locked_reason": "Enrollment running"})
+ before = len(self.program.message_ids)
+
+ self.program.action_force_unlock()
+
+ self.assertFalse(self.program.is_locked)
+ self.assertFalse(self.program.locked_reason)
+ self.assertGreater(len(self.program.message_ids), before)
+ self.assertIn("manually cleared", self.program.message_ids[0].body)
+
+
+class TestPaymentManagerLockRecovery(TransactionCase):
+ """Payment manager shares the same lock pattern."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.cycle = _new_cycle(self.env, self.program)
+ self.manager = self.program.get_manager(constants.MANAGER_PAYMENT)
+
+ def test_mark_job_as_failed_clears_cycle_lock(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Send payments for batches in cycle."})
+ self.manager.mark_job_as_failed(self.cycle, "Send payments failed.")
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+
+
+class TestMarkAsDoneHardenedPaths(TransactionCase):
+ """The hardened mark_*_as_done success paths must clear the lock first
+ and post completion chatter.
+
+ The fix in OP#188 reorders these methods so the lock is released *before*
+ `message_post` is attempted, with the chatter call wrapped in try/except.
+ Without these tests, the new try/except branches and the reordered write
+ are not exercised — codecov reports the entire method body as uncovered
+ even though the as_failed siblings have similar shape.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.cycle = _new_cycle(self.env, self.program)
+ self.cycle_manager = self.program.get_manager(constants.MANAGER_CYCLE)
+ self.entitlement_manager = self.program.get_manager(constants.MANAGER_ENTITLEMENT)
+ self.payment_manager = self.program.get_manager(constants.MANAGER_PAYMENT)
+ self.program_manager = self.program.get_manager(constants.MANAGER_PROGRAM)
+
+ def _assert_chatter_grew(self, record, before_count, expected_substring):
+ self.assertGreater(len(record.message_ids), before_count)
+ body = record.message_ids[0].body or ""
+ self.assertIn(expected_substring, body)
+
+ def test_cycle_mark_import_as_done_clears_lock_and_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Importing beneficiaries."})
+ before = len(self.cycle.message_ids)
+
+ self.cycle_manager.mark_import_as_done(self.cycle, "Beneficiary import finished.")
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self._assert_chatter_grew(self.cycle, before, "Beneficiary import finished")
+
+ def test_cycle_mark_prepare_entitlement_as_done_clears_lock_and_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Prepare entitlement for beneficiaries."})
+ before = len(self.cycle.message_ids)
+
+ self.cycle_manager.mark_prepare_entitlement_as_done(self.cycle, "Entitlement Ready.")
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self._assert_chatter_grew(self.cycle, before, "Entitlement Ready")
+
+ def test_cycle_mark_check_eligibility_as_done_clears_lock_and_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ before = len(self.cycle.message_ids)
+
+ self.cycle_manager.mark_check_eligibility_as_done(self.cycle)
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self._assert_chatter_grew(self.cycle, before, "Eligibility check finished")
+
+ def test_entitlement_mark_job_as_done_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Set entitlements to pending validation for cycle."})
+ before = len(self.cycle.message_ids)
+
+ self.entitlement_manager.mark_job_as_done(self.cycle, "Entitlements Set to Pending Validation.")
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self._assert_chatter_grew(self.cycle, before, "Entitlements Set to Pending Validation")
+
+ def test_payment_mark_job_as_done_clears_lock_and_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Send payments for batches in cycle."})
+ before = len(self.cycle.message_ids)
+
+ self.payment_manager.mark_job_as_done(self.cycle, "Payments sent.")
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self._assert_chatter_grew(self.cycle, before, "Payments sent")
+
+ def test_program_mark_enroll_eligible_as_done_clears_lock_and_posts_chatter(self):
+ self.program.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ before = len(self.program.message_ids)
+
+ self.program_manager.mark_enroll_eligible_as_done()
+
+ self.assertFalse(self.program.is_locked)
+ self.assertFalse(self.program.locked_reason)
+ self._assert_chatter_grew(self.program, before, "Eligibility check finished")
+
+
+class TestMarkAsFailedChatterContent(TransactionCase):
+ """Companion coverage for the as_failed paths: every mark_*_as_failed
+ method must also post a failure note to chatter (not just clear the lock).
+
+ The existing TestEntitlementManagerLockRecovery /
+ TestCycleManagerLockRecovery / TestPaymentManagerLockRecovery tests cover
+ lock clearing but stop short of asserting on the chatter body, so the
+ `cycle.message_post(body=msg)` line in the failure path is exercised but
+ not pinned to a behavioral assertion.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.cycle = _new_cycle(self.env, self.program)
+ self.cycle_manager = self.program.get_manager(constants.MANAGER_CYCLE)
+ self.entitlement_manager = self.program.get_manager(constants.MANAGER_ENTITLEMENT)
+ self.payment_manager = self.program.get_manager(constants.MANAGER_PAYMENT)
+ self.program_manager = self.program.get_manager(constants.MANAGER_PROGRAM)
+
+ def test_cycle_mark_import_as_failed_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Importing beneficiaries."})
+ before = len(self.cycle.message_ids)
+ self.cycle_manager.mark_import_as_failed(self.cycle, "Beneficiary import failed.")
+ self.assertGreater(len(self.cycle.message_ids), before)
+ self.assertIn("Beneficiary import failed", self.cycle.message_ids[0].body)
+
+ def test_cycle_mark_prepare_entitlement_as_failed_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Prepare entitlement for beneficiaries."})
+ before = len(self.cycle.message_ids)
+ self.cycle_manager.mark_prepare_entitlement_as_failed(self.cycle, "Entitlement preparation failed.")
+ self.assertGreater(len(self.cycle.message_ids), before)
+ self.assertIn("Entitlement preparation failed", self.cycle.message_ids[0].body)
+
+ def test_cycle_mark_check_eligibility_as_failed_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ before = len(self.cycle.message_ids)
+ self.cycle_manager.mark_check_eligibility_as_failed(self.cycle)
+ self.assertGreater(len(self.cycle.message_ids), before)
+ self.assertIn("Eligibility check failed", self.cycle.message_ids[0].body)
+
+ def test_payment_mark_job_as_failed_posts_chatter(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Send payments for batches in cycle."})
+ before = len(self.cycle.message_ids)
+ self.payment_manager.mark_job_as_failed(self.cycle, "Send payments failed.")
+ self.assertGreater(len(self.cycle.message_ids), before)
+ self.assertIn("Send payments failed", self.cycle.message_ids[0].body)
+
+ def test_program_mark_enroll_eligible_as_failed_posts_chatter(self):
+ self.program.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
+ before = len(self.program.message_ids)
+ self.program_manager.mark_enroll_eligible_as_failed()
+ self.assertGreater(len(self.program.message_ids), before)
+ self.assertIn("Eligibility check failed", self.program.message_ids[0].body)
+
+
+class TestForceUnlockOnCycle(TransactionCase):
+ """`action_force_unlock` on spp.cycle is the operator escape hatch when
+ no callback (neither on_done nor on_error) ever fires — e.g. the worker
+ was killed mid-pipeline. Mirrors the program-side test."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+ self.cycle = _new_cycle(self.env, self.program)
+
+ def test_action_force_unlock_clears_cycle_lock_and_audits(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Import running"})
+ before = len(self.cycle.message_ids)
+
+ self.cycle.action_force_unlock()
+
+ self.assertFalse(self.cycle.is_locked)
+ self.assertFalse(self.cycle.locked_reason)
+ self.assertGreater(len(self.cycle.message_ids), before)
+ latest = self.cycle.message_ids[0].body
+ self.assertIn("manually cleared", latest)
+ self.assertIn("Import running", latest)
+
+ def test_action_force_unlock_noop_when_cycle_not_locked(self):
+ before = len(self.cycle.message_ids)
+ self.cycle.action_force_unlock()
+ self.assertFalse(self.cycle.is_locked)
+ # No audit message — there was nothing to clear.
+ self.assertEqual(len(self.cycle.message_ids), before)
+
+ def test_action_force_unlock_records_user_in_audit_message(self):
+ self.cycle.write({"is_locked": True, "locked_reason": "Eligibility running"})
+ self.cycle.action_force_unlock()
+ latest = self.cycle.message_ids[0].body
+ # The current user (admin in tests) is part of the audit line.
+ self.assertIn(self.env.user.display_name, latest)
+
+ def test_action_force_unlock_uses_none_placeholder_when_reason_empty(self):
+ # Edge case: lock set but reason missing — the message uses "(none)"
+ # as the placeholder so the audit line stays well-formed.
+ self.cycle.write({"is_locked": True, "locked_reason": False})
+ self.cycle.action_force_unlock()
+ latest = self.cycle.message_ids[0].body
+ self.assertIn("manually cleared", latest)
+
+
+class TestForceUnlockOnProgramExtra(TransactionCase):
+ """Extra coverage for spp.program.action_force_unlock — noop and
+ user-name-in-audit branches that the existing test class skipped."""
+
+ def setUp(self):
+ super().setUp()
+ self.program = _new_program(self.env)
+
+ def test_action_force_unlock_noop_when_program_not_locked(self):
+ before = len(self.program.message_ids)
+ self.program.action_force_unlock()
+ self.assertFalse(self.program.is_locked)
+ self.assertEqual(len(self.program.message_ids), before)
+
+ def test_action_force_unlock_uses_none_placeholder_when_reason_empty(self):
+ self.program.write({"is_locked": True, "locked_reason": False})
+ self.program.action_force_unlock()
+ self.assertFalse(self.program.is_locked)
+ self.assertIn("manually cleared", self.program.message_ids[0].body)
diff --git a/spp_programs/tests/test_cycle.py b/spp_programs/tests/test_cycle.py
index 33269ef1..005247ac 100644
--- a/spp_programs/tests/test_cycle.py
+++ b/spp_programs/tests/test_cycle.py
@@ -465,6 +465,33 @@ def test_is_locked_can_be_set(self):
self.assertTrue(cycle.is_locked)
self.assertEqual(cycle.locked_reason, "Background import in progress")
+ def test_action_force_unlock_clears_lock_and_audits(self):
+ """action_force_unlock clears the lock and records who did it in chatter."""
+ cycle = self._make_cycle(name="Force Unlock Cycle [CYCLE TEST]")
+ cycle.write({"is_locked": True, "locked_reason": "Import running"})
+ message_count_before = len(cycle.message_ids)
+
+ cycle.action_force_unlock()
+
+ self.assertFalse(cycle.is_locked)
+ self.assertFalse(cycle.locked_reason)
+ # An audit message was posted
+ self.assertGreater(len(cycle.message_ids), message_count_before)
+ latest = cycle.message_ids[0]
+ self.assertIn("manually cleared", latest.body)
+ self.assertIn("Import running", latest.body)
+
+ def test_action_force_unlock_noop_when_not_locked(self):
+ """action_force_unlock is a no-op when the cycle is not locked."""
+ cycle = self._make_cycle(name="Already Unlocked Cycle [CYCLE TEST]")
+ message_count_before = len(cycle.message_ids)
+
+ cycle.action_force_unlock()
+
+ self.assertFalse(cycle.is_locked)
+ # No audit message — nothing to unlock
+ self.assertEqual(len(cycle.message_ids), message_count_before)
+
# ------------------------------------------------------------------
# get_entitlements
# ------------------------------------------------------------------
diff --git a/spp_programs/views/cycle_view.xml b/spp_programs/views/cycle_view.xml
index eafa1584..c4a7cae4 100644
--- a/spp_programs/views/cycle_view.xml
+++ b/spp_programs/views/cycle_view.xml
@@ -252,6 +252,15 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
class="btn btn-sm btn-warning ms-2"
icon="fa-refresh"
string="Refresh"
+ />
+
diff --git a/spp_programs/views/programs_view.xml b/spp_programs/views/programs_view.xml
index a460063e..b3c05f3b 100644
--- a/spp_programs/views/programs_view.xml
+++ b/spp_programs/views/programs_view.xml
@@ -180,6 +180,15 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
class="btn btn-sm btn-warning ms-2"
icon="fa-refresh"
string="Refresh"
+ />
+