From 84c953948be4e643bd02548c4f642dd232eb54b5 Mon Sep 17 00:00:00 2001 From: Thomas Tracy Date: Wed, 12 May 2021 15:42:08 -0400 Subject: [PATCH] MB-1167: calling course certificates api from LMS (#27552) * [feat] calling course certificates api from LMS Now that CourseCertificates in credentials have a field for the available_date, we need to make sure we are always updating that field when it changes in studio. This PR adds a call to a new Credentials API that will update the field each time the date change signal fires. --- openedx/core/djangoapps/programs/tasks.py | 64 ++++++++++++++- .../djangoapps/programs/tests/test_tasks.py | 82 ++++++++++++++++++- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/programs/tasks.py b/openedx/core/djangoapps/programs/tasks.py index 49ec1978e4..2882f41250 100644 --- a/openedx/core/djangoapps/programs/tasks.py +++ b/openedx/core/djangoapps/programs/tasks.py @@ -277,6 +277,19 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable LOGGER.info(f"Successfully completed the task award_program_certificates for username {username}") +def post_course_certificate_configuration(client, cert_config, certificate_available_date=None): + """ + POST a configuration for a course certificate and the date the certificate + will be available + """ + client.course_certificates.post({ + "course_id": cert_config['course_id'], + "certificate_type": cert_config['mode'], + "certificate_available_date": certificate_available_date, + "is_active": True + }) + + def post_course_certificate(client, username, certificate, visible_date): """ POST a certificate that has been updated to Credentials @@ -298,6 +311,51 @@ def post_course_certificate(client, username, certificate, visible_date): }) +# pylint: disable=W0613 +@shared_task(bind=True, ignore_result=True) +@set_code_owner_attribute +def update_credentials_course_certificate_configuration_available_date( + self, + course_key, + certificate_available_date=None +): + """ + This task will update the course certificate configuration's available date. This is different from the + "visable_date" attribute. This date will always either be the available date that is set in studio for + a given course, or it will be None. + + Arguments: + course_run_key (str): The course run key to award the certificate for + certificate_available_date (str): A string representation of the datetime for when to make the certificate + available to the user. If not provided, it will be none. + """ + LOGGER.info( + f"Running task update_credentials_course_certificate_configuration_available_date for course {course_key}" + ) + course_key = str(course_key) + course_modes = CourseMode.objects.filter(course_id=course_key) + # There should only ever be one certificate relevant mode per course run + modes = [mode.slug for mode in course_modes if mode.slug in CourseMode.CERTIFICATE_RELEVANT_MODES] + if len(modes) != 1: + LOGGER.exception( + f'Either course {course_key} has no certificate mode or multiple modes. Task failed.' + ) + return + + credentials_client = get_credentials_api_client( + User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME), + ) + cert_config = { + 'course_id': course_key, + 'mode': modes[0], + } + post_course_certificate_configuration( + client=credentials_client, + cert_config=cert_config, + certificate_available_date=certificate_available_date + ) + + @shared_task(bind=True, ignore_result=True) @set_code_owner_attribute def award_course_certificate(self, username, course_run_key, certificate_available_date=None): @@ -639,7 +697,11 @@ def update_certificate_visible_date_on_course_update(self, course_key, certifica f"Failed to update certificate availability date for course {course_key}. Reason: {error_msg}" ) raise self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES) - + # Always update the course certificate with the new certificate available date + update_credentials_course_certificate_configuration_available_date.delay( + str(course_key), + certificate_available_date + ) users_with_certificates_in_course = GeneratedCertificate.eligible_available_certificates.filter( course_id=course_key ).values_list('user__username', flat=True) diff --git a/openedx/core/djangoapps/programs/tests/test_tasks.py b/openedx/core/djangoapps/programs/tests/test_tasks.py index d67c8066ff..0f43629ba9 100644 --- a/openedx/core/djangoapps/programs/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tests/test_tasks.py @@ -8,9 +8,9 @@ import logging from datetime import datetime, timedelta from unittest import mock -import pytest import ddt import httpretty +import pytest import pytz from celery.exceptions import MaxRetriesExceededError from django.conf import settings @@ -19,6 +19,8 @@ from edx_rest_api_client import exceptions from edx_rest_api_client.client import EdxRestApiClient from waffle.testutils import override_switch +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin from openedx.core.djangoapps.certificates.config import waffle @@ -28,7 +30,6 @@ from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFa from openedx.core.djangoapps.programs import tasks from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory from openedx.core.djangolib.testing.utils import skip_unless_lms -from common.djangoapps.student.tests.factories import UserFactory log = logging.getLogger(__name__) @@ -915,3 +916,80 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC assert mock_exception.called assert mock_get_api_client.call_count == (tasks.MAX_RETRIES + 1) assert not mock_revoke_program_certificate.called + + +@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 + """ + def setUp(self): + super().setUp() + self.course = CourseOverviewFactory.create( + certificate_available_date=datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + ) + CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') + CourseModeFactory.create(course_id=self.course.id, mode_slug='audit') + self.available_date = self.course.certificate_available_date + 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): + with mock.patch(TASKS_MODULE + '.post_course_certificate_configuration') as update_posted: + tasks.update_credentials_course_certificate_configuration_available_date( + self.course_id, + self.available_date + ) + update_posted.assert_called_once() + + # pylint: disable=W0613 + def test_course_with_two_paid_modes(self, mock_client): + 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( + self.course_id, + self.available_date + ) + update_posted.assert_not_called() + + +@skip_unless_lms +class PostCourseCertificateConfigurationTestCase(TestCase): + """ + Test the post_course_certificate_configuration function + """ + + def setUp(self): # lint-amnesty, pylint: disable=super-method-not-called + self.certificate = { + 'mode': 'verified', + 'course_id': 'testCourse', + } + + @httpretty.activate + def test_post_course_certificate_configuration(self): + """ + Ensure the correct API call gets made + """ + test_client = EdxRestApiClient('http://test-server', jwt='test-token') + + httpretty.register_uri( + httpretty.POST, + 'http://test-server/course_certificates/', + ) + + available_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + + tasks.post_course_certificate_configuration(test_client, self.certificate, available_date) + + expected_body = { + "course_id": 'testCourse', + "certificate_type": 'verified', + "certificate_available_date": available_date, + "is_active": True + } + last_request_body = httpretty.last_request().body.decode('utf-8') + assert json.loads(last_request_body) == expected_body