diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py index 52356290e3..6a698d8528 100644 --- a/openedx/core/djangoapps/credentials/tests/test_utils.py +++ b/openedx/core/djangoapps/credentials/tests/test_utils.py @@ -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) diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index 5fcf5a584e..25eb37649e 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -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 diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index c9e38177db..cba2465766 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -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) } ] }) diff --git a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py index 948fc890f6..9e5994be71 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py @@ -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] ) diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index ee44542e19..b1e591ee49 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -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): diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 57dec906e7..4ec7f4e55b 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -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']):