diff --git a/cms/envs/common.py b/cms/envs/common.py index b38d869c56..a8ba7ef032 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1403,6 +1403,9 @@ INSTALLED_APPS = [ # Catalog integration 'openedx.core.djangoapps.catalog', + # Programs support + 'openedx.core.djangoapps.programs.apps.ProgramsConfig', + # django-oauth-toolkit 'oauth2_provider', diff --git a/openedx/core/djangoapps/models/config/__init__.py b/openedx/core/djangoapps/models/config/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/models/config/waffle.py b/openedx/core/djangoapps/models/config/waffle.py new file mode 100644 index 0000000000..7db160b43b --- /dev/null +++ b/openedx/core/djangoapps/models/config/waffle.py @@ -0,0 +1,39 @@ +""" +This module contains various configuration settings via +waffle switches for the course_details view. +""" + + +from openedx.core.djangoapps.waffle_utils import ( + CourseWaffleFlag, + WaffleFlagNamespace, + WaffleSwitchNamespace +) + +COURSE_DETAIL_WAFFLE_NAMESPACE = 'course_detail' +COURSE_DETAIL_WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name=COURSE_DETAIL_WAFFLE_NAMESPACE) +WAFFLE_SWITCHES = WaffleSwitchNamespace(name=COURSE_DETAIL_WAFFLE_NAMESPACE) + +# Course Override Flag +COURSE_DETAIL_UPDATE_CERTIFICATE_DATE = u'course_detail_update_certificate_date' + + +def waffle_flags(): + """ + Returns the namespaced, cached, audited Waffle flags dictionary for course detail. + """ + return { + COURSE_DETAIL_UPDATE_CERTIFICATE_DATE: CourseWaffleFlag( + waffle_namespace=COURSE_DETAIL_WAFFLE_NAMESPACE, + flag_name=COURSE_DETAIL_UPDATE_CERTIFICATE_DATE, + flag_undefined_default=False, + ) + } + + +def enable_course_detail_update_certificate_date(course_id): + """ + Returns True if course_detail_update_certificate_date course override flag is enabled, + otherwise False. + """ + return waffle_flags()[COURSE_DETAIL_UPDATE_CERTIFICATE_DATE].is_enabled(course_id) diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index e3624f55cb..bdd8e207fb 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -6,8 +6,11 @@ CourseDetails import logging import re +import six from django.conf import settings +from openedx.core.djangoapps.models.config.waffle import enable_course_detail_update_certificate_date +from openedx.core.djangoapps.signals.signals import COURSE_CERT_DATE_CHANGE from openedx.core.djangolib.markup import HTML from openedx.core.lib.courses import course_image_url from xmodule.fields import Date @@ -243,6 +246,8 @@ class CourseDetails(object): if converted != descriptor.certificate_available_date: dirty = True descriptor.certificate_available_date = converted + if enable_course_detail_update_certificate_date(course_key): + COURSE_CERT_DATE_CHANGE.send_robust(sender=cls, course_key=six.text_type(course_key)) if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image: descriptor.course_image = jsondict['course_image_name'] diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py index 0b907f650b..27f53b085b 100644 --- a/openedx/core/djangoapps/programs/signals.py +++ b/openedx/core/djangoapps/programs/signals.py @@ -6,8 +6,12 @@ This module contains signals / handlers related to programs. import logging from django.dispatch import receiver - -from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED +from openedx.core.djangoapps.signals.signals import ( + COURSE_CERT_AWARDED, + COURSE_CERT_CHANGED, + COURSE_CERT_DATE_CHANGE, + COURSE_CERT_REVOKED +) from openedx.core.djangoapps.site_configuration import helpers LOGGER = logging.getLogger(__name__) @@ -174,3 +178,36 @@ def handle_course_cert_revoked(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.v1.tasks import revoke_program_certificates revoke_program_certificates.delay(user.username, course_key) + + +@receiver(COURSE_CERT_DATE_CHANGE, dispatch_uid='course_certificate_date_change_handler') +def handle_course_cert_date_change(sender, course_key, **kwargs): + """ + If course is updated and the certificate_available_date is changed, + schedule a celery task to update visible_date for all certificates + within course. + + Args: + course_key: + refers to the course whose certificate_available_date was updated. + + Returns: + None + + """ + # Import here instead of top of file since this module gets imported before + # the credentials app is loaded, resulting in a Django deprecation warning. + from openedx.core.djangoapps.credentials.models import CredentialsApiConfig + + # Avoid scheduling new tasks if certification is disabled. + if not CredentialsApiConfig.current().is_learner_issuance_enabled: + return + + # schedule background task to process + LOGGER.info( + 'handling COURSE_CERT_DATE_CHANGE for course %s', + course_key, + ) + # import here, because signal is registered at startup, but items in tasks are not yet loaded + from openedx.core.djangoapps.programs.tasks.v1.tasks import update_certificate_visible_date_on_course_update + update_certificate_visible_date_on_course_update.delay(course_key) diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index a280d61d32..913c5d41a8 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -520,3 +520,41 @@ def revoke_program_certificates(self, username, course_key): LOGGER.info(u'There is no program certificates for user %s to revoke', username) LOGGER.info(u'Successfully completed the task revoke_program_certificates for username %s', username) + + +@task(bind=True, ignore_result=True, routing_key=PROGRAM_CERTIFICATES_ROUTING_KEY) +def update_certificate_visible_date_on_course_update(self, course_key): + """ + This task is designed to be called whenever a course is updated with + certificate_available_date so that visible_date is updated on credential + service as well. + + It will get all users within the course that have a certificate and call + the credentials API to update all these certificates visible_date value + to keep certificates in sync on both sides. + + Args: + course_key (str): The course identifier + + Returns: + None + + """ + countdown = 2 ** self.request.retries + # If the credentials config model is disabled for this + # feature, it may indicate a condition where processing of such tasks + # has been temporarily disabled. Since this is a recoverable situation, + # mark this task for retry instead of failing it altogether. + + if not CredentialsApiConfig.current().is_learner_issuance_enabled: + LOGGER.info( + 'Task update_certificate_visible_date_on_course_update cannot be executed when credentials issuance is ' + 'disabled in API config', + ) + raise self.retry(countdown=countdown, max_retries=MAX_RETRIES) + + users_with_certificates_in_course = GeneratedCertificate.eligible_available_certificates.filter( + course_id=course_key).values_list('user__username', flat=True) + + for user in users_with_certificates_in_course: + award_course_certificate.delay(user, str(course_key)) diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py index dfb2817a19..828eb44188 100644 --- a/openedx/core/djangoapps/programs/tests/test_signals.py +++ b/openedx/core/djangoapps/programs/tests/test_signals.py @@ -6,12 +6,17 @@ This module contains tests for programs-related signals and signal handlers. import mock from django.test import TestCase from opaque_keys.edx.keys import CourseKey - from openedx.core.djangoapps.programs.signals import ( - handle_course_cert_awarded, handle_course_cert_changed, handle_course_cert_revoked + handle_course_cert_awarded, + handle_course_cert_changed, + handle_course_cert_date_change, + handle_course_cert_revoked ) from openedx.core.djangoapps.signals.signals import ( - COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED + COURSE_CERT_AWARDED, + COURSE_CERT_CHANGED, + COURSE_CERT_DATE_CHANGE, + COURSE_CERT_REVOKED ) from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -224,3 +229,59 @@ class CertRevokedReceiverTest(TestCase): self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1) self.assertEqual(mock_task.call_count, 1) self.assertEqual(mock_task.call_args[0], (TEST_USERNAME, TEST_COURSE_KEY)) + + +@skip_unless_lms +@mock.patch('openedx.core.djangoapps.programs.tasks.v1.tasks.update_certificate_visible_date_on_course_update.delay') +@mock.patch( + 'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled', + new_callable=mock.PropertyMock, + return_value=False, +) +class CourseCertAvailableDateChangedReceiverTest(TestCase): + """ + Tests for the `handle_course_cert_date_change` signal handler function. + """ + + @property + def signal_kwargs(self): + """ + DRY helper. + """ + return { + 'sender': self.__class__, + 'course_key': TEST_COURSE_KEY, + } + + def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument + """ + Ensures the receiver function is invoked when COURSE_CERT_DATE_CHANGE is + sent. + + Suboptimal: because we cannot mock the receiver function itself (due + to the way django signals work), we mock a configuration call that is + known to take place inside the function. + """ + COURSE_CERT_DATE_CHANGE.send(**self.signal_kwargs) + self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1) + + def test_programs_disabled(self, mock_is_learner_issuance_enabled, mock_task): + """ + Ensures that the receiver function does nothing when the credentials API + configuration is not enabled. + """ + handle_course_cert_date_change(**self.signal_kwargs) + self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1) + self.assertEqual(mock_task.call_count, 0) + + def test_programs_enabled(self, mock_is_learner_issuance_enabled, mock_task): + """ + Ensures that the receiver function invokes the expected celery task + when the credentials API configuration is enabled. + """ + mock_is_learner_issuance_enabled.return_value = True + + handle_course_cert_date_change(**self.signal_kwargs) + + self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1) + self.assertEqual(mock_task.call_count, 1) diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index fd4d1765c3..5bab209a54 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -14,6 +14,8 @@ COURSE_GRADE_CHANGED = Signal(providing_args=["user", "course_grade", "course_ke COURSE_CERT_CHANGED = Signal(providing_args=["user", "course_key", "mode", "status"]) COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "status"]) COURSE_CERT_REVOKED = Signal(providing_args=["user", "course_key", "mode", "status"]) +COURSE_CERT_DATE_CHANGE = Signal(providing_args=["course_key"]) + # Signal that indicates that a user has passed a course. COURSE_GRADE_NOW_PASSED = Signal(