From 8d261ccaf200e5b536e2f12f9006d3a79593ee62 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 5 May 2026 15:00:22 +0800 Subject: [PATCH 1/3] fix(spp_programs): release stuck cycle/program lock when async pipeline fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async entitlement / cycle / payment / enrollment pipelines acquired is_locked=True before scheduling a queue.job group, but only released the flag inside the on_done callback. If any child job failed, the on_done was cascade-failed (never executed) and the cycle remained "Operation in progress" forever — even though no active queue jobs were left to drive it. Same vulnerability on spp.program for the enrollment flow. Wires a paired on_error() callback at every async site so the lock is cleared on both success and failure paths. Adds mark_*_as_failed companions for each existing mark_*_as_done, and hardens the success path so a chatter exception can't strand the lock either. Adds an admin-only action_force_unlock action (with audit chatter) on spp.cycle and spp.program for the residual case where neither callback fires at all (e.g. server killed mid-operation, pre-fix data). Requires job_worker >= 19.0.1.1.0 for the on_error() / run_on_failure semantics. Bumps spp_programs to 19.0.2.1.0. Signed-off-by: Red --- spp_programs/__manifest__.py | 2 +- spp_programs/models/cycle.py | 21 +++ .../models/managers/cycle_manager_base.py | 59 ++++++-- .../managers/entitlement_manager_base.py | 34 ++++- .../managers/entitlement_manager_cash.py | 3 + .../managers/entitlement_manager_inkind.py | 6 + .../models/managers/payment_manager.py | 19 ++- .../models/managers/program_manager.py | 24 ++- spp_programs/models/programs.py | 19 +++ spp_programs/tests/__init__.py | 1 + .../tests/test_async_lock_recovery.py | 137 ++++++++++++++++++ spp_programs/tests/test_cycle.py | 27 ++++ spp_programs/views/cycle_view.xml | 9 ++ spp_programs/views/programs_view.xml | 9 ++ 14 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 spp_programs/tests/test_async_lock_recovery.py 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..7104ab8e 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,23 @@ 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.")) + 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.""" + 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 +567,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 +657,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 +908,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..366c2c82 --- /dev/null +++ b/spp_programs/tests/test_async_lock_recovery.py @@ -0,0 +1,137 @@ +# 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.tests import TransactionCase + +from odoo.addons.spp_programs.models import constants + + +def _new_program(env): + return env["spp.program"].create({"name": f"Async Lock Recovery {uuid.uuid4().hex[:8]}"}) + + +def _new_cycle(env, program): + return env["spp.cycle"].create( + { + "name": f"Async Lock Cycle {uuid.uuid4().hex[:8]}", + "program_id": program.id, + "sequence": 1, + } + ) + + +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) 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" + /> +