From e619aef63fa29d070f70ae5af3e33f0e74fc641a Mon Sep 17 00:00:00 2001 From: "waleed.mujahid" Date: Wed, 17 Jun 2026 15:23:26 +0500 Subject: [PATCH] fix: wrap award_course_certificate.delay in transaction.on_commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COURSE_CERT_CHANGED fires synchronously during GeneratedCertificate.save(), which runs inside the generate_certificate Celery task. The .delay() call was enqueuing award_course_certificate before the DB transaction committed, so the task raced ahead and hit eligible_certificates.get() DoesNotExist — exiting silently with no retry, and never posting the course cert to Credentials. Wrapping in transaction.on_commit() guarantees the cert row is committed before the task is enqueued. Fixes: EDLYPRODUCT-5411 --- openedx/core/djangoapps/programs/signals.py | 3 ++- .../core/djangoapps/programs/tests/test_signals.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py index 0a85cbf84efd..a1274bb38a69 100644 --- a/openedx/core/djangoapps/programs/signals.py +++ b/openedx/core/djangoapps/programs/signals.py @@ -4,6 +4,7 @@ import logging +from django.db import transaction from django.dispatch import receiver from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACING_CHANGED @@ -86,7 +87,7 @@ def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs) # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded from openedx.core.djangoapps.programs.tasks import award_course_certificate - award_course_certificate.delay(user.username, str(course_key)) + transaction.on_commit(lambda: award_course_certificate.delay(user.username, str(course_key))) @receiver(COURSE_CERT_REVOKED) diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py index fae9b1dff22a..278c1f53e466 100644 --- a/openedx/core/djangoapps/programs/tests/test_signals.py +++ b/openedx/core/djangoapps/programs/tests/test_signals.py @@ -142,7 +142,8 @@ def test_credentials_disabled(self, mock_is_learner_issuance_enabled, mock_task) assert mock_is_learner_issuance_enabled.call_count == 1 assert mock_task.call_count == 0 - def test_credentials_enabled(self, mock_is_learner_issuance_enabled, mock_task): + @mock.patch("django.db.transaction.on_commit", side_effect=lambda f: f()) + def test_credentials_enabled(self, mock_on_commit, mock_is_learner_issuance_enabled, mock_task): """ Ensures that the receiver function invokes the expected celery task when the credentials API configuration is enabled. @@ -152,23 +153,28 @@ def test_credentials_enabled(self, mock_is_learner_issuance_enabled, mock_task): handle_course_cert_changed(**self.signal_kwargs) assert mock_is_learner_issuance_enabled.call_count == 1 + assert mock_on_commit.call_count == 1 assert mock_task.call_count == 1 assert mock_task.call_args[0] == (TEST_USERNAME, str(TEST_COURSE_KEY)) - def test_records_enabled(self, mock_is_learner_issuance_enabled, mock_task): + @mock.patch("django.db.transaction.on_commit", side_effect=lambda f: f()) + def test_records_enabled(self, mock_on_commit, mock_is_learner_issuance_enabled, mock_task): mock_is_learner_issuance_enabled.return_value = True site_config = SiteConfigurationFactory.create(site_values={"course_org_filter": ["edX"]}) - # Correctly sent + # Correctly sent (scheduled via transaction.on_commit) handle_course_cert_changed(**self.signal_kwargs) + assert mock_on_commit.called assert mock_task.called + mock_on_commit.reset_mock() mock_task.reset_mock() # Correctly not sent site_config.site_values["ENABLE_LEARNER_RECORDS"] = False site_config.save() handle_course_cert_changed(**self.signal_kwargs) + assert not mock_on_commit.called assert not mock_task.called