Send program cert visible_date
When sending a program cert to Credentials, also send along a calculated visible_date along with it. LEARNER-6262
This commit is contained in:
committed by
Michael Terry
parent
05debb8d54
commit
755ebc8c7f
@@ -45,6 +45,7 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
|
||||
querystring = {
|
||||
'username': self.user.username,
|
||||
'status': 'awarded',
|
||||
'only_visible': 'True',
|
||||
}
|
||||
cache_key = '{}.{}'.format(self.credentials_config.CACHE_KEY, self.user.username)
|
||||
self.assertEqual(kwargs['querystring'], querystring)
|
||||
@@ -66,6 +67,7 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
|
||||
querystring = {
|
||||
'username': self.user.username,
|
||||
'status': 'awarded',
|
||||
'only_visible': 'True',
|
||||
'program_uuid': program_uuid,
|
||||
}
|
||||
cache_key = '{}.{}.{}'.format(self.credentials_config.CACHE_KEY, self.user.username, program_uuid)
|
||||
@@ -84,6 +86,7 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
|
||||
querystring = {
|
||||
'username': self.user.username,
|
||||
'status': 'awarded',
|
||||
'only_visible': 'True',
|
||||
'type': 'program',
|
||||
}
|
||||
self.assertEqual(kwargs['querystring'], querystring)
|
||||
|
||||
@@ -64,7 +64,7 @@ def get_credentials(user, program_uuid=None, credential_type=None):
|
||||
"""
|
||||
credential_configuration = CredentialsApiConfig.current()
|
||||
|
||||
querystring = {'username': user.username, 'status': 'awarded'}
|
||||
querystring = {'username': user.username, 'status': 'awarded', 'only_visible': 'True'}
|
||||
|
||||
if program_uuid:
|
||||
querystring['program_uuid'] = program_uuid
|
||||
|
||||
@@ -29,6 +29,7 @@ MAX_RETRIES = 11
|
||||
|
||||
PROGRAM_CERTIFICATE = 'program'
|
||||
COURSE_CERTIFICATE = 'course-run'
|
||||
VISIBLE_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
|
||||
|
||||
|
||||
def get_completed_programs(site, student):
|
||||
@@ -40,11 +41,11 @@ def get_completed_programs(site, student):
|
||||
student (User): Representing the student whose completed programs to check for.
|
||||
|
||||
Returns:
|
||||
list of program UUIDs
|
||||
dict of {program_UUIDs: visible_dates}
|
||||
|
||||
"""
|
||||
meter = ProgramProgressMeter(site, student)
|
||||
return meter.completed_programs
|
||||
return meter.completed_programs_with_available_dates
|
||||
|
||||
|
||||
def get_certified_programs(student):
|
||||
@@ -66,7 +67,7 @@ def get_certified_programs(student):
|
||||
return certified_programs
|
||||
|
||||
|
||||
def award_program_certificate(client, username, program_uuid):
|
||||
def award_program_certificate(client, username, program_uuid, visible_date):
|
||||
"""
|
||||
Issue a new certificate of completion to the given student for the given program.
|
||||
|
||||
@@ -77,6 +78,8 @@ def award_program_certificate(client, username, program_uuid):
|
||||
The username of the student
|
||||
program_uuid:
|
||||
uuid of the completed program
|
||||
visible_date:
|
||||
when the program credential should be visible to user
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -88,7 +91,12 @@ def award_program_certificate(client, username, program_uuid):
|
||||
'type': PROGRAM_CERTIFICATE,
|
||||
'program_uuid': program_uuid
|
||||
},
|
||||
'attributes': []
|
||||
'attributes': [
|
||||
{
|
||||
'name': 'visible_date',
|
||||
'value': visible_date.strftime(VISIBLE_DATE_FORMAT)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -136,10 +144,10 @@ def award_program_certificates(self, username):
|
||||
LOGGER.exception('Task award_program_certificates was called with invalid username %s', username)
|
||||
# Don't retry for this case - just conclude the task.
|
||||
return
|
||||
program_uuids = []
|
||||
completed_programs = {}
|
||||
for site in Site.objects.all():
|
||||
program_uuids.extend(get_completed_programs(site, student))
|
||||
if not program_uuids:
|
||||
completed_programs.update(get_completed_programs(site, student))
|
||||
if not completed_programs:
|
||||
# No reason to continue beyond this point unless/until this
|
||||
# task gets updated to support revocation of program certs.
|
||||
LOGGER.info('Task award_program_certificates was called for user %s with no completed programs', username)
|
||||
@@ -158,7 +166,7 @@ def award_program_certificates(self, username):
|
||||
# This logic is important, because we will retry the whole task if awarding any particular program cert fails.
|
||||
#
|
||||
# N.B. the list is sorted to facilitate deterministic ordering, e.g. for tests.
|
||||
new_program_uuids = sorted(list(set(program_uuids) - set(existing_program_uuids)))
|
||||
new_program_uuids = sorted(list(set(completed_programs.keys()) - set(existing_program_uuids)))
|
||||
if new_program_uuids:
|
||||
try:
|
||||
credentials_client = get_credentials_api_client(
|
||||
@@ -171,8 +179,9 @@ def award_program_certificates(self, username):
|
||||
|
||||
failed_program_certificate_award_attempts = []
|
||||
for program_uuid in new_program_uuids:
|
||||
visible_date = completed_programs[program_uuid]
|
||||
try:
|
||||
award_program_certificate(credentials_client, username, program_uuid)
|
||||
award_program_certificate(credentials_client, username, program_uuid, visible_date)
|
||||
LOGGER.info('Awarded certificate for program %s to user %s', program_uuid, username)
|
||||
except exceptions.HttpNotFoundError:
|
||||
LOGGER.exception(
|
||||
@@ -237,7 +246,7 @@ def post_course_certificate(client, username, certificate, visible_date):
|
||||
'attributes': [
|
||||
{
|
||||
'name': 'visible_date',
|
||||
'value': visible_date.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
'value': visible_date.strftime(VISIBLE_DATE_FORMAT)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ class AwardProgramCertificateTestCase(TestCase):
|
||||
'http://test-server/credentials/',
|
||||
)
|
||||
|
||||
tasks.award_program_certificate(test_client, test_username, 123)
|
||||
tasks.award_program_certificate(test_client, test_username, 123, datetime(2010, 5, 30))
|
||||
|
||||
expected_body = {
|
||||
'username': test_username,
|
||||
@@ -97,7 +97,12 @@ class AwardProgramCertificateTestCase(TestCase):
|
||||
'program_uuid': 123,
|
||||
'type': tasks.PROGRAM_CERTIFICATE,
|
||||
},
|
||||
'attributes': []
|
||||
'attributes': [
|
||||
{
|
||||
'name': 'visible_date',
|
||||
'value': '2010-05-30T00:00:00Z',
|
||||
}
|
||||
]
|
||||
}
|
||||
self.assertEqual(json.loads(httpretty.last_request().body), expected_body)
|
||||
|
||||
@@ -154,7 +159,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
Checks that the Credentials API is used to award certificates for
|
||||
the proper programs.
|
||||
"""
|
||||
mock_get_completed_programs.return_value = [1, 2, 3]
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2, 3: 3}
|
||||
mock_get_certified_programs.return_value = already_awarded_program_uuids
|
||||
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
@@ -162,6 +167,9 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
actual_program_uuids = [call[0][2] for call in mock_award_program_certificate.call_args_list]
|
||||
self.assertEqual(actual_program_uuids, expected_awarded_program_uuids)
|
||||
|
||||
actual_visible_dates = [call[0][3] for call in mock_award_program_certificate.call_args_list]
|
||||
self.assertEqual(actual_visible_dates, expected_awarded_program_uuids) # program uuids are same as mock dates
|
||||
|
||||
@ddt.data(
|
||||
('credentials', 'enable_learner_issuance'),
|
||||
)
|
||||
@@ -205,7 +213,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
Checks that the task will be aborted without further action if there
|
||||
are no programs for which to award a certificate.
|
||||
"""
|
||||
mock_get_completed_programs.return_value = []
|
||||
mock_get_completed_programs.return_value = {}
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
self.assertTrue(mock_get_completed_programs.called)
|
||||
self.assertFalse(mock_get_certified_programs.called)
|
||||
@@ -244,7 +252,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
successfully awarded certs are logged as INFO and warning is logged
|
||||
for failed requests if there are retries available.
|
||||
"""
|
||||
mock_get_completed_programs.return_value = [1, 2]
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_get_certified_programs.side_effect = [[], [2]]
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
|
||||
@@ -288,7 +296,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
transient API errors) will cause the task to be failed and queued for
|
||||
retry.
|
||||
"""
|
||||
mock_get_completed_programs.return_value = [1, 2]
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_get_certified_programs.return_value = [1]
|
||||
mock_get_certified_programs.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
@@ -306,7 +314,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
"""
|
||||
exception = exceptions.HttpClientError()
|
||||
exception.response = mock.Mock(status_code=429)
|
||||
mock_get_completed_programs.return_value = [1, 2]
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
@@ -326,7 +334,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
"""
|
||||
exception = exceptions.HttpNotFoundError()
|
||||
exception.response = mock.Mock(status_code=404)
|
||||
mock_get_completed_programs.return_value = [1, 2]
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
@@ -346,7 +354,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
"""
|
||||
exception = exceptions.HttpClientError()
|
||||
exception.response = mock.Mock(status_code=418)
|
||||
mock_get_completed_programs.return_value = [1, 2]
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from entitlements.tests.factories import CourseEntitlementFactory
|
||||
from waffle.testutils import override_switch
|
||||
|
||||
from lms.djangoapps.certificates.api import MODES
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
|
||||
from lms.djangoapps.commerce.tests.test_utils import update_commerce_config
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from lms.djangoapps.grades.tests.utils import mock_passing_grade
|
||||
@@ -77,6 +78,11 @@ class TestProgramProgressMeter(TestCase):
|
||||
for course_uuid in course_uuids:
|
||||
CourseEntitlementFactory(user=self.user, course_uuid=course_uuid)
|
||||
|
||||
def _create_certificates(self, *course_keys, **kwargs):
|
||||
""" Variadic helper used to create course certificates. """
|
||||
kwargs.setdefault('status', 'downloadable')
|
||||
return [GeneratedCertificateFactory(user=self.user, course_id=key, **kwargs) for key in course_keys]
|
||||
|
||||
def _assert_progress(self, meter, *progresses):
|
||||
"""Variadic helper used to verify progress calculations."""
|
||||
self.assertEqual(meter.progress(), list(progresses))
|
||||
@@ -86,23 +92,6 @@ class TestProgramProgressMeter(TestCase):
|
||||
for program in programs:
|
||||
program['detail_url'] = reverse('program_details_view', kwargs={'program_uuid': program['uuid']})
|
||||
|
||||
def _make_certificate_result(self, **kwargs):
|
||||
"""Helper to create dummy results from the certificates API."""
|
||||
result = {
|
||||
'username': 'dummy-username',
|
||||
'course_key': 'dummy-course',
|
||||
'type': 'dummy-type',
|
||||
'status': 'dummy-status',
|
||||
'download_url': 'http://www.example.com/cert.pdf',
|
||||
'grade': '0.98',
|
||||
'created': '2015-07-31T00:00:00Z',
|
||||
'modified': '2015-07-31T00:00:00Z',
|
||||
}
|
||||
|
||||
result.update(**kwargs)
|
||||
|
||||
return result
|
||||
|
||||
def test_no_enrollments_or_entitlements(self, mock_get_programs):
|
||||
"""Verify behavior when programs exist, but no relevant enrollments or entitlements do."""
|
||||
data = [ProgramFactory()]
|
||||
@@ -112,7 +101,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
self.assertEqual(meter.engaged_programs, [])
|
||||
self._assert_progress(meter)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
def test_enrollments_but_no_programs(self, mock_get_programs):
|
||||
"""Verify behavior when enrollments exist, but no matching programs do."""
|
||||
@@ -124,7 +113,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
self.assertEqual(meter.engaged_programs, [])
|
||||
self._assert_progress(meter)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
def test_entitlements_but_no_programs(self, mock_get_programs):
|
||||
""" Verify engaged_programs is empty when entitlements exist, but no matching programs do. """
|
||||
@@ -163,7 +152,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
meter,
|
||||
ProgressFactory(uuid=program['uuid'], in_progress=1, grades={course_run_key: 0.0})
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
def test_single_program_entitlement(self, mock_get_programs):
|
||||
"""
|
||||
@@ -387,7 +376,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
meter,
|
||||
*(ProgressFactory(uuid=program['uuid'], in_progress=1, grades=grades) for program in programs)
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
def test_multiple_program_entitlement(self, mock_get_programs):
|
||||
"""
|
||||
@@ -460,7 +449,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
meter,
|
||||
*(ProgressFactory(uuid=program['uuid'], in_progress=1, grades=grades) for program in programs)
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
def test_shared_entitlement_engagement(self, mock_get_programs):
|
||||
"""
|
||||
@@ -490,8 +479,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
programs = data[:3]
|
||||
self.assertEqual(meter.engaged_programs, programs)
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
|
||||
def test_simulate_progress(self, mock_completed_course_runs, mock_get_programs):
|
||||
def test_simulate_progress(self, mock_get_programs):
|
||||
"""Simulate the entirety of a user's progress through a program."""
|
||||
first_course_run_key, second_course_run_key = (generate_course_run_key() for __ in range(2))
|
||||
data = [
|
||||
@@ -512,7 +500,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
# No enrollments, no programs in progress.
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(meter)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
# One enrollment, one program in progress.
|
||||
self._create_enrollments(first_course_run_key)
|
||||
@@ -522,7 +510,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
meter,
|
||||
ProgressFactory(uuid=program_uuid, in_progress=1, not_started=1, grades={first_course_run_key: 0.0})
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
# Two enrollments, all courses in progress.
|
||||
self._create_enrollments(second_course_run_key)
|
||||
@@ -538,12 +526,10 @@ class TestProgramProgressMeter(TestCase):
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
# One valid certificate earned, one course complete.
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': first_course_run_key, 'type': MODES.verified},
|
||||
]
|
||||
self._create_certificates(first_course_run_key, mode=MODES.verified)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
@@ -557,13 +543,11 @@ class TestProgramProgressMeter(TestCase):
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
# Invalid certificate earned, still one course to complete. (invalid because mode doesn't match the course)
|
||||
second_cert = self._create_certificates(second_course_run_key, mode=MODES.honor)[0]
|
||||
|
||||
# Invalid certificate earned, still one course to complete.
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': first_course_run_key, 'type': MODES.verified},
|
||||
{'course_run_id': second_course_run_key, 'type': MODES.honor},
|
||||
]
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
@@ -577,13 +561,11 @@ class TestProgramProgressMeter(TestCase):
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
# Second valid certificate obtained, all courses complete.
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': first_course_run_key, 'type': MODES.verified},
|
||||
{'course_run_id': second_course_run_key, 'type': MODES.verified},
|
||||
]
|
||||
second_cert.mode = MODES.verified
|
||||
second_cert.save()
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
@@ -596,10 +578,9 @@ class TestProgramProgressMeter(TestCase):
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [program_uuid])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [program_uuid])
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
|
||||
def test_nonverified_course_run_completion(self, mock_completed_course_runs, mock_get_programs):
|
||||
def test_nonverified_course_run_completion(self, mock_get_programs):
|
||||
"""
|
||||
Course runs aren't necessarily of type verified. Verify that a program can
|
||||
still be completed when this is the case.
|
||||
@@ -619,9 +600,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
mock_get_programs.return_value = data
|
||||
|
||||
self._create_enrollments(course_run_key)
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': course_run_key, 'type': MODES.honor},
|
||||
]
|
||||
self._create_certificates(course_run_key)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
program, program_uuid = data[0], data[0]['uuid']
|
||||
@@ -629,71 +608,80 @@ class TestProgramProgressMeter(TestCase):
|
||||
meter,
|
||||
ProgressFactory(uuid=program_uuid, completed=1, grades={course_run_key: 0.0})
|
||||
)
|
||||
self.assertEqual(meter.completed_programs, [program_uuid])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [program_uuid])
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.available_date_for_certificate')
|
||||
def test_completed_programs_with_available_dates(self, mock_available_date_for_certificate, mock_get_programs):
|
||||
# First we want to set up the scenario:
|
||||
# - A program that is incomplete due to no cert (won't show up in result)
|
||||
# - A program that is incomplete due to cert with wrong mode (won't show up in result)
|
||||
# - A completed program with the multiple certs, each with specific available dates
|
||||
|
||||
run_no_cert = CourseRunFactory()
|
||||
run_wrong_mode = CourseRunFactory()
|
||||
run_course1_1 = CourseRunFactory()
|
||||
run_course1_2 = CourseRunFactory()
|
||||
run_course2 = CourseRunFactory()
|
||||
|
||||
course_no_cert = CourseFactory(course_runs=[run_no_cert])
|
||||
course_wrong_mode = CourseFactory(course_runs=[run_wrong_mode])
|
||||
course1 = CourseFactory(course_runs=[run_course1_1, run_course1_2])
|
||||
course2 = CourseFactory(course_runs=[run_course2])
|
||||
|
||||
program_incomplete_no_cert = ProgramFactory(courses=[course_no_cert, course1, course2])
|
||||
program_incomplete_wrong_mode = ProgramFactory(courses=[course_wrong_mode])
|
||||
program_complete = ProgramFactory(courses=[course1, course2])
|
||||
mock_get_programs.return_value = [program_incomplete_no_cert, program_incomplete_wrong_mode, program_complete]
|
||||
|
||||
self._create_enrollments(run_no_cert['key'], run_wrong_mode['key'], run_course1_1['key'], run_course1_2['key'],
|
||||
run_course2['key'])
|
||||
self._create_certificates(run_wrong_mode['key'], mode=MODES.audit)
|
||||
self._create_certificates(run_course1_1['key'], run_course1_2['key'], run_course2['key'], mode=MODES.verified)
|
||||
|
||||
def available_date_fake(_course, cert):
|
||||
""" Fake available_date_for_certificate """
|
||||
if str(cert.course_id) == run_course1_1['key']:
|
||||
return datetime.datetime(2018, 1, 1)
|
||||
if str(cert.course_id) == run_course1_2['key']:
|
||||
return datetime.datetime(2017, 1, 1)
|
||||
if str(cert.course_id) == run_course2['key']:
|
||||
return datetime.datetime(2016, 1, 1)
|
||||
return datetime.datetime(2015, 1, 1)
|
||||
mock_available_date_for_certificate.side_effect = available_date_fake
|
||||
|
||||
def test_empty_programs(self, mock_get_programs):
|
||||
"""Verify that programs with no courses do not count as completed."""
|
||||
program = ProgramFactory()
|
||||
program['courses'] = []
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
program_complete = meter._is_program_complete(program)
|
||||
self.assertFalse(program_complete)
|
||||
available_dates = meter.completed_programs_with_available_dates
|
||||
self.assertDictEqual(available_dates, {
|
||||
program_complete['uuid']: datetime.datetime(2017, 1, 1)
|
||||
})
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
|
||||
def test_completed_programs(self, mock_completed_course_runs, mock_get_programs):
|
||||
"""Verify that completed programs are correctly identified."""
|
||||
data = ProgramFactory.create_batch(3)
|
||||
mock_get_programs.return_value = data
|
||||
|
||||
program_uuids = []
|
||||
course_run_keys = []
|
||||
for program in data:
|
||||
program_uuids.append(program['uuid'])
|
||||
|
||||
for course in program['courses']:
|
||||
for course_run in course['course_runs']:
|
||||
course_run_keys.append(course_run['key'])
|
||||
|
||||
# Verify that no programs are complete.
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
|
||||
# Complete all programs.
|
||||
self._create_enrollments(*course_run_keys)
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': course_run_key, 'type': MODES.verified}
|
||||
for course_run_key in course_run_keys
|
||||
]
|
||||
|
||||
# Verify that all programs are complete.
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, program_uuids)
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
|
||||
def test_completed_course_runs(self, mock_get_certificates_for_user, _mock_get_programs):
|
||||
def test_completed_course_runs(self, mock_get_programs):
|
||||
"""
|
||||
Verify that the method can find course run certificates when not mocked out.
|
||||
"""
|
||||
mock_get_certificates_for_user.return_value = [
|
||||
self._make_certificate_result(
|
||||
status='downloadable', type=CourseMode.VERIFIED, course_key='downloadable-course'
|
||||
),
|
||||
self._make_certificate_result(status='generating', type='honor', course_key='generating-course'),
|
||||
self._make_certificate_result(status='unknown', course_key='unknown-course'),
|
||||
]
|
||||
downloadable = CourseRunFactory()
|
||||
generating = CourseRunFactory()
|
||||
unknown = CourseRunFactory()
|
||||
course = CourseFactory(course_runs=[downloadable, generating, unknown])
|
||||
program = ProgramFactory(courses=[course])
|
||||
mock_get_programs.return_value = [program]
|
||||
|
||||
self._create_enrollments(downloadable['key'], generating['key'], unknown['key'])
|
||||
|
||||
self._create_certificates(downloadable['key'], mode=CourseMode.VERIFIED)
|
||||
self._create_certificates(generating['key'], status='generating', mode=CourseMode.HONOR)
|
||||
self._create_certificates(unknown['key'], status='unknown')
|
||||
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(
|
||||
self.assertItemsEqual(
|
||||
meter.completed_course_runs,
|
||||
[
|
||||
{'course_run_id': 'downloadable-course', 'type': CourseMode.VERIFIED},
|
||||
{'course_run_id': 'generating-course', 'type': 'honor'},
|
||||
{'course_run_id': downloadable['key'], 'type': CourseMode.VERIFIED},
|
||||
{'course_run_id': generating['key'], 'type': CourseMode.HONOR},
|
||||
]
|
||||
)
|
||||
mock_get_certificates_for_user.assert_called_with(self.user.username)
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
|
||||
def test_program_completion_with_no_id_professional(self, mock_get_certificates_for_user, mock_get_programs):
|
||||
def test_program_completion_with_no_id_professional(self, mock_get_programs):
|
||||
"""
|
||||
Verify that 'no-id-professional' certificates are treated as if they were
|
||||
'professional' certificates when determining program completion.
|
||||
@@ -707,19 +695,16 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
# Verify that the test program is not complete.
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
|
||||
|
||||
# Grant a 'no-id-professional' certificate for one of the course runs,
|
||||
# thereby completing the program.
|
||||
mock_get_certificates_for_user.return_value = [
|
||||
self._make_certificate_result(
|
||||
status='downloadable', type=CourseMode.NO_ID_PROFESSIONAL_MODE, course_key=course_runs[0]['key']
|
||||
)
|
||||
]
|
||||
self._create_enrollments(course_runs[0]['key'])
|
||||
self._create_certificates(course_runs[0]['key'], mode=CourseMode.NO_ID_PROFESSIONAL_MODE)
|
||||
|
||||
# Verify that the program is complete.
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, [program['uuid']])
|
||||
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [program['uuid']])
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
|
||||
def test_credit_course_counted_complete_for_verified(self, mock_completed_course_runs, mock_get_programs):
|
||||
|
||||
@@ -21,10 +21,12 @@ from requests.exceptions import ConnectionError, Timeout
|
||||
from course_modes.models import CourseMode
|
||||
from entitlements.models import CourseEntitlement
|
||||
from lms.djangoapps.certificates import api as certificate_api
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs, get_fulfillable_course_runs_for_entitlement
|
||||
from openedx.core.djangoapps.certificates.api import available_date_for_certificate
|
||||
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.credentials.utils import get_credentials
|
||||
@@ -276,27 +278,81 @@ class ProgramProgressMeter(object):
|
||||
return progress
|
||||
|
||||
@property
|
||||
def completed_programs(self):
|
||||
"""Identify programs completed by the student.
|
||||
|
||||
Returns:
|
||||
list of UUIDs, each identifying a completed program.
|
||||
def completed_programs_with_available_dates(self):
|
||||
"""
|
||||
return [program['uuid'] for program in self.programs if self._is_program_complete(program)]
|
||||
Calculate the available date for completed programs based on course runs.
|
||||
|
||||
def _is_program_complete(self, program):
|
||||
"""Check if a user has completed a program.
|
||||
|
||||
A program is completed if the user has completed all nested courses.
|
||||
|
||||
Arguments:
|
||||
program (dict): Representing the program whose completion to assess.
|
||||
|
||||
Returns:
|
||||
bool, indicating whether the program is complete.
|
||||
Returns a dict of {uuid_string: available_datetime}
|
||||
"""
|
||||
return all(self._is_course_complete(course) for course in program['courses']) \
|
||||
and len(program['courses']) > 0
|
||||
completed = {}
|
||||
for program in self.programs:
|
||||
available_date = self._available_date_for_program(program)
|
||||
if available_date:
|
||||
completed[program['uuid']] = available_date
|
||||
return completed
|
||||
|
||||
def _available_date_for_program(self, program_data):
|
||||
"""
|
||||
Calculate the available date for the program based on the courses within it.
|
||||
|
||||
Returns a datetime object or None if the program is not complete.
|
||||
"""
|
||||
program_available_date = None
|
||||
for course in program_data['courses']:
|
||||
earliest_course_run_date = None
|
||||
|
||||
for course_run in course['course_runs']:
|
||||
key = CourseKey.from_string(course_run['key'])
|
||||
|
||||
# Get a certificate if one exists
|
||||
certificate = GeneratedCertificate.eligible_certificates.filter(user=self.user, course_id=key).first()
|
||||
if certificate is None:
|
||||
continue
|
||||
|
||||
# Modes must match (see _is_course_complete() comments for why)
|
||||
course_run_mode = self._course_run_mode_translation(course_run['type'])
|
||||
certificate_mode = self._certificate_mode_translation(certificate.mode)
|
||||
modes_match = course_run_mode == certificate_mode
|
||||
|
||||
# Grab the available date and keep it if it's the earliest one for this catalog course.
|
||||
if modes_match and certificate_api.is_passing_status(certificate.status):
|
||||
course_overview = CourseOverview.get_from_id(key)
|
||||
available_date = available_date_for_certificate(course_overview, certificate)
|
||||
earliest_course_run_date = min(filter(None, [available_date, earliest_course_run_date]))
|
||||
|
||||
# If we're missing a cert for a course, the program isn't completed and we should just bail now
|
||||
if earliest_course_run_date is None:
|
||||
return None
|
||||
|
||||
# Keep the catalog course date if it's the latest one
|
||||
program_available_date = max(filter(None, [earliest_course_run_date, program_available_date]))
|
||||
|
||||
return program_available_date
|
||||
|
||||
def _course_run_mode_translation(self, course_run_mode):
|
||||
"""
|
||||
Returns a canonical mode for a course run (whose data is coming from the program cache).
|
||||
This mode must match the certificate mode to be counted as complete.
|
||||
"""
|
||||
mappings = {
|
||||
# Runs of type 'credit' are counted as 'verified' since verified
|
||||
# certificates are earned when credit runs are completed. LEARNER-1274
|
||||
# tracks a cleaner way to do this using the discovery service's
|
||||
# applicable_seat_types field.
|
||||
CourseMode.CREDIT_MODE: CourseMode.VERIFIED,
|
||||
}
|
||||
return mappings.get(course_run_mode, course_run_mode)
|
||||
|
||||
def _certificate_mode_translation(self, certificate_mode):
|
||||
"""
|
||||
Returns a canonical mode for a certificate (whose data is coming from the database).
|
||||
This mode must match the course run mode to be counted as complete.
|
||||
"""
|
||||
mappings = {
|
||||
# Treat "no-id-professional" certificates as "professional" certificates
|
||||
CourseMode.NO_ID_PROFESSIONAL_MODE: CourseMode.PROFESSIONAL,
|
||||
}
|
||||
return mappings.get(certificate_mode, certificate_mode)
|
||||
|
||||
def _is_course_complete(self, course):
|
||||
"""Check if a user has completed a course.
|
||||
@@ -325,12 +381,7 @@ class ProgramProgressMeter(object):
|
||||
# count towards completion of a course in a program). This may change
|
||||
# in the future to make use of the more rigid set of "applicable seat
|
||||
# types" associated with each program type in the catalog.
|
||||
|
||||
# Runs of type 'credit' are counted as 'verified' since verified
|
||||
# certificates are earned when credit runs are completed. LEARNER-1274
|
||||
# tracks a cleaner way to do this using the discovery service's
|
||||
# applicable_seat_types field.
|
||||
'type': 'verified' if course_run['type'] == 'credit' else course_run['type'],
|
||||
'type': self._course_run_mode_translation(course_run['type']),
|
||||
}
|
||||
|
||||
return any(reshape(course_run) in self.completed_course_runs for course_run in course['course_runs'])
|
||||
@@ -367,15 +418,9 @@ class ProgramProgressMeter(object):
|
||||
|
||||
completed_runs, failed_runs = [], []
|
||||
for certificate in course_run_certificates:
|
||||
certificate_type = certificate['type']
|
||||
|
||||
# Treat "no-id-professional" certificates as "professional" certificates
|
||||
if certificate_type == CourseMode.NO_ID_PROFESSIONAL_MODE:
|
||||
certificate_type = CourseMode.PROFESSIONAL
|
||||
|
||||
course_data = {
|
||||
'course_run_id': unicode(certificate['course_key']),
|
||||
'type': certificate_type,
|
||||
'type': self._certificate_mode_translation(certificate['type']),
|
||||
}
|
||||
|
||||
if certificate_api.is_passing_status(certificate['status']):
|
||||
|
||||
Reference in New Issue
Block a user