Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spp_programs/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "OpenSPP Programs",
"summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.",
"category": "OpenSPP/Core",
"version": "19.0.2.0.11",
"version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
21 changes: 21 additions & 0 deletions spp_programs/models/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 50 additions & 9 deletions spp_programs/models/managers/cycle_manager_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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):
Comment thread
reichie020212 marked this conversation as resolved.
"""Complete the enrollment of eligible beneficiaries.
Base :meth:`mark_check_eligibility_as_done`.
Expand All @@ -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):
Comment thread
reichie020212 marked this conversation as resolved.
"""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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
34 changes: 31 additions & 3 deletions spp_programs/models/managers/entitlement_manager_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
"""
Expand Down
3 changes: 3 additions & 0 deletions spp_programs/models/managers/entitlement_manager_cash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions spp_programs/models/managers/entitlement_manager_inkind.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 16 additions & 3 deletions spp_programs/models/managers/payment_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions spp_programs/models/managers/program_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 19 additions & 0 deletions spp_programs/models/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions spp_programs/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
from . import test_keyset_pagination
from . import test_canary_patterns
from . import test_concurrency
from . import test_async_lock_recovery
Loading
Loading