syncing certificates on course update on credential side.
This commit is contained in:
@@ -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',
|
||||
|
||||
|
||||
0
openedx/core/djangoapps/models/config/__init__.py
Normal file
0
openedx/core/djangoapps/models/config/__init__.py
Normal file
39
openedx/core/djangoapps/models/config/waffle.py
Normal file
39
openedx/core/djangoapps/models/config/waffle.py
Normal file
@@ -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)
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user