Merge pull request #31063 from openedx/jhynes/APER-1941_fix-cad-mgmt-cmd
fix issue with cert available dates not being removed from credentials
This commit is contained in:
@@ -179,32 +179,29 @@ def handle_course_cert_revoked(sender, user, course_key, mode, status, **kwargs)
|
||||
@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
|
||||
"""
|
||||
If course is updated and the certificate_available_date is changed,
|
||||
schedule a celery task to update visible_date for all certificates
|
||||
within course.
|
||||
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.
|
||||
|
||||
Args:
|
||||
course_key (CourseLocator): refers to the course whose certificate_available_date was updated.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
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.
|
||||
# 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.
|
||||
# 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."
|
||||
)
|
||||
return
|
||||
|
||||
# schedule background task to process
|
||||
LOGGER.info(
|
||||
'handling COURSE_CERT_DATE_CHANGE for course %s',
|
||||
course_key,
|
||||
)
|
||||
|
||||
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
|
||||
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))
|
||||
|
||||
@@ -7,7 +7,7 @@ from celery import shared_task
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
@@ -28,6 +28,8 @@ from openedx.core.djangoapps.credentials.utils import (
|
||||
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
LOGGER = get_task_logger(__name__)
|
||||
# Maximum number of retries before giving up on awarding credentials.
|
||||
# For reference, 11 retries with exponential backoff yields a maximum waiting
|
||||
@@ -290,14 +292,21 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
|
||||
|
||||
def post_course_certificate_configuration(client, cert_config, certificate_available_date=None):
|
||||
"""
|
||||
POST to a course_certificates endpoint.
|
||||
Make a POST request to the Credentials IDA's `course_certificates` endpoint (/api/v2/course_certificates/). This
|
||||
endpoint manages the course certificate configurations within the Credentials IDA.
|
||||
|
||||
POST a configuration for a course certificate and the date the certificate will be available.
|
||||
Args:
|
||||
client(Session): An authenticated Credentials API Client
|
||||
cert_config(Dict): A dictionary containing course metadata (course-run key and mode as Strings) important to the
|
||||
Course Certificate Configuration.
|
||||
certificate_available_date(Str): The desired Certificate Available Date for the Course Certificate Configuration
|
||||
in the form of an ISO 8601 DateTime String.
|
||||
"""
|
||||
credentials_api_base_url = get_credentials_api_base_url()
|
||||
api_url = urljoin(f"{credentials_api_base_url}/", "course_certificates/")
|
||||
credentials_api_url = urljoin(f"{credentials_api_base_url}/", "course_certificates/")
|
||||
|
||||
response = client.post(
|
||||
api_url,
|
||||
credentials_api_url,
|
||||
json={
|
||||
"course_id": cert_config['course_id'],
|
||||
"certificate_type": cert_config['mode'],
|
||||
@@ -305,6 +314,17 @@ def post_course_certificate_configuration(client, cert_config, certificate_avail
|
||||
"is_active": True
|
||||
}
|
||||
)
|
||||
|
||||
# Sometimes helpful error context is swallowed when calling `raise_for_status()`. We try to print out any additional
|
||||
# error details here in the hope that it will save someone time when debugging an issue.
|
||||
#
|
||||
# Also... even though this endpoint does an `update_or_create()` on the Credentials side, it always passes back a
|
||||
# 201 on a successful call.
|
||||
if response.status_code != 201:
|
||||
LOGGER.error(
|
||||
"Error creating or updating a course certificate configuration in the Credentials IDA. Additional details: "
|
||||
f"{response.text}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
@@ -358,8 +378,8 @@ def update_credentials_course_certificate_configuration_available_date(
|
||||
not provided, it will be None.
|
||||
"""
|
||||
LOGGER.info(
|
||||
f"Running task update_credentials_course_certificate_configuration_available_date for course {course_key} \
|
||||
with certificate_available_date {certificate_available_date}"
|
||||
f"Running task `update_credentials_course_certificate_configuration_available_date` for course {course_key} "
|
||||
f"with certificate_available_date {certificate_available_date}"
|
||||
)
|
||||
course_key = str(course_key)
|
||||
course_modes = CourseMode.objects.filter(course_id=course_key)
|
||||
@@ -389,11 +409,13 @@ def update_credentials_course_certificate_configuration_available_date(
|
||||
@set_code_owner_attribute
|
||||
def award_course_certificate(self, username, course_run_key):
|
||||
"""
|
||||
This task is designed to be called whenever a student GeneratedCertificate is updated.
|
||||
This task is designed to be called whenever a student GeneratedCertificate is updated, or when a course-run's
|
||||
`certificate_available_date` value is updated.
|
||||
|
||||
It can be called independently for a username and a course_run, but is invoked on each GeneratedCertificate.save.
|
||||
|
||||
If this function is moved, make sure to update it's entry in
|
||||
EXPLICIT_QUEUES in the settings files so it runs in the correct queue.
|
||||
If this function is moved, make sure to update it's entry in EXPLICIT_QUEUES in the settings files so it runs in the
|
||||
correct queue.
|
||||
|
||||
Arguments:
|
||||
username (str): The user to award the Credentials course cert to
|
||||
@@ -417,7 +439,6 @@ def award_course_certificate(self, username, course_run_key):
|
||||
# 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:
|
||||
error_msg = (
|
||||
"Task award_course_certificate cannot be executed when credentials issuance is disabled in API config"
|
||||
@@ -473,13 +494,12 @@ def award_course_certificate(self, username, course_run_key):
|
||||
f"{course_key} with a visible date of {visible_date}"
|
||||
)
|
||||
|
||||
# If the certificate has an associated CertificateDateOverride, send
|
||||
# it along
|
||||
# If the certificate has an associated CertificateDateOverride, send it along
|
||||
try:
|
||||
date_override = certificate.date_override.date
|
||||
LOGGER.info(
|
||||
"Task award_course_certificate will award certificate for "
|
||||
f"course {course_key} with a date override of {date_override}"
|
||||
"Task award_course_certificate will award certificate for course {course_key} with a date override "
|
||||
f"of {date_override}"
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
date_override = None
|
||||
@@ -706,64 +726,121 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
|
||||
@set_code_owner_attribute
|
||||
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.
|
||||
This task is designed to be called whenever a course-run's `certificate_available_date` is updated.
|
||||
|
||||
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.
|
||||
When executed, this task will first get a list of all learners within the course-run that have earned a certificate.
|
||||
Next, we will enqueue an additional `award_course_certificate` task for each learner in this list. These subtasks
|
||||
will be responsible for updating the `visible_date` attribute on each certificate the Credentials IDA knows about.
|
||||
|
||||
If this function is moved, make sure to update it's entry in
|
||||
EXPLICIT_QUEUES in the settings files so it runs in the correct queue.
|
||||
If this function is moved, make sure to update it's entry in EXPLICIT_QUEUES in the settings files so it runs in the
|
||||
correct queue.
|
||||
|
||||
Arguments:
|
||||
course_key (str): The course identifier
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
course_key(str): The course identifier
|
||||
"""
|
||||
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 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:
|
||||
error_msg = (
|
||||
"Task update_certificate_visible_date_on_course_update cannot be executed when credentials issuance is "
|
||||
"disabled in API config"
|
||||
"Cannot execute the `update_certificate_visible_date_on_course_update` task. Issuing user credentials "
|
||||
"through the Credentials IDA is disabled."
|
||||
)
|
||||
LOGGER.info(error_msg)
|
||||
LOGGER.warning(error_msg)
|
||||
exception = MaxRetriesExceededError(
|
||||
f"Failed to update certificate availability date for course {course_key}. Reason: {error_msg}"
|
||||
f"Failed to update the `visible_date` attribute for certificates in course {course_key}. Reason: "
|
||||
f"{error_msg}"
|
||||
)
|
||||
raise self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
# Retrieve a list of all usernames of learners who have a certificate record in this course-run. The
|
||||
# Credentials IDA REST API still requires a username as the main identifier for the learner.
|
||||
users_with_certificates_in_course = (
|
||||
GeneratedCertificate
|
||||
.eligible_available_certificates
|
||||
.filter(course_id=course_key)
|
||||
.values_list('user__username', flat=True)
|
||||
)
|
||||
|
||||
LOGGER.info(
|
||||
f"Resending course certificates for learners in course {course_key} to the Credentials service. Queueing "
|
||||
f"{len(users_with_certificates_in_course)} `award_course_certificate` tasks."
|
||||
)
|
||||
for user in users_with_certificates_in_course:
|
||||
award_course_certificate.delay(user, str(course_key))
|
||||
|
||||
|
||||
@shared_task(bind=True, ignore_result=True)
|
||||
@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.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
course_key(str): The course 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:
|
||||
error_msg = (
|
||||
"Cannot execute the `update_certificate_visible_date_on_course_update` task. Issuing user credentials "
|
||||
"through the Credentials IDA is disabled."
|
||||
)
|
||||
LOGGER.warning(error_msg)
|
||||
exception = MaxRetriesExceededError(
|
||||
"Failed to update the `certificate_available_date` in the Credentials service for course-run "
|
||||
f"{course_key}. Reason: {error_msg}"
|
||||
)
|
||||
raise self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
# Update the CourseCertificate configuration in Credentials with the new
|
||||
# certificate_available_date if:
|
||||
# - The course is not self paced, AND
|
||||
# - The certificates_display_behavior is "end_with_date"
|
||||
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)
|
||||
)
|
||||
|
||||
# This code will update the visible_date in Credentials; we have moved away
|
||||
# from relying on visible_date in favor of the above, but this still runs
|
||||
# and visible_date is still updated
|
||||
users_with_certificates_in_course = GeneratedCertificate.eligible_available_certificates.filter(
|
||||
course_id=course_key
|
||||
).values_list('user__username', flat=True)
|
||||
|
||||
LOGGER.info(
|
||||
"Task update_certificate_visible_date_on_course_update resending course certificates "
|
||||
f"for {len(users_with_certificates_in_course)} users in course {course_key}."
|
||||
)
|
||||
for user in users_with_certificates_in_course:
|
||||
award_course_certificate.delay(user, str(course_key))
|
||||
# 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:
|
||||
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."
|
||||
)
|
||||
|
||||
@@ -235,6 +235,7 @@ class CertRevokedReceiverTest(TestCase):
|
||||
|
||||
@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.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled',
|
||||
new_callable=mock.PropertyMock,
|
||||
@@ -256,35 +257,39 @@ class CourseCertAvailableDateChangedReceiverTest(TestCase):
|
||||
'available_date': datetime.datetime.now()
|
||||
}
|
||||
|
||||
def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument
|
||||
def test_signal_received(
|
||||
self,
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
def test_programs_disabled(self, mock_is_learner_issuance_enabled, mock_task):
|
||||
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 nothing when the credentials API configuration is not enabled.
|
||||
"""
|
||||
handle_course_cert_date_change(**self.signal_kwargs)
|
||||
assert mock_is_learner_issuance_enabled.call_count == 1
|
||||
assert mock_task.call_count == 0
|
||||
assert mock_visible_date_task.call_count == 0
|
||||
assert mock_cad_task.call_count == 0
|
||||
|
||||
def test_programs_enabled(self, mock_is_learner_issuance_enabled, mock_task):
|
||||
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 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)
|
||||
|
||||
assert mock_is_learner_issuance_enabled.call_count == 1
|
||||
assert mock_task.call_count == 1
|
||||
assert mock_visible_date_task.call_count == 1
|
||||
assert mock_cad_task.call_count == 1
|
||||
|
||||
@@ -18,7 +18,8 @@ from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
from edx_rest_api_client.auth import SuppliedJwtAuth
|
||||
from requests.exceptions import HTTPError
|
||||
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from testfixtures import LogCapture
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
@@ -953,11 +954,9 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
|
||||
@skip_unless_lms
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
|
||||
@mock.patch(TASKS_MODULE + '.get_credentials_api_client')
|
||||
class UpdateCredentialsCourseCertificateConfigurationAvailableDateTestCase(TestCase):
|
||||
"""
|
||||
Tests for the update_credentials_course_certificate_configuration_available_date
|
||||
function
|
||||
Tests for the update_credentials_course_certificate_configuration_available_date function
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -970,8 +969,7 @@ class UpdateCredentialsCourseCertificateConfigurationAvailableDateTestCase(TestC
|
||||
self.course_id = self.course.id
|
||||
self.credentials_worker = UserFactory(username='test-service-username')
|
||||
|
||||
# pylint: disable=W0613
|
||||
def test_update_course_cert_available_date(self, mock_client):
|
||||
def test_update_course_cert_available_date(self):
|
||||
with mock.patch(TASKS_MODULE + '.post_course_certificate_configuration') as update_posted:
|
||||
tasks.update_credentials_course_certificate_configuration_available_date(
|
||||
self.course_id,
|
||||
@@ -979,8 +977,7 @@ class UpdateCredentialsCourseCertificateConfigurationAvailableDateTestCase(TestC
|
||||
)
|
||||
update_posted.assert_called_once()
|
||||
|
||||
# pylint: disable=W0613
|
||||
def test_course_with_two_paid_modes(self, mock_client):
|
||||
def test_course_with_two_paid_modes(self):
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug='professional')
|
||||
with mock.patch(TASKS_MODULE + '.post_course_certificate_configuration') as update_posted:
|
||||
tasks.update_credentials_course_certificate_configuration_available_date(
|
||||
@@ -995,8 +992,8 @@ class PostCourseCertificateConfigurationTestCase(TestCase):
|
||||
"""
|
||||
Test the post_course_certificate_configuration function
|
||||
"""
|
||||
|
||||
def setUp(self): # lint-amnesty, pylint: disable=super-method-not-called
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.certificate = {
|
||||
'mode': 'verified',
|
||||
'course_id': 'testCourse',
|
||||
@@ -1029,3 +1026,173 @@ class PostCourseCertificateConfigurationTestCase(TestCase):
|
||||
}
|
||||
last_request_body = httpretty.last_request().body.decode('utf-8')
|
||||
assert json.loads(last_request_body) == expected_body
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class UpdateCertificateVisibleDatesOnCourseUpdateTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the `update_certificate_visible_date_on_course_update` task.
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.credentials_api_config = self.create_credentials_config(enabled=False)
|
||||
# setup course
|
||||
self.course = CourseOverviewFactory.create()
|
||||
# setup users
|
||||
self.student1 = UserFactory.create(username='test-student1')
|
||||
self.student2 = UserFactory.create(username='test-student2')
|
||||
self.student3 = UserFactory.create(username='test-student3')
|
||||
# award certificates to users in course we created
|
||||
self.certificate_student1 = GeneratedCertificateFactory.create(
|
||||
user=self.student1,
|
||||
mode='verified',
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
)
|
||||
self.certificate_student2 = GeneratedCertificateFactory.create(
|
||||
user=self.student2,
|
||||
mode='verified',
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
)
|
||||
self.certificate_student3 = GeneratedCertificateFactory.create(
|
||||
user=self.student3,
|
||||
mode='verified',
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.credentials_api_config = self.create_credentials_config(enabled=False)
|
||||
|
||||
def test_update_visible_dates_but_credentials_config_disabled(self):
|
||||
"""
|
||||
This test verifies the behavior of the `update_certificate_visible_date_on_course_update` task when the
|
||||
CredentialsApiConfig is disabled.
|
||||
|
||||
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.
|
||||
"""
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.update_certificate_visible_date_on_course_update(self.course.id) # pylint: disable=no-value-for-parameter
|
||||
|
||||
def test_update_visible_dates(self):
|
||||
"""
|
||||
Happy path test.
|
||||
|
||||
This test verifies the behavior of the `update_certificate_visible_date_on_course_update` task. This test
|
||||
verifies attempts by the system to queue a number of `award_course_certificate` tasks to ensure the
|
||||
`visible_date` attribute is updated on all eligible course certificates.
|
||||
"""
|
||||
# enable the CredentialsApiConfig to issue certificates using the Credentials service
|
||||
self.credentials_api_config.enabled = True
|
||||
self.credentials_api_config.enable_learner_issuance = True
|
||||
|
||||
with mock.patch(f"{TASKS_MODULE}.award_course_certificate.delay") as award_course_cert:
|
||||
tasks.update_certificate_visible_date_on_course_update(self.course.id) # pylint: disable=no-value-for-parameter
|
||||
|
||||
assert award_course_cert.call_count == 3
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the `update_certificate_available_date_on_course_update` task.
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
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):
|
||||
"""
|
||||
This test verifies the behavior of the `UpdateCertificateAvailableDateOnCourseUpdateTestCase` task when the
|
||||
CredentialsApiConfig is disabled.
|
||||
|
||||
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.
|
||||
"""
|
||||
course = CourseOverviewFactory.create()
|
||||
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.update_certificate_available_date_on_course_update(course.id) # pylint: disable=no-value-for-parameter
|
||||
|
||||
def test_update_certificate_available_date_with_self_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 removing 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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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), str(available_date))
|
||||
|
||||
def test_update_certificate_available_date_with_expect_no_update(self):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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.EARLY_NO_INFO
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
with LogCapture(level=logging.WARNING) as log_capture:
|
||||
tasks.update_certificate_available_date_on_course_update(course.id) # pylint: disable=no-value-for-parameter
|
||||
|
||||
assert len(log_capture.records) == 1
|
||||
assert log_capture.records[0].getMessage() == expected_message
|
||||
|
||||
Reference in New Issue
Block a user