feat: update task and signals responsible for cert available dates in Credentials
[APER-3229] The monolith and the Credentials IDA keep independent records of a course runs certificate availability/visibility preferences. This PR aims to improve the communication between the monolith and the Credentiala IDA to keep the availability date/preference in sync with the monoliths records. The current code is too strict and actually prevents valid updates in some configurations. Additionally, the Credentials IDA doesn't understand the concept of "course pacing" (instructor-paced vs self-paced) and has troubles with courses with an availability date of "end". Instead of having to add the concept of course pacing (and syncing more data between the two systems), this PR proposes sending the end date of a course as the "certificate available date" to Credentials. This way, the Credentials IDA can manage the visibility of awarded credentials in a course run with a display behavior of "end" using the existing feature set and models of the Credentials service.
This commit is contained in:
@@ -11,7 +11,8 @@ from django.dispatch import Signal
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_DATE_CHANGE
|
||||
from xmodule.modulestore.django import SignalHandler # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
from xmodule.modulestore.django import SignalHandler
|
||||
|
||||
from .models import CourseOverview
|
||||
|
||||
@@ -30,8 +31,8 @@ DELETE_COURSE_DETAILS = Signal()
|
||||
@receiver(SignalHandler.course_published)
|
||||
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Catches the signal that a course has been published in Studio and
|
||||
updates the corresponding CourseOverview cache entry.
|
||||
Catches the signal that a course has been published in Studio and updates the corresponding CourseOverview cache
|
||||
entry.
|
||||
"""
|
||||
try:
|
||||
previous_course_overview = CourseOverview.objects.get(id=course_key)
|
||||
@@ -72,13 +73,37 @@ def trigger_import_course_details_signal(sender, instance, created, **kwargs):
|
||||
|
||||
|
||||
def _check_for_course_changes(previous_course_overview, updated_course_overview):
|
||||
"""
|
||||
Utility function responsible for calling other utility functions that check for specific changes in a course
|
||||
overview after a course run has been updated and published.
|
||||
|
||||
Args:
|
||||
previous_course_overview (CourseOverview): the current course overview instance for a particular course run
|
||||
updated_course_overview (CourseOverview): an updated course overview instance, reflecting the current state of
|
||||
data from the modulestore/Mongo
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if previous_course_overview:
|
||||
_check_for_course_date_changes(previous_course_overview, updated_course_overview)
|
||||
_check_for_course_start_date_changes(previous_course_overview, updated_course_overview)
|
||||
_check_for_pacing_changes(previous_course_overview, updated_course_overview)
|
||||
_check_for_cert_availability_date_changes(previous_course_overview, updated_course_overview)
|
||||
_check_for_cert_date_changes(previous_course_overview, updated_course_overview)
|
||||
|
||||
|
||||
def _check_for_course_date_changes(previous_course_overview, updated_course_overview):
|
||||
def _check_for_course_start_date_changes(previous_course_overview, updated_course_overview):
|
||||
"""
|
||||
Checks if a course run's start date has been updated. If so, we emit the `COURSE_START_DATE_CHANGED` signal to
|
||||
ensure other parts of the system are aware of the change.
|
||||
|
||||
Args:
|
||||
previous_course_overview (CourseOverview): the current course overview instance for a particular course run
|
||||
updated_course_overview (CourseOverview): an updated course overview instance, reflecting the current state of
|
||||
data from the modulestore/Mongo
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if previous_course_overview.start != updated_course_overview.start:
|
||||
_log_start_date_change(previous_course_overview, updated_course_overview)
|
||||
COURSE_START_DATE_CHANGED.send(
|
||||
@@ -88,21 +113,46 @@ def _check_for_course_date_changes(previous_course_overview, updated_course_over
|
||||
)
|
||||
|
||||
|
||||
def _log_start_date_change(previous_course_overview, updated_course_overview): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def _log_start_date_change(previous_course_overview, updated_course_overview):
|
||||
"""
|
||||
Utility function to log a course run's start date when updating a course overview. This log only appears when the
|
||||
start date has been changed (see the `_check_for_course_date_changes` function above).
|
||||
|
||||
Args:
|
||||
previous_course_overview (CourseOverview): the current course overview instance for a particular course run
|
||||
updated_course_overview (CourseOverview): an updated course overview instance, reflecting the current state of
|
||||
data from the modulestore/Mongo
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
previous_start_str = 'None'
|
||||
if previous_course_overview.start is not None:
|
||||
previous_start_str = previous_course_overview.start.isoformat()
|
||||
new_start_str = 'None'
|
||||
if updated_course_overview.start is not None:
|
||||
new_start_str = updated_course_overview.start.isoformat()
|
||||
LOG.info('Course start date changed: course={} previous={} new={}'.format(
|
||||
updated_course_overview.id,
|
||||
previous_start_str,
|
||||
new_start_str,
|
||||
))
|
||||
LOG.info(
|
||||
f"Course start date changed: course={updated_course_overview.id} previous={previous_start_str} "
|
||||
f"new={new_start_str}"
|
||||
)
|
||||
|
||||
|
||||
def _check_for_pacing_changes(previous_course_overview, updated_course_overview):
|
||||
"""
|
||||
Checks if a course run's pacing has been updated. If so, we emit the `COURSE_PACING_CHANGED` signal to ensure other
|
||||
parts of the system are aware of the change. The `programs` and `certificates` apps listen for this signal in
|
||||
order to manage certificate generation features in the LMS and certificate visibility settings in the Credentials
|
||||
IDA.
|
||||
|
||||
Args:
|
||||
previous_course_overview (CourseOverview): the current course overview instance for a particular course run
|
||||
updated_course_overview (CourseOverview): an updated course overview instance, reflecting the current state of
|
||||
data from the modulestore/Mongo
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if previous_course_overview.self_paced != updated_course_overview.self_paced:
|
||||
COURSE_PACING_CHANGED.send(
|
||||
sender=None,
|
||||
@@ -111,19 +161,58 @@ def _check_for_pacing_changes(previous_course_overview, updated_course_overview)
|
||||
)
|
||||
|
||||
|
||||
def _check_for_cert_availability_date_changes(previous_course_overview, updated_course_overview):
|
||||
""" Checks if the cert available date has changed and if so, sends a COURSE_CERT_DATE_CHANGE signal"""
|
||||
if previous_course_overview.certificate_available_date != updated_course_overview.certificate_available_date:
|
||||
def _check_for_cert_date_changes(previous_course_overview, updated_course_overview):
|
||||
"""
|
||||
Checks if the certificate available date (CAD) or the certificates display behavior (CDB) of a course run has
|
||||
changed during a course overview update. If so, we emit the COURSE_CERT_DATE_CHANGE signal to ensure other parts of
|
||||
the system are aware of the change. The `credentials` app listens for this signal in order to keep our certificate
|
||||
visibility settings in the Credentials IDA up to date.
|
||||
|
||||
Args:
|
||||
previous_course_overview (CourseOverview): the current course overview instance for a particular course run
|
||||
updated_course_overview (CourseOverview): an updated course overview instance, reflecting the current state of
|
||||
data from the modulestore/Mongo
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
def _send_course_cert_date_change_signal():
|
||||
"""
|
||||
A callback used to fire the COURSE_CERT_DATE_CHANGE Django signal *after* the ORM has successfully commited the
|
||||
update.
|
||||
"""
|
||||
COURSE_CERT_DATE_CHANGE.send_robust(sender=None, course_key=str(updated_course_overview.id))
|
||||
|
||||
course_run_id = str(updated_course_overview.id)
|
||||
prev_available_date = previous_course_overview.certificate_available_date
|
||||
prev_display_behavior = previous_course_overview.certificates_display_behavior
|
||||
prev_end_date = previous_course_overview.end # `end_date` is a deprecated field, use `end` instead
|
||||
updated_available_date = updated_course_overview.certificate_available_date
|
||||
updated_display_behavior = updated_course_overview.certificates_display_behavior
|
||||
updated_end_date = updated_course_overview.end # `end_date` is a deprecated field, use `end` instead
|
||||
send_signal = False
|
||||
|
||||
if prev_available_date != updated_available_date:
|
||||
LOG.info(
|
||||
f"Certificate availability date for {str(updated_course_overview.id)} has changed from " +
|
||||
f"{previous_course_overview.certificate_available_date} to " +
|
||||
f"{updated_course_overview.certificate_available_date}. Sending COURSE_CERT_DATE_CHANGE signal."
|
||||
f"The certificate available date for {course_run_id} has changed from {prev_available_date} to "
|
||||
f"{updated_available_date}"
|
||||
)
|
||||
send_signal = True
|
||||
|
||||
def _send_course_cert_date_change_signal():
|
||||
COURSE_CERT_DATE_CHANGE.send_robust(
|
||||
sender=None,
|
||||
course_key=updated_course_overview.id,
|
||||
)
|
||||
if prev_display_behavior != updated_display_behavior:
|
||||
LOG.info(
|
||||
f"The certificates display behavior for {course_run_id} has changed from {prev_display_behavior} to "
|
||||
f"{updated_display_behavior}"
|
||||
)
|
||||
send_signal = True
|
||||
|
||||
# edge case -- if a course run with a cert display behavior of "End date of course" has changed its end date, we
|
||||
# should fire our signal to ensure visibility of certificates managed by the Credentials IDA are corrected too
|
||||
if (updated_display_behavior == CertificatesDisplayBehaviors.END and prev_end_date != updated_end_date):
|
||||
LOG.info(
|
||||
f"The end date for {course_run_id} has changed from {prev_end_date} to {updated_end_date}."
|
||||
)
|
||||
send_signal = True
|
||||
|
||||
if send_signal:
|
||||
transaction.on_commit(_send_course_cert_date_change_signal)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
"""
|
||||
Tests for the course_overviews app's signal functionality.
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
@@ -29,13 +32,32 @@ class CourseOverviewSignalsTestCase(ModuleStoreTestCase):
|
||||
TODAY = datetime.datetime.utcnow().replace(tzinfo=UTC)
|
||||
NEXT_WEEK = TODAY + datetime.timedelta(days=7)
|
||||
|
||||
def assert_changed_signal_sent(self, changes, mock_signal):
|
||||
"""
|
||||
Utility function used to verify that an emulated change to a course overview results in the expected signals
|
||||
being fired by the system.
|
||||
"""
|
||||
course = CourseFactory.create(
|
||||
emit_signals=True,
|
||||
**{change.field_name: change.initial_value for change in changes}
|
||||
)
|
||||
|
||||
# changing display name doesn't fire the signal
|
||||
with self.captureOnCommitCallbacks(execute=True) as callbacks:
|
||||
course.display_name = course.display_name + 'changed'
|
||||
course = self.store.update_item(course, ModuleStoreEnum.UserID.test)
|
||||
assert not mock_signal.called
|
||||
|
||||
# changing the given field fires the signal
|
||||
with self.captureOnCommitCallbacks(execute=True) as callbacks:
|
||||
for change in changes:
|
||||
setattr(course, change.field_name, change.changed_value)
|
||||
self.store.update_item(course, ModuleStoreEnum.UserID.test)
|
||||
assert mock_signal.called
|
||||
|
||||
def test_caching(self):
|
||||
"""
|
||||
Tests that CourseOverview structures are actually getting cached.
|
||||
|
||||
Arguments:
|
||||
modulestore_type (ModuleStoreEnum.Type): type of store to create the
|
||||
course in.
|
||||
"""
|
||||
# Creating a new course will trigger a publish event and the course will be cached
|
||||
course = CourseFactory.create(emit_signals=True)
|
||||
@@ -46,12 +68,7 @@ class CourseOverviewSignalsTestCase(ModuleStoreTestCase):
|
||||
|
||||
def test_cache_invalidation(self):
|
||||
"""
|
||||
Tests that when a course is published or deleted, the corresponding
|
||||
course_overview is removed from the cache.
|
||||
|
||||
Arguments:
|
||||
modulestore_type (ModuleStoreEnum.Type): type of store to create the
|
||||
course in.
|
||||
Tests that when a course is published or deleted, the corresponding course_overview is removed from the cache.
|
||||
"""
|
||||
# Create a course where mobile_available is True.
|
||||
course = CourseFactory.create(mobile_available=True)
|
||||
@@ -74,31 +91,18 @@ class CourseOverviewSignalsTestCase(ModuleStoreTestCase):
|
||||
self.store.delete_course(course.id, ModuleStoreEnum.UserID.test)
|
||||
CourseOverview.get_from_id(course.id)
|
||||
|
||||
def assert_changed_signal_sent(self, changes, mock_signal): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
course = CourseFactory.create(
|
||||
emit_signals=True,
|
||||
**{change.field_name: change.initial_value for change in changes}
|
||||
)
|
||||
|
||||
# changing display name doesn't fire the signal
|
||||
with self.captureOnCommitCallbacks(execute=True) as callbacks:
|
||||
course.display_name = course.display_name + 'changed'
|
||||
course = self.store.update_item(course, ModuleStoreEnum.UserID.test)
|
||||
assert not mock_signal.called
|
||||
|
||||
# changing the given field fires the signal
|
||||
with self.captureOnCommitCallbacks(execute=True) as callbacks:
|
||||
for change in changes:
|
||||
setattr(course, change.field_name, change.changed_value)
|
||||
self.store.update_item(course, ModuleStoreEnum.UserID.test)
|
||||
assert mock_signal.called
|
||||
|
||||
@patch('openedx.core.djangoapps.content.course_overviews.signals.COURSE_START_DATE_CHANGED.send')
|
||||
def test_start_changed(self, mock_signal):
|
||||
"""
|
||||
A test that ensures the `COURSE_STATE_DATE_CHANGED` signal is emit when the start date of course run is updated.
|
||||
"""
|
||||
self.assert_changed_signal_sent([Change('start', self.TODAY, self.NEXT_WEEK)], mock_signal)
|
||||
|
||||
@patch('openedx.core.djangoapps.content.course_overviews.signals.COURSE_PACING_CHANGED.send')
|
||||
def test_pacing_changed(self, mock_signal):
|
||||
"""
|
||||
A test that ensures the `COURSE_PACING_CHANGED` signal is emit when the pacing type of a course run is updated.
|
||||
"""
|
||||
self.assert_changed_signal_sent([Change('self_paced', True, False)], mock_signal)
|
||||
|
||||
@patch('openedx.core.djangoapps.content.course_overviews.signals.COURSE_CERT_DATE_CHANGE.send_robust')
|
||||
@@ -112,3 +116,21 @@ class CourseOverviewSignalsTestCase(ModuleStoreTestCase):
|
||||
)
|
||||
]
|
||||
self.assert_changed_signal_sent(changes, mock_signal)
|
||||
|
||||
@patch('openedx.core.djangoapps.content.course_overviews.signals.COURSE_CERT_DATE_CHANGE.send_robust')
|
||||
def test_cert_end_date_changed(self, mock_signal):
|
||||
"""
|
||||
This test ensures when an instructor-paced course with a certificates display behavior of "END" updates its end
|
||||
date that we emit the `COURSE_CERT_DATE_CHANGE` signal.
|
||||
"""
|
||||
course = CourseFactory.create(
|
||||
emit_signals=True,
|
||||
end=self.TODAY,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END
|
||||
)
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
course.end = self.NEXT_WEEK
|
||||
self.store.update_item(course, ModuleStoreEnum.UserID.test)
|
||||
|
||||
assert mock_signal.called
|
||||
|
||||
15
openedx/core/djangoapps/credentials/api.py
Normal file
15
openedx/core/djangoapps/credentials/api.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Python APIs exposed by the credentials app to other in-process apps.
|
||||
"""
|
||||
|
||||
|
||||
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
||||
|
||||
|
||||
def is_credentials_enabled():
|
||||
"""
|
||||
A utility function wrapping the `is_learner_issurance_enabled` utility function of the CredentialsApiConfig model.
|
||||
Intended to be an easier to read/grok utility function that informs the caller if use of the Credentials IDA is
|
||||
enabled for this Open edX instance.
|
||||
"""
|
||||
return CredentialsApiConfig.current().is_learner_issuance_enabled
|
||||
46
openedx/core/djangoapps/credentials/tests/test_api.py
Normal file
46
openedx/core/djangoapps/credentials/tests/test_api.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Tests for the utility functions defined as part of the credentials app's public Python API.
|
||||
"""
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from openedx.core.djangoapps.credentials.api import is_credentials_enabled
|
||||
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
||||
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
class CredentialsApiTests(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the Public Pyton API exposed by the credentials Django app.
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
CredentialsApiConfig.objects.all().delete()
|
||||
|
||||
@skip_unless_lms
|
||||
def test_is_credentials_enabled_config_enabled(self):
|
||||
"""
|
||||
A test that verifies the output of the `is_credentials_enabled` utility function when a CredentialsApiConfig
|
||||
exists and is enabled.
|
||||
"""
|
||||
self.create_credentials_config(enabled=True)
|
||||
assert is_credentials_enabled()
|
||||
|
||||
@skip_unless_lms
|
||||
def test_is_credentials_enabled_config_disabled(self):
|
||||
"""
|
||||
A test that verifies the output of the `is_credentials_enabled` utility function when a CredentialsApiConfig
|
||||
exists and is disabled.
|
||||
"""
|
||||
self.create_credentials_config(enabled=False)
|
||||
assert not is_credentials_enabled()
|
||||
|
||||
@skip_unless_lms
|
||||
def test_is_credentials_enabled_config_absent(self):
|
||||
"""
|
||||
A test that verifies the output of the `is_credentials_enabled` utility function when a CredentialsApiConfig
|
||||
does not exist.
|
||||
"""
|
||||
assert not is_credentials_enabled()
|
||||
@@ -7,6 +7,8 @@ import logging
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACING_CHANGED
|
||||
from openedx.core.djangoapps.credentials.api import is_credentials_enabled
|
||||
from openedx.core.djangoapps.credentials.helpers import is_learner_records_enabled_for_org
|
||||
from openedx.core.djangoapps.signals.signals import (
|
||||
COURSE_CERT_AWARDED,
|
||||
@@ -21,41 +23,24 @@ LOGGER = logging.getLogger(__name__)
|
||||
@receiver(COURSE_CERT_AWARDED)
|
||||
def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
If programs is enabled and a learner is awarded a course certificate,
|
||||
schedule a celery task to process any programs certificates for which
|
||||
the learner may now be eligible.
|
||||
If use of the Credentials IDA is enabled and a learner is awarded a course certificate, schedule a celery task to
|
||||
determine if the learner is also eligible to be awarded any program certificates.
|
||||
|
||||
Args:
|
||||
sender:
|
||||
class of the object instance that sent this signal
|
||||
user:
|
||||
django.contrib.auth.User - the user to whom a cert was awarded
|
||||
course_key:
|
||||
refers to the course run for which the cert was awarded
|
||||
mode:
|
||||
mode / certificate type, e.g. "verified"
|
||||
status:
|
||||
either "downloadable" or "generating"
|
||||
sender: class of the object instance that sent this signal
|
||||
user(User): The user to whom a course certificate was awarded
|
||||
course_key(CourseLocator): The course run key for which the course certificate was awarded
|
||||
mode(str): The "mode" of the course (e.g. Audit, Honor, Verified, etc.)
|
||||
status(str): The status of the course certificate that was awarded (e.g. "downloadable")
|
||||
|
||||
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:
|
||||
if not is_credentials_enabled():
|
||||
return
|
||||
|
||||
# schedule background task to process
|
||||
LOGGER.debug(
|
||||
'handling COURSE_CERT_AWARDED: username=%s, course_key=%s, mode=%s, status=%s',
|
||||
user,
|
||||
course_key,
|
||||
mode,
|
||||
status,
|
||||
f"Handling COURSE_CERT_AWARDED: user={user}, course_key={course_key}, mode={mode}, status={status}"
|
||||
)
|
||||
# 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_program_certificates
|
||||
@@ -63,75 +48,43 @@ def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs)
|
||||
|
||||
|
||||
@receiver(COURSE_CERT_CHANGED)
|
||||
def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs):
|
||||
def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
If a learner is awarded a course certificate,
|
||||
schedule a celery task to process that course certificate
|
||||
When the system updates a course certificate, enqueue a celery task responsible for syncing this change in the
|
||||
Credentials IDA
|
||||
|
||||
Args:
|
||||
sender:
|
||||
class of the object instance that sent this signal
|
||||
user:
|
||||
django.contrib.auth.User - the user to whom a cert was awarded
|
||||
course_key:
|
||||
refers to the course run for which the cert was awarded
|
||||
mode:
|
||||
mode / certificate type, e.g. "verified"
|
||||
status:
|
||||
"downloadable"
|
||||
***** Important *****
|
||||
While the current name of the enqueue'd task is `award_course_certificate` it is *actually* responsible for both
|
||||
awarding and revocation of course certificates in Credentials.
|
||||
*********************
|
||||
|
||||
Returns:
|
||||
None
|
||||
Args:
|
||||
sender: class of the object instance that sent this signal
|
||||
user(User): The user to whom a course certificate was awarded
|
||||
course_key(CourseLocator): The course run key for which the course certificate was awarded
|
||||
mode(str): The "mode" of the course (e.g. Audit, Honor, Verified, etc.)
|
||||
status(str): The status of the course certificate that was awarded (e.g. "downloadable")
|
||||
|
||||
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
|
||||
|
||||
verbose = kwargs.get('verbose', False)
|
||||
if verbose:
|
||||
msg = "Starting handle_course_cert_changed with params: "\
|
||||
"sender [{sender}], "\
|
||||
"user [{username}], "\
|
||||
"course_key [{course_key}], "\
|
||||
"mode [{mode}], "\
|
||||
"status [{status}], "\
|
||||
"kwargs [{kw}]"\
|
||||
.format(
|
||||
sender=sender,
|
||||
username=getattr(user, 'username', None),
|
||||
course_key=str(course_key),
|
||||
mode=mode,
|
||||
status=status,
|
||||
kw=kwargs
|
||||
)
|
||||
LOGGER.info(
|
||||
f"Starting handle_course_cert_changed with params: sender [{sender}], user [{user}], course_key "
|
||||
f"[{course_key}], mode [{mode}], status [{status}], kwargs [{kwargs}]"
|
||||
)
|
||||
|
||||
LOGGER.info(msg)
|
||||
|
||||
# Avoid scheduling new tasks if certification is disabled.
|
||||
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
||||
if verbose:
|
||||
LOGGER.info("Skipping send cert: is_learner_issuance_enabled False")
|
||||
if not is_credentials_enabled():
|
||||
return
|
||||
|
||||
# Avoid scheduling new tasks if learner records are disabled for this site (right now, course certs are only
|
||||
# used for learner records -- when that changes, we can remove this bit and always send course certs).
|
||||
if not is_learner_records_enabled_for_org(course_key.org):
|
||||
if verbose:
|
||||
LOGGER.info(
|
||||
"Skipping send cert: ENABLE_LEARNER_RECORDS False for org [{org}]".format(
|
||||
org=course_key.org
|
||||
)
|
||||
)
|
||||
LOGGER.warning(f"Skipping send cert: the Learner Record feature is disabled for org [{course_key.org}]")
|
||||
return
|
||||
|
||||
# schedule background task to process
|
||||
LOGGER.debug(
|
||||
'handling COURSE_CERT_CHANGED: username=%s, course_key=%s, mode=%s, status=%s',
|
||||
user,
|
||||
course_key,
|
||||
mode,
|
||||
status,
|
||||
)
|
||||
LOGGER.debug(f"Handling COURSE_CERT_CHANGED: user={user}, course_key={course_key}, mode={mode}, status={status}")
|
||||
# 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))
|
||||
@@ -140,68 +93,76 @@ def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs)
|
||||
@receiver(COURSE_CERT_REVOKED)
|
||||
def handle_course_cert_revoked(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
If programs is enabled and a learner's course certificate is revoked,
|
||||
schedule a celery task to revoke any related program certificates.
|
||||
If use of the Credentials IDA is enabled and a learner has a course certificate revoked, schedule a celery task
|
||||
to determine if there are any program certificates that must be revoked too.
|
||||
|
||||
Args:
|
||||
sender:
|
||||
class of the object instance that sent this signal
|
||||
user:
|
||||
django.contrib.auth.User - the user for which a cert was revoked
|
||||
course_key:
|
||||
refers to the course run for which the cert was revoked
|
||||
mode:
|
||||
mode / certificate type, e.g. "verified"
|
||||
status:
|
||||
revoked
|
||||
sender: class of the object instance that sent this signal
|
||||
user(User): The user to whom a course certificate was revoked
|
||||
course_key(CourseLocator): The course run key for which the course certificate was revoked
|
||||
mode(str): The "mode" of the course (e.g. "audit", "honor", "verified", etc.)
|
||||
status(str): The status of the course certificate that was revoked (e.g. "revoked")
|
||||
|
||||
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:
|
||||
if not is_credentials_enabled():
|
||||
return
|
||||
|
||||
# schedule background task to process
|
||||
LOGGER.info(
|
||||
f"handling COURSE_CERT_REVOKED: user={user.id}, course_key={course_key}, mode={mode}, status={status}"
|
||||
)
|
||||
LOGGER.info(f"Handling COURSE_CERT_REVOKED: user={user}, course_key={course_key}, mode={mode}, status={status}")
|
||||
# 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 revoke_program_certificates
|
||||
revoke_program_certificates.delay(user.username, str(course_key))
|
||||
|
||||
|
||||
@receiver(COURSE_CERT_DATE_CHANGE, dispatch_uid='course_certificate_date_change_handler')
|
||||
def handle_course_cert_date_change(sender, course_key, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
||||
def handle_course_cert_date_change(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
If a course-run's `certificate_available_date` is updated, schedule a celery task to update the `visible_date`
|
||||
attribute of all (course) credentials awarded in the Credentials service.
|
||||
When a course run's configuration has been updated, and the system has detected an update related to the display
|
||||
behavior or availability date of the certificates issued in that course, we should enqueue celery tasks responsible
|
||||
for:
|
||||
- updating the `visible_date` attribute of any previously awarded certificates the Credentials IDA manages
|
||||
- updating the certificate available date of the course run's course certificate configuration in Credentials
|
||||
|
||||
Args:
|
||||
course_key(CourseLocator): refers to the course whose certificate_available_date was updated.
|
||||
"""
|
||||
# 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
|
||||
sender: class of the object instance that sent this signal
|
||||
course_key(CourseLocator): The course run key of the course run which was updated
|
||||
|
||||
# Avoid scheduling new tasks if we're not using the Credentials IDA
|
||||
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
||||
LOGGER.warning(
|
||||
f"Skipping handling of COURSE_CERT_DATE_CHANGE for course {course_key}. Use of the Credentials service is "
|
||||
"disabled."
|
||||
)
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if not is_credentials_enabled():
|
||||
return
|
||||
|
||||
LOGGER.info(f"Handling COURSE_CERT_DATE_CHANGE for course {course_key}")
|
||||
# import here, because signal is registered at startup, but items in tasks are not yet loaded
|
||||
from openedx.core.djangoapps.programs.tasks import update_certificate_visible_date_on_course_update
|
||||
from openedx.core.djangoapps.programs.tasks import update_certificate_available_date_on_course_update
|
||||
# update the awarded credentials `visible_date` attribute in the Credentials service after a date update
|
||||
from openedx.core.djangoapps.programs.tasks import update_certificate_visible_date_on_course_update
|
||||
update_certificate_visible_date_on_course_update.delay(str(course_key))
|
||||
# update the (course) certificate configuration in the Credentials service after a date update
|
||||
update_certificate_available_date_on_course_update.delay(str(course_key))
|
||||
|
||||
|
||||
@receiver(COURSE_PACING_CHANGED, dispatch_uid="update_credentials_on_pacing_change")
|
||||
def handle_course_pacing_change(sender, updated_course_overview, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
If the pacing of a course run has been updated, we should enqueue the tasks responsible for updating the certificate
|
||||
available date (CAD) stored in the Credentials IDA's internal records. This ensures that we are correctly managing
|
||||
the visibiltiy of certificates on learners' program records.
|
||||
|
||||
Args:
|
||||
sender: class of the object instance that sent this signal
|
||||
updated_course_overview(CourseOverview): The course overview of the course run which was just updated
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if not is_credentials_enabled():
|
||||
return
|
||||
|
||||
course_id = str(updated_course_overview.id)
|
||||
LOGGER.info(f"Handling COURSE_PACING_CHANGED for course {course_id}")
|
||||
# import here, because signal is registered at startup, but items in tasks are not yet loaded
|
||||
from openedx.core.djangoapps.programs.tasks import update_certificate_available_date_on_course_update
|
||||
from openedx.core.djangoapps.programs.tasks import update_certificate_visible_date_on_course_update
|
||||
update_certificate_available_date_on_course_update.delay(course_id)
|
||||
update_certificate_visible_date_on_course_update.delay(course_id)
|
||||
|
||||
@@ -18,7 +18,9 @@ from requests.exceptions import HTTPError
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from lms.djangoapps.certificates.api import available_date_for_certificate
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.credentials.api import is_credentials_enabled
|
||||
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
||||
from openedx.core.djangoapps.credentials.utils import (
|
||||
get_credentials,
|
||||
@@ -755,22 +757,21 @@ def update_certificate_visible_date_on_course_update(self, course_key):
|
||||
@set_code_owner_attribute
|
||||
def update_certificate_available_date_on_course_update(self, course_key):
|
||||
"""
|
||||
This task is designed to be called whenever a course-run's `certificate_available_date` is updated.
|
||||
This task is designed to be enqueued whenever a course run's Certificate Display Behavior (CDB) or Certificate
|
||||
Available Date (CAD) has been updated in the CMS.
|
||||
|
||||
When executed, this task will determine if we need to enqueue an
|
||||
`update_credentials_course_certificate_configuration_available_date` task associated with the specified course-run
|
||||
key from this task. If so, this subtask is responsible for making a REST API call to the Credentials IDA to update
|
||||
the specified course-run's Course Certificate configuration with the new `certificate_available_date` value.
|
||||
When executed, this task is responsible for enqueuing an additional subtask responsible for syncing the updated CAD
|
||||
value in the Credentials IDA's internal records.
|
||||
|
||||
Args:
|
||||
course_key(str): The course identifier
|
||||
course_key(str): The course run's identifier
|
||||
"""
|
||||
countdown = 2**self.request.retries
|
||||
|
||||
# If the CredentialsApiConfig configuration 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.
|
||||
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
||||
if not is_credentials_enabled():
|
||||
error_msg = (
|
||||
"Cannot execute the `update_certificate_visible_date_on_course_update` task. Issuing user credentials "
|
||||
"through the Credentials IDA is disabled."
|
||||
@@ -782,39 +783,43 @@ def update_certificate_available_date_on_course_update(self, course_key):
|
||||
)
|
||||
raise self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
course_overview = CourseOverview.get_from_id(course_key)
|
||||
# Update the Credentials service's CourseCertificate configuration with the new `certificate_available_date` if:
|
||||
# - The course-run is instructor-paced, AND
|
||||
# - The `certificates_display_behavior` is set to "end_with_date",
|
||||
if (
|
||||
course_overview
|
||||
and course_overview.self_paced is False
|
||||
and course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
):
|
||||
LOGGER.info(
|
||||
f"Queueing task to update the `certificate_available_date` of course-run {course_key} to "
|
||||
f"[{course_overview.certificate_available_date}] in the Credentials service"
|
||||
)
|
||||
update_credentials_course_certificate_configuration_available_date.delay(
|
||||
str(course_key), str(course_overview.certificate_available_date)
|
||||
)
|
||||
# OR,
|
||||
# - The course-run is self-paced, AND
|
||||
# - The `certificate_available_date` is (now) None. (This task will be executed after an update to the course
|
||||
# overview)
|
||||
# There are times when the CourseCertificate configuration of a self-paced course-run in Credentials can become
|
||||
# associated with a `certificate_available_date`. This ends up causing learners' certificate to be incorrectly
|
||||
# hidden. This is due to the Credentials IDA not understanding the concept of course pacing. Thus, we need a way
|
||||
# to remove this value from self-paced courses in Credentials.
|
||||
elif course_overview and course_overview.self_paced is True and course_overview.certificate_available_date is None:
|
||||
LOGGER.info(
|
||||
"Queueing task to remove the `certificate_available_date` in the Credentials service for course-run "
|
||||
f"{course_key}"
|
||||
)
|
||||
update_credentials_course_certificate_configuration_available_date.delay(str(course_key), None)
|
||||
# ELSE, we don't meet the criteria to update the course cert config in the Credentials IDA
|
||||
else:
|
||||
course_overview = get_course_overview_or_none(course_key)
|
||||
if not course_overview:
|
||||
LOGGER.warning(
|
||||
f"Skipping update of the `certificate_available_date` for course {course_key} in the Credentials service. "
|
||||
"This course-run does not meet the required criteria for an update."
|
||||
f"Unable to send the updated certificate available date of course run [{course_key}] to Credentials. A "
|
||||
"course overview for this course run could not be found"
|
||||
)
|
||||
return
|
||||
|
||||
# When updating the certificate available date of instructor-paced course runs,
|
||||
# - If the display behavior is set to "A date after the course end date" (END_WITH_DATE), we should send the
|
||||
# certificate available date set by the course team in Studio (and stored as part of the course runs Course
|
||||
# Overview)
|
||||
# - If the display behavior is set to "End date of course" (END), we should send the end date of the course run
|
||||
# as the certificate available date. We send the end date because the Credentials IDA doesn't understand the
|
||||
# concept of course pacing and needs an explicit date in order to correctly gate the visibility of course and
|
||||
# program certificates.
|
||||
# - If the display behavior is set to "Immediately upon passing" (EARLY_NO_INFO), we should always send None for
|
||||
# the course runs certificate available date. A course run configured with this display behavior must not have a
|
||||
# certificate available date associated with or the Credentials system will incorrectly hide certificates from
|
||||
# learners.
|
||||
if course_overview.self_paced is False:
|
||||
if course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE:
|
||||
new_certificate_available_date = str(course_overview.certificate_available_date)
|
||||
elif course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END:
|
||||
new_certificate_available_date = str(course_overview.end) # `end_date` is deprecated, use `end` instead
|
||||
elif course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO:
|
||||
new_certificate_available_date = None
|
||||
# Else, this course run is self-paced, and having a certificate available date associated with a self-paced course
|
||||
# run is not allowed. Course runs with this type of pacing should always award a certificate to learners immediately
|
||||
# upon passing. If the system detects that an update must be sent to Credentials, we *always* send a certificate
|
||||
# available date of `None`. We are aware of a defect that sometimes allows a certificate available date to be saved
|
||||
# for a self-paced course run. This is an attempt to prevent bad data from being synced to the Credentials service
|
||||
# too.
|
||||
else:
|
||||
new_certificate_available_date = None
|
||||
|
||||
update_credentials_course_certificate_configuration_available_date.delay(
|
||||
str(course_key),
|
||||
new_certificate_available_date
|
||||
)
|
||||
|
||||
@@ -9,17 +9,21 @@ from django.test import TestCase
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import (
|
||||
CourseOverviewFactory,
|
||||
)
|
||||
from openedx.core.djangoapps.programs.signals import (
|
||||
handle_course_cert_awarded,
|
||||
handle_course_cert_changed,
|
||||
handle_course_cert_date_change,
|
||||
handle_course_cert_revoked
|
||||
handle_course_cert_revoked,
|
||||
handle_course_pacing_change,
|
||||
)
|
||||
from openedx.core.djangoapps.signals.signals import (
|
||||
COURSE_CERT_AWARDED,
|
||||
COURSE_CERT_CHANGED,
|
||||
COURSE_CERT_DATE_CHANGE,
|
||||
COURSE_CERT_REVOKED
|
||||
COURSE_CERT_REVOKED,
|
||||
)
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
@@ -262,19 +266,19 @@ class CourseCertAvailableDateChangedReceiverTest(TestCase):
|
||||
mock_is_learner_issuance_enabled,
|
||||
mock_visible_date_task,
|
||||
mock_cad_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)
|
||||
assert mock_is_learner_issuance_enabled.call_count == 1
|
||||
assert mock_visible_date_task.call_count == 0
|
||||
assert mock_cad_task.call_count == 0
|
||||
|
||||
def test_programs_disabled(self, mock_is_learner_issuance_enabled, mock_visible_date_task, mock_cad_task):
|
||||
"""
|
||||
Ensures that the receiver function does nothing when the credentials API configuration is not enabled.
|
||||
Ensures that the receiver function does not queue any celery tasks if the system is not configured to use the
|
||||
Credentials service.
|
||||
"""
|
||||
handle_course_cert_date_change(**self.signal_kwargs)
|
||||
assert mock_is_learner_issuance_enabled.call_count == 1
|
||||
@@ -283,13 +287,73 @@ class CourseCertAvailableDateChangedReceiverTest(TestCase):
|
||||
|
||||
def test_programs_enabled(self, mock_is_learner_issuance_enabled, mock_visible_date_task, mock_cad_task):
|
||||
"""
|
||||
Ensures that the receiver function invokes the expected celery task when the credentials API configuration is
|
||||
enabled.
|
||||
Ensures that the receiver function enqueues the expected celery tasks when the system is configured to use the
|
||||
Credentials IDA.
|
||||
"""
|
||||
mock_is_learner_issuance_enabled.return_value = True
|
||||
|
||||
handle_course_cert_date_change(**self.signal_kwargs)
|
||||
|
||||
assert mock_is_learner_issuance_enabled.call_count == 1
|
||||
assert mock_visible_date_task.call_count == 1
|
||||
assert mock_cad_task.call_count == 1
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.update_certificate_visible_date_on_course_update.delay')
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update.delay')
|
||||
@mock.patch('openedx.core.djangoapps.programs.signals.is_credentials_enabled')
|
||||
class CoursePacingChangedReceiverTest(TestCase):
|
||||
"""
|
||||
Tests for the `handle_course_pacing_change` signal handler function.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course_overview = CourseOverviewFactory.create(
|
||||
self_paced=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def signal_kwargs(self):
|
||||
"""
|
||||
DRY helper.
|
||||
"""
|
||||
return {
|
||||
'sender': self.__class__,
|
||||
'updated_course_overview': self.course_overview,
|
||||
}
|
||||
|
||||
def test_handle_course_pacing_change_credentials_disabled(
|
||||
self,
|
||||
mock_is_creds_enabled,
|
||||
mock_cad_task,
|
||||
mock_visible_date_task,
|
||||
):
|
||||
"""
|
||||
Test the verifies the behavior of the `handle_course_pacing_change` signal receiver when use of the Credentials
|
||||
IDA is disabled by configuration.
|
||||
"""
|
||||
mock_is_creds_enabled.return_value = False
|
||||
|
||||
handle_course_pacing_change(**self.signal_kwargs)
|
||||
assert mock_is_creds_enabled.call_count == 1
|
||||
assert mock_visible_date_task.call_count == 0
|
||||
assert mock_cad_task.call_count == 0
|
||||
|
||||
def test_handle_course_pacing_change_credentials_enabled(
|
||||
self,
|
||||
mock_is_creds_enabled,
|
||||
mock_cad_task,
|
||||
mock_visible_date_task
|
||||
):
|
||||
"""
|
||||
Test that verifies the behavior of the `handle_course_pacing_change` signal receiver when use of the Credentials
|
||||
IDA is enabled by configuration.
|
||||
"""
|
||||
mock_is_creds_enabled.return_value = True
|
||||
self.course_overview.self_paced = True
|
||||
|
||||
handle_course_pacing_change(**self.signal_kwargs)
|
||||
assert mock_is_creds_enabled.call_count == 1
|
||||
assert mock_visible_date_task.call_count == 1
|
||||
assert mock_cad_task.call_count == 1
|
||||
|
||||
@@ -1083,94 +1083,176 @@ class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigM
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.end_date = datetime.now(pytz.UTC) + timedelta(days=90)
|
||||
self.credentials_api_config = self.create_credentials_config(enabled=False)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.credentials_api_config = self.create_credentials_config(enabled=False)
|
||||
|
||||
def test_update_certificate_available_date_but_credentials_config_disabled(self):
|
||||
def _create_course_overview(self, self_paced, display_behavior, available_date, end):
|
||||
"""
|
||||
This test verifies the behavior of the `UpdateCertificateAvailableDateOnCourseUpdateTestCase` task when the
|
||||
CredentialsApiConfig is disabled.
|
||||
Utility function to generate a CourseOverview with required settings for functions under test.
|
||||
"""
|
||||
return CourseOverviewFactory.create(
|
||||
self_paced=self_paced,
|
||||
end=end,
|
||||
certificate_available_date=available_date,
|
||||
certificates_display_behavior=display_behavior,
|
||||
)
|
||||
|
||||
If the system is configured to _not_ use the Credentials IDA, we should expect this task to eventually throw an
|
||||
exception when the max number of retries has reached.
|
||||
def _update_credentials_api_config(self, is_enabled):
|
||||
"""
|
||||
course = CourseOverviewFactory.create()
|
||||
Utility function to enable or disable use of the Credentials IDA in our test environment for the functions
|
||||
under test.
|
||||
"""
|
||||
self.credentials_api_config.enabled = True
|
||||
self.credentials_api_config.enable_learner_issuance = True
|
||||
|
||||
def test_update_certificate_available_date_credentials_config_disabled(self):
|
||||
"""
|
||||
A test that verifies we do not queue any subtasks to update a certificate available date if use of the
|
||||
Credentials is disabled in config.
|
||||
"""
|
||||
course_overview = self._create_course_overview(
|
||||
False,
|
||||
CertificatesDisplayBehaviors.EARLY_NO_INFO,
|
||||
None,
|
||||
self.end_date,
|
||||
)
|
||||
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.update_certificate_available_date_on_course_update(course.id) # pylint: disable=no-value-for-parameter
|
||||
tasks.update_certificate_available_date_on_course_update(course_overview.id) # pylint: disable=no-value-for-parameter
|
||||
|
||||
def test_update_certificate_available_date_with_self_paced_course(self):
|
||||
@mock.patch(f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay")
|
||||
def test_update_certificate_available_date_instructor_paced_cdb_early_no_info(self, mock_update):
|
||||
"""
|
||||
Happy path test.
|
||||
This test checks that we enqueue an `update_credentials_course_certificate_configuration_available_date` celery
|
||||
task with values we would expect.
|
||||
|
||||
This test verifies that we are queueing a `update_credentials_course_certificate_configuration_available_date`
|
||||
task with the expected arguments when removing a "certificate available date" from a course cert config in
|
||||
Credentials.
|
||||
In this scenario, we have a course overview that...
|
||||
- is instructor-paced
|
||||
- has a certificates display behavior of "EARLY NO INFO" (certificates are visible immediately after
|
||||
generation)
|
||||
- has no certificate available date
|
||||
|
||||
We expect that the task enqueued has a certificate available date of `None`, as the certificates should have no
|
||||
visibility restrictions.
|
||||
"""
|
||||
self.credentials_api_config.enabled = True
|
||||
self.credentials_api_config.enable_learner_issuance = True
|
||||
self._update_credentials_api_config(True)
|
||||
|
||||
course = CourseOverviewFactory.create(self_paced=True, certificate_available_date=None)
|
||||
|
||||
with mock.patch(
|
||||
f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay"
|
||||
) as update_credentials_course_cert_config:
|
||||
tasks.update_certificate_available_date_on_course_update(course.id) # pylint: disable=no-value-for-parameter
|
||||
|
||||
update_credentials_course_cert_config.assert_called_once_with(str(course.id), None)
|
||||
|
||||
def test_update_certificate_available_date_with_instructor_paced_course(self):
|
||||
"""
|
||||
Happy path test.
|
||||
|
||||
This test verifies that we are queueing a `update_credentials_course_certificate_configuration_available_date`
|
||||
task with the expected arguments when updating a "certificate available date" from a course cert config in
|
||||
Credentials.
|
||||
"""
|
||||
self.credentials_api_config.enabled = True
|
||||
self.credentials_api_config.enable_learner_issuance = True
|
||||
|
||||
available_date = datetime.now(pytz.UTC) + timedelta(days=1)
|
||||
|
||||
course = CourseOverviewFactory.create(
|
||||
self_paced=False,
|
||||
certificate_available_date=available_date,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
course_overview = self._create_course_overview(
|
||||
False,
|
||||
CertificatesDisplayBehaviors.EARLY_NO_INFO,
|
||||
None,
|
||||
self.end_date,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay"
|
||||
) as update_credentials_course_cert_config:
|
||||
tasks.update_certificate_available_date_on_course_update(course.id) # pylint: disable=no-value-for-parameter
|
||||
tasks.update_certificate_available_date_on_course_update(course_overview.id) # pylint: disable=no-value-for-parameter
|
||||
mock_update.assert_called_once_with(str(course_overview.id), None)
|
||||
|
||||
update_credentials_course_cert_config.assert_called_once_with(str(course.id), str(available_date))
|
||||
|
||||
def test_update_certificate_available_date_with_expect_no_update(self):
|
||||
@mock.patch(f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay")
|
||||
def test_update_certificate_available_date_instructor_paced_cdb_end(self, mock_update):
|
||||
"""
|
||||
This test verifies that we do _not_ queue a task to update the course certificate configuration in Credentials
|
||||
if the course-run does not meet the required criteria.
|
||||
This test checks that we enqueue an `update_credentials_course_certificate_configuration_available_date` celery
|
||||
task with values we would expect.
|
||||
|
||||
In this scenario, we have a course overview that...
|
||||
- is instructor-paced
|
||||
- has a certificates display behavior of "END" ("End of the course")
|
||||
- has no certificate available date
|
||||
|
||||
We expect that the task enqueued has a certificate available date that matches the end date of the course.
|
||||
"""
|
||||
self.credentials_api_config.enabled = True
|
||||
self.credentials_api_config.enable_learner_issuance = True
|
||||
self._update_credentials_api_config(True)
|
||||
|
||||
available_date = datetime.now(pytz.UTC) + timedelta(days=1)
|
||||
|
||||
course = CourseOverviewFactory.create(
|
||||
self_paced=False,
|
||||
certificate_available_date=available_date,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.EARLY_NO_INFO,
|
||||
course_overview = self._create_course_overview(
|
||||
False,
|
||||
CertificatesDisplayBehaviors.END,
|
||||
None,
|
||||
self.end_date,
|
||||
)
|
||||
|
||||
tasks.update_certificate_available_date_on_course_update(course_overview.id) # pylint: disable=no-value-for-parameter
|
||||
mock_update.assert_called_once_with(str(course_overview.id), str(self.end_date))
|
||||
|
||||
@mock.patch(f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay")
|
||||
def test_update_certificate_available_date_instructor_paced_cdb_end_with_date(self, mock_update):
|
||||
"""
|
||||
This test checks that we enqueue an `update_credentials_course_certificate_configuration_available_date` celery
|
||||
task with values we would expect.
|
||||
|
||||
In this scenario, we have a course overview that...
|
||||
- is instructor-paced
|
||||
- has a certificates display behavior of "END WITH DATE" ("A date after the course ends")
|
||||
- has an end date set to 90 days from today
|
||||
- has a certificate available date set to 120 days from today
|
||||
|
||||
We expect that the task enqueued has a certificate available date that matches the certificate available date
|
||||
explicitly set as part of the course overview.
|
||||
"""
|
||||
self._update_credentials_api_config(True)
|
||||
certificate_available_date = datetime.now(pytz.UTC) + timedelta(days=120)
|
||||
|
||||
course_overview = self._create_course_overview(
|
||||
False,
|
||||
CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
certificate_available_date,
|
||||
self.end_date,
|
||||
)
|
||||
|
||||
tasks.update_certificate_available_date_on_course_update(course_overview.id) # pylint: disable=no-value-for-parameter
|
||||
mock_update.assert_called_once_with(str(course_overview.id), str(certificate_available_date))
|
||||
|
||||
@mock.patch(f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay")
|
||||
def test_update_certificate_available_date_self_paced(self, mock_update):
|
||||
"""
|
||||
This test checks that we enqueue an `update_credentials_course_certificate_configuration_available_date` celery
|
||||
task with values we would expect.
|
||||
|
||||
In this scenario, we have a course overview that...
|
||||
- is self-paced
|
||||
- has a certificates display behavior of "END WITH DATE" ("A date after the course ends")
|
||||
- has an end date set to 90 days from today
|
||||
- has a cerificate available date set 120 days from today
|
||||
|
||||
We expect that the task enqueued has a certificate available date that matches the certificate available date
|
||||
explicitly set as part of the course overview.
|
||||
|
||||
This test case also verifies a change in recent behavior. There is a product defect that allows a self-paced
|
||||
course to sometimes pass a certificate available date to Credentials. This test case also verifies that, if
|
||||
invalid data is set in a course overview, we don't pass it to Credentials.
|
||||
"""
|
||||
self._update_credentials_api_config(True)
|
||||
certificate_available_date = datetime.now(pytz.UTC) + timedelta(days=120)
|
||||
|
||||
course_overview = self._create_course_overview(
|
||||
True,
|
||||
None,
|
||||
certificate_available_date,
|
||||
self.end_date,
|
||||
)
|
||||
|
||||
tasks.update_certificate_available_date_on_course_update(course_overview.id) # pylint: disable=no-value-for-parameter
|
||||
mock_update.assert_called_once_with(str(course_overview.id), None)
|
||||
|
||||
def test_update_certificate_available_date_no_course_overview(self):
|
||||
"""
|
||||
A test case that verifies some logging if the
|
||||
`update_credentials_course_certificate_configuration_available_date` task is queued with an invalid course run
|
||||
id.
|
||||
"""
|
||||
bad_course_run_key = "course-v1:OpenEdx+MtG101x+1T2024"
|
||||
expected_message = (
|
||||
f"Skipping update of the `certificate_available_date` for course {course.id} in the Credentials service. "
|
||||
"This course-run does not meet the required criteria for an update."
|
||||
f"Unable to send the updated certificate available date of course run [{bad_course_run_key}] to "
|
||||
"Credentials. A course overview for this course run could not be found"
|
||||
)
|
||||
|
||||
self._update_credentials_api_config(True)
|
||||
|
||||
with LogCapture(level=logging.WARNING) as log_capture:
|
||||
tasks.update_certificate_available_date_on_course_update(course.id) # pylint: disable=no-value-for-parameter
|
||||
tasks.update_certificate_available_date_on_course_update(bad_course_run_key) # pylint: disable=no-value-for-parameter
|
||||
|
||||
assert len(log_capture.records) == 1
|
||||
assert log_capture.records[0].getMessage() == expected_message
|
||||
log_capture.check_present(
|
||||
('openedx.core.djangoapps.programs.tasks', 'WARNING', expected_message),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user