LEARNER-5050: Adds award course cert job to post to Credentials

This commit is contained in:
Jeff LaJoie
2018-06-08 15:01:42 -04:00
parent 3b5239dd27
commit b07374ed39
9 changed files with 401 additions and 60 deletions

View File

@@ -68,7 +68,7 @@ from badges.events.course_meta import completion_check, course_group_check
from course_modes.models import CourseMode
from lms.djangoapps.instructor_task.models import InstructorTask
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED
from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager
from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
@@ -340,8 +340,16 @@ class GeneratedCertificate(models.Model):
"""
After the base save() method finishes, fire the COURSE_CERT_AWARDED
signal iff we are saving a record of a learner passing the course.
As well as the COURSE_CERT_CHANGED for any save event.
"""
super(GeneratedCertificate, self).save(*args, **kwargs)
COURSE_CERT_CHANGED.send_robust(
sender=self.__class__,
user=self.user,
course_key=self.course_id,
mode=self.mode,
status=self.status,
)
if CertificateStatuses.is_passing_status(self.status):
COURSE_CERT_AWARDED.send_robust(
sender=self.__class__,

View File

@@ -67,11 +67,20 @@ class CredentialsApiConfig(ConfigurationModel):
@property
def internal_api_url(self):
"""
Internally-accessible API URL root.
Internally-accessible API URL root, looked up based on the current request.
"""
root = helpers.get_value('CREDENTIALS_INTERNAL_SERVICE_URL', settings.CREDENTIALS_INTERNAL_SERVICE_URL)
return urljoin(root, '/api/{}/'.format(API_VERSION))
@staticmethod
def get_internal_api_url_for_org(org):
"""
Internally-accessible API URL root, looked up by org rather than the current request.
"""
root = helpers.get_value_for_org(org, 'CREDENTIALS_INTERNAL_SERVICE_URL',
settings.CREDENTIALS_INTERNAL_SERVICE_URL)
return urljoin(root, '/api/{}/'.format(API_VERSION))
@property
def public_api_url(self):
"""

View File

@@ -24,6 +24,9 @@ class TestCredentialsApiConfig(CredentialsApiConfigMixin, TestCase):
"""Verify that URLs returned by the model are constructed correctly."""
credentials_config = self.create_credentials_config()
expected = '{root}/api/{version}/'.format(root=CREDENTIALS_INTERNAL_SERVICE_URL.strip('/'), version=API_VERSION)
self.assertEqual(credentials_config.get_internal_api_url_for_org('nope'), expected)
expected = '{root}/api/{version}/'.format(root=CREDENTIALS_INTERNAL_SERVICE_URL.strip('/'), version=API_VERSION)
self.assertEqual(credentials_config.internal_api_url, expected)

View File

@@ -22,13 +22,25 @@ def get_credentials_records_url(program_uuid=None):
return base_url
def get_credentials_api_client(user):
""" Returns an authenticated Credentials API client. """
def get_credentials_api_client(user, org=None):
"""
Returns an authenticated Credentials API client.
Arguments:
user (User): The user to authenticate as when requesting credentials.
org (str): Optional organization to look up the site config for, rather than the current request
"""
scopes = ['email', 'profile']
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
jwt = JwtBuilder(user).build_token(scopes, expires_in)
return EdxRestApiClient(CredentialsApiConfig.current().internal_api_url, jwt=jwt)
if org is None:
url = CredentialsApiConfig.current().internal_api_url # by current request
else:
url = CredentialsApiConfig.get_internal_api_url_for_org(org) # by org
return EdxRestApiClient(url, jwt=jwt)
def get_credentials(user, program_uuid=None, credential_type=None):

View File

@@ -5,7 +5,7 @@ import logging
from django.dispatch import receiver
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED
LOGGER = logging.getLogger(__name__)
@@ -52,3 +52,46 @@ def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs)
# import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates
award_program_certificates.delay(user.username)
@receiver(COURSE_CERT_CHANGED)
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
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"
Returns:
None
"""
# Import here instead of top of file since this module gets imported before
# the credentials app is loaded, resulting in a Django deprecation warning.
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
# Avoid scheduling new tasks if certification is disabled.
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
return
# schedule background task to process
LOGGER.debug(
'handling COURSE_CERT_CHANGED: username=%s, course_key=%s, mode=%s, status=%s',
user,
course_key,
mode,
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.v1.tasks import award_course_certificate
award_course_certificate.delay(user.username, course_key)

View File

@@ -6,15 +6,15 @@ from celery.utils.log import get_task_logger # pylint: disable=no-name-in-modul
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured
from edx_rest_api_client import exceptions
from edx_rest_api_client.client import EdxRestApiClient
from provider.oauth2.models import Client
from course_modes.models import CourseMode
from lms.djangoapps.certificates.models import GeneratedCertificate
from openedx.core.djangoapps.certificates.api import display_date_for_certificate
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.utils import get_credentials
from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_api_client
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.core.lib.token_utils import JwtBuilder
LOGGER = get_task_logger(__name__)
@@ -26,23 +26,8 @@ ROUTING_KEY = getattr(settings, 'CREDENTIALS_GENERATION_ROUTING_KEY', None)
# unwanted behavior: infinite retries.
MAX_RETRIES = 11
def get_api_client(api_config, user):
"""
Create and configure an API client for authenticated HTTP requests.
Args:
api_config: CredentialsApiConfig object
user: User object as whom to authenticate to the API
Returns:
EdxRestApiClient
"""
scopes = ['email', 'profile']
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
jwt = JwtBuilder(user).build_token(scopes, expires_in)
return EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
PROGRAM_CERTIFICATE = 'program'
COURSE_CERTIFICATE = 'course-run'
def get_completed_programs(site, student):
@@ -98,7 +83,10 @@ def award_program_certificate(client, username, program_uuid):
"""
client.credentials.post({
'username': username,
'credential': {'program_uuid': program_uuid},
'credential': {
'type': PROGRAM_CERTIFICATE,
'program_uuid': program_uuid
},
'attributes': []
})
@@ -172,9 +160,8 @@ def award_program_certificates(self, username):
new_program_uuids = sorted(list(set(program_uuids) - set(existing_program_uuids)))
if new_program_uuids:
try:
credentials_client = get_api_client(
CredentialsApiConfig.current(),
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME) # pylint: disable=no-member
credentials_client = get_credentials_api_client(
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
)
except Exception as exc: # pylint: disable=broad-except
LOGGER.exception('Failed to create a credentials API client to award program certificates')
@@ -205,3 +192,91 @@ def award_program_certificates(self, username):
LOGGER.info('User %s is not eligible for any new program certificates', username)
LOGGER.info('Successfully completed the task award_program_certificates for username %s', username)
def post_course_certificate(client, username, certificate, visible_date):
"""
POST a certificate that has been updated to Credentials
"""
client.credentials.post({
'username': username,
'status': 'awarded' if certificate.is_valid() else 'revoked', # Only need the two options at this time
'credential': {
'course_run_key': str(certificate.course_id),
'mode': certificate.mode,
'type': COURSE_CERTIFICATE,
},
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime('%Y-%m-%dT%H:%M:%SZ')
}
]
})
@task(bind=True, ignore_result=True, routing_key=ROUTING_KEY)
def award_course_certificate(self, username, course_run_key):
"""
This task is designed to be called whenever a student GeneratedCertificate is updated.
It can be called independently for a username and a course_run, but is invoked on each GeneratedCertificate.save.
"""
LOGGER.info('Running task award_course_certificate for username %s', username)
countdown = 2 ** self.request.retries
# If the credentials config model is disabled for this
# feature, it may indicate a condition where processing of such tasks
# has been temporarily disabled. Since this is a recoverable situation,
# mark this task for retry instead of failing it altogether.
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
LOGGER.warning(
'Task award_course_certificate cannot be executed when credentials issuance is disabled in API config',
)
raise self.retry(countdown=countdown, max_retries=MAX_RETRIES)
try:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
LOGGER.exception('Task award_course_certificate was called with invalid username %s', username)
# Don't retry for this case - just conclude the task.
return
# Get the cert for the course key and username if it's both passing and available in professional/verified
try:
certificate = GeneratedCertificate.eligible_certificates.get(
user=user.id,
course_id=course_run_key
)
except GeneratedCertificate.DoesNotExist:
LOGGER.exception(
'Task award_course_certificate was called without Certificate found for %s to user %s',
course_run_key,
username
)
return
if certificate.mode in CourseMode.VERIFIED_MODES + CourseMode.CREDIT_MODES:
try:
course_overview = CourseOverview.get_from_id(course_run_key)
except (CourseOverview.DoesNotExist, IOError):
LOGGER.exception(
'Task award_course_certificate was called without course overview data for course %s',
course_run_key
)
return
credentials_client = get_credentials_api_client(User.objects.get(
username=settings.CREDENTIALS_SERVICE_USERNAME),
org=course_run_key.org,
)
# FIXME This may result in visible dates that do not update alongside the Course Overview if that changes
# This is a known limitation of this implementation and was chosen to reduce the amount of replication,
# endpoints, celery tasks, and jenkins jobs that needed to be written for this functionality
visible_date = display_date_for_certificate(course_overview, certificate)
post_course_certificate(credentials_client, username, certificate, visible_date)
LOGGER.info('Awarded certificate for course %s to user %s', course_run_key, username)
except Exception as exc:
LOGGER.exception('Failed to determine course certificates to be awarded for user %s', username)
raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES)

View File

@@ -2,7 +2,7 @@
Tests for programs celery tasks.
"""
import json
from datetime import datetime
import ddt
import httpretty
import mock
@@ -13,40 +13,20 @@ from edx_oauth2_provider.tests.factories import ClientFactory
from edx_rest_api_client import exceptions
from edx_rest_api_client.client import EdxRestApiClient
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.programs.tasks.v1 import tasks
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory
CREDENTIALS_INTERNAL_SERVICE_URL = 'https://credentials.example.com'
TASKS_MODULE = 'openedx.core.djangoapps.programs.tasks.v1.tasks'
@skip_unless_lms
class GetApiClientTestCase(CredentialsApiConfigMixin, TestCase):
"""
Test the get_api_client function
"""
@override_settings(CREDENTIALS_INTERNAL_SERVICE_URL=CREDENTIALS_INTERNAL_SERVICE_URL)
@mock.patch(TASKS_MODULE + '.JwtBuilder.build_token')
def test_get_api_client(self, mock_build_token):
"""
Ensure the function is making the right API calls based on inputs
"""
student = UserFactory()
ClientFactory.create(name='credentials')
api_config = self.create_credentials_config()
mock_build_token.return_value = 'test-token'
api_client = tasks.get_api_client(api_config, student)
expected = CREDENTIALS_INTERNAL_SERVICE_URL.strip('/') + '/api/v2/'
self.assertEqual(api_client._store['base_url'], expected) # pylint: disable=protected-access
self.assertEqual(api_client._store['session'].auth.token, 'test-token') # pylint: disable=protected-access
@skip_unless_lms
class GetAwardedCertificateProgramsTestCase(TestCase):
"""
@@ -110,7 +90,10 @@ class AwardProgramCertificateTestCase(TestCase):
expected_body = {
'username': test_username,
'credential': {'program_uuid': 123},
'credential': {
'program_uuid': 123,
'type': tasks.PROGRAM_CERTIFICATE,
},
'attributes': []
}
self.assertEqual(json.loads(httpretty.last_request().body), expected_body)
@@ -324,3 +307,147 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
tasks.award_program_certificates.delay(self.student.username).get()
self.assertEqual(mock_award_program_certificate.call_count, 2)
@skip_unless_lms
class PostCourseCertificateTestCase(TestCase):
"""
Test the award_program_certificate function
"""
def setUp(self):
self.student = UserFactory.create(username='test-student')
self.course = CourseOverviewFactory.create(
self_paced=True # Any option to allow the certificate to be viewable for the course
)
self.certificate = GeneratedCertificateFactory(
user=self.student,
mode='verified',
course_id=self.course.id,
status='downloadable'
)
@httpretty.activate
def test_post_course_certificate(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/credentials/',
)
visible_date = datetime.now()
tasks.post_course_certificate(test_client, self.student.username, self.certificate, visible_date)
expected_body = {
'username': self.student.username,
'status': 'awarded',
'credential': {
'course_run_key': str(self.certificate.course_id),
'mode': self.certificate.mode,
'type': tasks.COURSE_CERTIFICATE,
},
'attributes': [{
'name': 'visible_date',
'value': visible_date.strftime('%Y-%m-%dT%H:%M:%SZ') # text representation of date
}]
}
self.assertEqual(json.loads(httpretty.last_request().body), expected_body)
@skip_unless_lms
@mock.patch(TASKS_MODULE + '.post_course_certificate')
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
"""
Test the award_course_certificate celery task
"""
def setUp(self):
super(AwardCourseCertificatesTestCase, self).setUp()
self.course = CourseOverviewFactory.create(
self_paced=True # Any option to allow the certificate to be viewable for the course
)
self.student = UserFactory.create(username='test-student')
# Instantiate the Certificate first so that the config doesn't execute issuance
self.certificate = GeneratedCertificateFactory.create(
user=self.student,
mode='verified',
course_id=self.course.id,
status='downloadable'
)
self.create_credentials_config()
self.site = SiteFactory()
ClientFactory.create(name='credentials')
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
def test_award_course_certificates(self, mock_post_course_certificate):
"""
Tests the API POST method is called with appropriate params when configured properly
"""
tasks.award_course_certificate.delay(self.student.username, self.course.id).get()
call_args, _ = mock_post_course_certificate.call_args
self.assertEqual(call_args[1], self.student.username)
self.assertEqual(call_args[2], self.certificate)
def test_award_course_cert_not_called_if_disabled(self, mock_post_course_certificate):
"""
Test that the post method is never called if the config is disabled
"""
self.create_credentials_config(enabled=False)
with mock.patch(TASKS_MODULE + '.LOGGER.warning') as mock_warning:
with self.assertRaises(MaxRetriesExceededError):
tasks.award_course_certificate.delay(self.student.username, self.course.id).get()
self.assertTrue(mock_warning.called)
self.assertFalse(mock_post_course_certificate.called)
def test_award_course_cert_not_called_if_user_not_found(self, mock_post_course_certificate):
"""
Test that the post method is never called if the user isn't found by username
"""
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
# Use a random username here since this user won't be found in the DB
tasks.award_course_certificate.delay('random_username', self.course.id).get()
self.assertTrue(mock_exception.called)
self.assertFalse(mock_post_course_certificate.called)
def test_award_course_cert_not_called_if_certificate_not_found(self, mock_post_course_certificate):
"""
Test that the post method is never called if the certificate doesn't exist for the user and course
"""
self.certificate.delete()
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
tasks.award_course_certificate.delay(self.student.username, self.course.id).get()
self.assertTrue(mock_exception.called)
self.assertFalse(mock_post_course_certificate.called)
def test_award_course_cert_not_called_if_course_overview_not_found(self, mock_post_course_certificate):
"""
Test that the post method is never called if the CourseOverview isn't found
"""
self.course.delete()
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
# Use the certificate course id here since the course will be deleted
tasks.award_course_certificate.delay(self.student.username, self.certificate.course_id).get()
self.assertTrue(mock_exception.called)
self.assertFalse(mock_post_course_certificate.called)
def test_award_course_cert_not_called_if_certificated_not_verified_mode(self, mock_post_course_certificate):
"""
Test that the post method is never called if the GeneratedCertificate is an 'audit' cert
"""
# Temporarily disable the config so the signal isn't handled from .save
self.create_credentials_config(enabled=False)
self.certificate.mode = 'audit'
self.certificate.save()
self.create_credentials_config()
tasks.award_course_certificate.delay(self.student.username, self.certificate.course_id).get()
self.assertFalse(mock_post_course_certificate.called)

View File

@@ -8,11 +8,12 @@ import mock
from student.tests.factories import UserFactory
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded, handle_course_cert_changed
from openedx.core.djangolib.testing.utils import skip_unless_lms
TEST_USERNAME = 'test-user'
TEST_COURSE_KEY = 'test-course'
@attr(shard=2)
@@ -37,7 +38,7 @@ class CertAwardedReceiverTest(TestCase):
return dict(
sender=self.__class__,
user=UserFactory.create(username=TEST_USERNAME),
course_key='test-course',
course_key=TEST_COURSE_KEY,
mode='test-mode',
status='test-status',
)
@@ -75,3 +76,65 @@ class CertAwardedReceiverTest(TestCase):
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
self.assertEqual(mock_task.call_count, 1)
self.assertEqual(mock_task.call_args[0], (TEST_USERNAME,))
@attr(shard=2)
# The credentials app isn't installed for the CMS.
@skip_unless_lms
@mock.patch('openedx.core.djangoapps.programs.tasks.v1.tasks.award_course_certificate.delay')
@mock.patch(
'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled',
new_callable=mock.PropertyMock,
return_value=False,
)
class CertChangedReceiverTest(TestCase):
"""
Tests for the `handle_course_cert_changed` signal handler function.
"""
@property
def signal_kwargs(self):
"""
DRY helper.
"""
return dict(
sender=self.__class__,
user=UserFactory.create(username=TEST_USERNAME),
course_key='test-course',
mode='test-mode',
status='test-status',
)
def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument
"""
Ensures the receiver function is invoked when COURSE_CERT_CHANGED 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_CHANGED.send(**self.signal_kwargs)
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
def test_credentials_disabled(self, mock_is_learner_issuance_enabled, mock_task):
"""
Ensures that the receiver function does nothing when the credentials API
configuration is not enabled.
"""
handle_course_cert_changed(**self.signal_kwargs)
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
self.assertEqual(mock_task.call_count, 0)
def test_credentials_enabled(self, mock_is_learner_issuance_enabled, mock_task):
"""
Ensures that the receiver function invokes the expected celery task
when the credentials API configuration is enabled.
"""
mock_is_learner_issuance_enabled.return_value = True
handle_course_cert_changed(**self.signal_kwargs)
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
self.assertEqual(mock_task.call_count, 1)
self.assertEqual(mock_task.call_args[0], (TEST_USERNAME, TEST_COURSE_KEY))

View File

@@ -10,6 +10,7 @@ COURSE_GRADE_CHANGED = Signal(providing_args=["user", "course_grade", "course_ke
# Signal that fires when a user is awarded a certificate in a course (in the certificates django app)
# TODO: runtime coupling between apps will be reduced if this event is changed to carry a username
# rather than a User object; however, this will require changes to the milestones and badges APIs
COURSE_CERT_CHANGED = Signal(providing_args=["user", "course_key", "mode", "status"])
COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "status"])
# Signal that indicates that a user has passed a course.