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" + /> +