diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index ace9fe14bf..61c73a9bc7 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -925,7 +925,6 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): programs[unicode(course)] = [{ 'id': _id, 'category': self.category, - 'display_category': self.display_category, 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, 'marketing_slug': 'fake-marketing-slug-xseries-1', 'status': program_status, @@ -968,7 +967,6 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): u'edx/demox/Run_1': [{ 'id': 0, 'category': self.category, - 'display_category': self.display_category, 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, 'marketing_slug': marketing_slug, 'status': program_status, diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e8ee1362dd..e4e98cf537 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -126,7 +126,7 @@ from notification_prefs.views import enable_notifications from openedx.core.djangoapps.credentials.utils import get_user_program_credentials from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings from openedx.core.djangoapps.user_api.preferences import api as preferences_api -from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard +from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard, get_display_category from openedx.core.djangoapps.programs.models import ProgramsApiConfig @@ -2452,8 +2452,8 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali 'xseries' + '/{}' ).format(program['marketing_slug']) }) - programs_for_course['display_category'] = program.get('display_category') programs_for_course['category'] = program.get('category') + programs_for_course['display_category'] = get_display_category(program) except KeyError: log.warning('Program structure is invalid, skipping display: %r', program) diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index ae000bfefe..e9f31172ff 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -32,6 +32,7 @@ from certificates.queue import XQueueCertInterface log = logging.getLogger("edx.certificate") +MODES = GeneratedCertificate.MODES def is_passing_status(cert_status): diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py index a9cc3a25ac..a447007382 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_programs.py +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -53,6 +53,8 @@ class TestProgramListing( def _create_course_and_enroll(self, student, org, course, run): """ Creates a course and associated enrollment. + + TODO: Use CourseEnrollmentFactory to avoid course creation. """ course_location = locator.CourseLocator(org, course, run) course = CourseFactory.create( @@ -96,6 +98,10 @@ class TestProgramListing( self.PROGRAMS_API_RESPONSE['results'][program_id]['organizations'][0]['display_name'], ] + def _assert_progress_data_present(self, response): + """Verify that progress data is present.""" + self.assertContains(response, 'userProgress') + @httpretty.activate def test_get_program_with_no_enrollment(self): response = self._setup_and_get_program() @@ -113,6 +119,8 @@ class TestProgramListing( for program_element in self._get_program_checklist(1): self.assertNotContains(response, program_element) + self._assert_progress_data_present(response) + @httpretty.activate def test_get_both_program(self): self._create_course_and_enroll(self.student, *self.COURSE_KEYS[0].split('/')) @@ -123,6 +131,8 @@ class TestProgramListing( for program_element in self._get_program_checklist(1): self.assertContains(response, program_element) + self._assert_progress_data_present(response) + def test_get_programs_dashboard_not_enabled(self): self.create_programs_config(program_listing_enabled=False) self.client.login(username=self.student.username, password=self.PASSWORD) diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py index 427bfa6ed3..fe3a1a90cc 100644 --- a/lms/djangoapps/learner_dashboard/views.py +++ b/lms/djangoapps/learner_dashboard/views.py @@ -7,8 +7,8 @@ from django.views.decorators.http import require_GET from django.http import Http404 from edxmako.shortcuts import render_to_response -from openedx.core.djangoapps.programs.utils import get_engaged_programs from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.utils import ProgramProgressMeter, get_display_category from student.views import get_course_enrollments, _get_xseries_credentials @@ -21,11 +21,13 @@ def view_programs(request): raise Http404 enrollments = list(get_course_enrollments(request.user, None, [])) - programs = get_engaged_programs(request.user, enrollments) + meter = ProgramProgressMeter(request.user, enrollments) + programs = meter.engaged_programs # TODO: Pull 'xseries' string from configuration model. marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').strip('/') for program in programs: + program['display_category'] = get_display_category(program) program['marketing_url'] = '{root}/{slug}'.format( root=marketing_root, slug=program['marketing_slug'] @@ -33,6 +35,7 @@ def view_programs(request): return render_to_response('learner_dashboard/programs.html', { 'programs': programs, + 'progress': meter.progress, 'xseries_url': marketing_root if ProgramsApiConfig.current().show_xseries_ad else None, 'nav_hidden': True, 'show_program_listing': show_program_listing, diff --git a/lms/templates/learner_dashboard/programs.html b/lms/templates/learner_dashboard/programs.html index ca0edab375..7413385c70 100644 --- a/lms/templates/learner_dashboard/programs.html +++ b/lms/templates/learner_dashboard/programs.html @@ -13,6 +13,7 @@ from openedx.core.djangolib.js_utils import ( ProgramListFactory({ programsData: ${programs | n, dump_js_escaped_json}, certificatesData: ${credentials | n, dump_js_escaped_json}, + userProgress: ${progress | n, dump_js_escaped_json}, xseriesUrl: '${xseries_url | n, js_escaped_string}', xseriesImage: '${static.url('images/xseries-certificate-visual.png')}' }); diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index 65a636f5c9..7e31fa4db7 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -1,18 +1,16 @@ """ This file contains celery tasks for programs-related functionality. """ - from celery import task from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error from django.conf import settings from django.contrib.auth.models import User from edx_rest_api_client.client import EdxRestApiClient -from lms.djangoapps.certificates.api import get_certificates_for_user, is_passing_status - from openedx.core.djangoapps.credentials.models import CredentialsApiConfig from openedx.core.djangoapps.credentials.utils import get_user_credentials from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.utils import get_completed_courses from openedx.core.lib.token_utils import get_id_token @@ -37,26 +35,6 @@ def get_api_client(api_config, student): return EdxRestApiClient(api_config.internal_api_url, jwt=id_token) -def get_completed_courses(student): - """ - Determine which courses have been completed by the user. - - Args: - student: - User object representing the student - - Returns: - iterable of dicts with structure {'course_id': course_key, 'mode': cert_type} - - """ - all_certs = get_certificates_for_user(student.username) - return [ - {'course_id': unicode(cert['course_key']), 'mode': cert['type']} - for cert in all_certs - if is_passing_status(cert['status']) - ] - - def get_completed_programs(client, course_certificates): """ Given a set of completed courses, determine which programs are completed. 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 397bd593c1..e3bafd27e8 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py @@ -48,50 +48,6 @@ class GetApiClientTestCase(TestCase, ProgramsApiConfigMixin): self.assertEqual(api_client._store['session'].auth.token, 'test-token') # pylint: disable=protected-access -@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class GetCompletedCoursesTestCase(TestCase): - """ - Test the get_completed_courses function - """ - - def make_cert_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 - - @mock.patch(TASKS_MODULE + '.get_certificates_for_user') - def test_get_completed_courses(self, mock_get_certs_for_user): - """ - Ensure the function correctly calls to and handles results from the - certificates API - """ - student = UserFactory(username='test-username') - mock_get_certs_for_user.return_value = [ - self.make_cert_result(status='downloadable', type='verified', course_key='downloadable-course'), - self.make_cert_result(status='generating', type='prof-ed', course_key='generating-course'), - self.make_cert_result(status='unknown', type='honor', course_key='unknown-course'), - ] - - result = tasks.get_completed_courses(student) - self.assertEqual(mock_get_certs_for_user.call_args[0], (student.username, )) - self.assertEqual(result, [ - {'course_id': 'downloadable-course', 'mode': 'verified'}, - {'course_id': 'generating-course', 'mode': 'prof-ed'}, - ]) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class GetCompletedProgramsTestCase(TestCase): """ diff --git a/openedx/core/djangoapps/programs/tests/factories.py b/openedx/core/djangoapps/programs/tests/factories.py index 10b97de2f3..a20837a46f 100644 --- a/openedx/core/djangoapps/programs/tests/factories.py +++ b/openedx/core/djangoapps/programs/tests/factories.py @@ -52,3 +52,16 @@ class RunMode(factory.Factory): course_key = FuzzyText(prefix='org/', suffix='/run') mode_slug = 'verified' + + +class Progress(factory.Factory): + """ + Factory for stubbing program progress dicts. + """ + class Meta(object): + model = dict + + id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name + completed = [] + in_progress = [] + not_started = [] diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py index 8e62886f89..616fbe0621 100644 --- a/openedx/core/djangoapps/programs/tests/mixins.py +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -33,7 +33,10 @@ class ProgramsApiConfigMixin(object): class ProgramsDataMixin(object): - """Mixin mocking Programs API URLs and providing fake data for testing.""" + """Mixin mocking Programs API URLs and providing fake data for testing. + + NOTE: This mixin is DEPRECATED. Tests should create and manage their own data. + """ PROGRAM_NAMES = [ 'Test Program A', 'Test Program B', diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index f152c7682a..19a6638c80 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -1,5 +1,6 @@ """Tests covering Programs utilities.""" -import unittest +import json +from unittest import skipUnless from django.conf import settings from django.core.cache import cache @@ -10,20 +11,19 @@ from nose.plugins.attrib import attr from edx_oauth2_provider.tests.factories import ClientFactory from provider.constants import CONFIDENTIAL +from lms.djangoapps.certificates.api import MODES from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin +from openedx.core.djangoapps.programs import utils from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.tests import factories from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin -from openedx.core.djangoapps.programs.utils import ( - get_programs, - get_programs_for_dashboard, - get_programs_for_credentials, - get_engaged_programs, - get_display_category -) from student.tests.factories import UserFactory, CourseEnrollmentFactory -@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' + + +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @attr('shard_2') class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, CredentialsApiConfigMixin, TestCase): @@ -42,7 +42,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_programs_config() self.mock_programs_api() - actual = get_programs(self.user) + actual = utils.get_programs(self.user) self.assertEqual( actual, self.PROGRAMS_API_RESPONSE['results'] @@ -58,10 +58,10 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.mock_programs_api() # Warm up the cache. - get_programs(self.user) + utils.get_programs(self.user) # Hit the cache. - get_programs(self.user) + utils.get_programs(self.user) # Verify only one request was made. self.assertEqual(len(httpretty.httpretty.latest_requests), 1) @@ -70,7 +70,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, # Hit the Programs API twice. for _ in range(2): - get_programs(staff_user) + utils.get_programs(staff_user) # Verify that three requests have been made (one for student, two for staff). self.assertEqual(len(httpretty.httpretty.latest_requests), 3) @@ -79,7 +79,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, """Verify behavior when programs is disabled.""" self.create_programs_config(enabled=False) - actual = get_programs(self.user) + actual = utils.get_programs(self.user) self.assertEqual(actual, []) @mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__') @@ -88,7 +88,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_programs_config() mock_init.side_effect = Exception - actual = get_programs(self.user) + actual = utils.get_programs(self.user) self.assertEqual(actual, []) self.assertTrue(mock_init.called) @@ -98,7 +98,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_programs_config() self.mock_programs_api(status_code=500) - actual = get_programs(self.user) + actual = utils.get_programs(self.user) self.assertEqual(actual, []) @httpretty.activate @@ -107,10 +107,9 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_programs_config() self.mock_programs_api() - actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS) + actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS) expected = {} for program in self.PROGRAMS_API_RESPONSE['results']: - program['display_category'] = get_display_category(program) for course_code in program['course_codes']: for run in course_code['run_modes']: course_key = run['course_key'] @@ -122,7 +121,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, """Verify behavior when student dashboard display is disabled.""" self.create_programs_config(enable_student_dashboard=False) - actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS) + actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS) self.assertEqual(actual, {}) @httpretty.activate @@ -131,7 +130,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_programs_config() self.mock_programs_api(data={'results': []}) - actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS) + actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS) self.assertEqual(actual, {}) @httpretty.activate @@ -141,7 +140,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, invalid_program = {'invalid_key': 'invalid_data'} self.mock_programs_api(data={'results': [invalid_program]}) - actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS) + actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS) self.assertEqual(actual, {}) @httpretty.activate @@ -150,7 +149,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_programs_config() self.mock_programs_api() - actual = get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA) + actual = utils.get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA) expected = self.PROGRAMS_API_RESPONSE['results'][:2] expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url'] expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url'] @@ -165,7 +164,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.create_credentials_config() self.mock_programs_api(data={'results': []}) - actual = get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA) + actual = utils.get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA) self.assertEqual(actual, []) @httpretty.activate @@ -188,113 +187,385 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, "credential_url": "www.example.com" } ] - actual = get_programs_for_credentials(self.user, credential_data) + actual = utils.get_programs_for_credentials(self.user, credential_data) self.assertEqual(actual, []) - def _create_enrollments(self, *course_ids): - """Variadic helper method used to create course enrollments.""" - return [CourseEnrollmentFactory(user=self.user, course_id=c) for c in course_ids] - - @httpretty.activate - def test_get_engaged_programs(self): - """ - Verify that correct programs are returned in the correct order when the user - has multiple enrollments. - """ - self.create_programs_config() - self.mock_programs_api() - - enrollments = self._create_enrollments(*self.COURSE_KEYS) - actual = get_engaged_programs(self.user, enrollments) - - programs = self.PROGRAMS_API_RESPONSE['results'] - for program in programs: - program['display_category'] = get_display_category(program) - # get_engaged_programs iterates across a list returned by the programs - # API to create flattened lists keyed by course ID. These lists are - # joined in order of enrollment creation time when constructing the - # list of engaged programs. As such, two programs sharing an enrollment - # should be returned in the same order found in the API response. In this - # case, the most recently created enrollment is for a run mode present in - # the last two test programs. - expected = [ - programs[1], - programs[2], - programs[0], - ] - - self.assertEqual(expected, actual) - - @httpretty.activate - def test_get_engaged_programs_single_program(self): - """ - Verify that correct program is returned when the user has a single enrollment - appearing in one program. - """ - self.create_programs_config() - self.mock_programs_api() - - enrollments = self._create_enrollments(self.COURSE_KEYS[0]) - actual = get_engaged_programs(self.user, enrollments) - - programs = self.PROGRAMS_API_RESPONSE['results'] - for program in programs: - program['display_category'] = get_display_category(program) - expected = [programs[0]] - - self.assertEqual(expected, actual) - - @httpretty.activate - def test_get_engaged_programs_shared_enrollment(self): - """ - Verify that correct programs are returned when the user has a single enrollment - appearing in multiple programs. - """ - self.create_programs_config() - self.mock_programs_api() - - enrollments = self._create_enrollments(self.COURSE_KEYS[-1]) - actual = get_engaged_programs(self.user, enrollments) - - programs = self.PROGRAMS_API_RESPONSE['results'] - for program in programs: - program['display_category'] = get_display_category(program) - expected = programs[-2:] - - self.assertEqual(expected, actual) - - @httpretty.activate - def test_get_engaged_no_enrollments(self): - """Verify that no programs are returned when the user has no enrollments.""" - self.create_programs_config() - self.mock_programs_api() - - actual = get_engaged_programs(self.user, []) - expected = [] - - self.assertEqual(expected, actual) - - @httpretty.activate - def test_get_engaged_no_programs(self): - """Verify that no programs are returned when no programs exist.""" - self.create_programs_config() - self.mock_programs_api(data=[]) - - enrollments = self._create_enrollments(*self.COURSE_KEYS) - actual = get_engaged_programs(self.user, enrollments) - expected = [] - - self.assertEqual(expected, actual) - @httpretty.activate def test_get_display_category_success(self): self.create_programs_config() self.mock_programs_api() - actual_programs = get_programs(self.user) + actual_programs = utils.get_programs(self.user) for program in actual_programs: expected = 'XSeries' - self.assertEqual(expected, get_display_category(program)) + self.assertEqual(expected, utils.get_display_category(program)) def test_get_display_category_none(self): - self.assertEqual('', get_display_category(None)) - self.assertEqual('', get_display_category({"id": "test"})) + self.assertEqual('', utils.get_display_category(None)) + self.assertEqual('', utils.get_display_category({"id": "test"})) + + +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class GetCompletedCoursesTestCase(TestCase): + """ + Test the get_completed_courses function + """ + + def make_cert_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 + + @mock.patch(UTILS_MODULE + '.get_certificates_for_user') + def test_get_completed_courses(self, mock_get_certs_for_user): + """ + Ensure the function correctly calls to and handles results from the + certificates API + """ + student = UserFactory(username='test-username') + mock_get_certs_for_user.return_value = [ + self.make_cert_result(status='downloadable', type='verified', course_key='downloadable-course'), + self.make_cert_result(status='generating', type='professional', course_key='generating-course'), + self.make_cert_result(status='unknown', type='honor', course_key='unknown-course'), + ] + + result = utils.get_completed_courses(student) + self.assertEqual(mock_get_certs_for_user.call_args[0], (student.username, )) + self.assertEqual(result, [ + {'course_id': 'downloadable-course', 'mode': 'verified'}, + {'course_id': 'generating-course', 'mode': 'professional'}, + ]) + + +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@attr('shard_2') +class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): + """Tests of the program progress utility class.""" + def setUp(self): + super(TestProgramProgressMeter, self).setUp() + + self.user = UserFactory() + self.create_programs_config() + + ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) + + def _mock_programs_api(self, data): + """Helper for mocking out Programs API URLs.""" + self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.') + + url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/' + body = json.dumps({'results': data}) + + httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json') + + def _create_enrollments(self, *course_ids): + """Variadic helper used to create course enrollments.""" + return [CourseEnrollmentFactory(user=self.user, course_id=c) for c in course_ids] + + def _assert_progress(self, meter, *progresses): + """Variadic helper used to verify progress calculations.""" + self.assertEqual(meter.progress, list(progresses)) + + def _extract_names(self, program, *course_codes): + """Construct a list containing the display names of the indicated course codes.""" + return [program['course_codes'][cc]['display_name'] for cc in course_codes] + + @httpretty.activate + def test_no_enrollments(self): + """Verify behavior when programs exist, but no relevant enrollments do.""" + data = [ + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[factories.RunMode()]), + ] + ), + ] + self._mock_programs_api(data) + + meter = utils.ProgramProgressMeter(self.user, []) + + self.assertEqual(meter.engaged_programs, []) + self._assert_progress(meter) + + @httpretty.activate + def test_no_programs(self): + """Verify behavior when enrollments exist, but no matching programs do.""" + self._mock_programs_api([]) + + enrollments = self._create_enrollments('org/course/run') + meter = utils.ProgramProgressMeter(self.user, enrollments) + + self.assertEqual(meter.engaged_programs, []) + self._assert_progress(meter) + + @httpretty.activate + def test_single_program_engagement(self): + """ + Verify that correct program is returned when the user has a single enrollment + appearing in one program. + """ + course_id = 'org/course/run' + data = [ + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=course_id), + ]), + ] + ), + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[factories.RunMode()]), + ] + ), + ] + self._mock_programs_api(data) + + enrollments = self._create_enrollments(course_id) + meter = utils.ProgramProgressMeter(self.user, enrollments) + + program = data[0] + self.assertEqual(meter.engaged_programs, [program]) + self._assert_progress( + meter, + factories.Progress( + id=program['id'], + in_progress=self._extract_names(program, 0) + ) + ) + + @httpretty.activate + def test_mutiple_program_engagement(self): + """ + Verify that correct programs are returned in the correct order when the user + has multiple enrollments. + """ + first_course_id, second_course_id = 'org/first-course/run', 'org/second-course/run' + data = [ + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=first_course_id), + ]), + ] + ), + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=second_course_id), + ]), + ] + ), + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[factories.RunMode()]), + ] + ), + ] + self._mock_programs_api(data) + + enrollments = self._create_enrollments(second_course_id, first_course_id) + meter = utils.ProgramProgressMeter(self.user, enrollments) + + programs = data[:2] + self.assertEqual(meter.engaged_programs, programs) + self._assert_progress( + meter, + factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)), + factories.Progress(id=programs[1]['id'], in_progress=self._extract_names(programs[1], 0)) + ) + + @httpretty.activate + def test_shared_enrollment_engagement(self): + """ + Verify that correct programs are returned when the user has a single enrollment + appearing in multiple programs. + """ + shared_course_id, solo_course_id = 'org/shared-course/run', 'org/solo-course/run' + data = [ + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=shared_course_id), + ]), + ] + ), + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=shared_course_id), + ]), + ] + ), + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=solo_course_id), + ]), + ] + ), + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[factories.RunMode()]), + ] + ), + ] + self._mock_programs_api(data) + + # Enrollment for the shared course ID created last (most recently). + enrollments = self._create_enrollments(solo_course_id, shared_course_id) + meter = utils.ProgramProgressMeter(self.user, enrollments) + + programs = data[:3] + self.assertEqual(meter.engaged_programs, programs) + self._assert_progress( + meter, + factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)), + factories.Progress(id=programs[1]['id'], in_progress=self._extract_names(programs[1], 0)), + factories.Progress(id=programs[2]['id'], in_progress=self._extract_names(programs[2], 0)) + ) + + @httpretty.activate + @mock.patch(UTILS_MODULE + '.get_completed_courses') + def test_simulate_progress(self, mock_get_completed_courses): + """Simulate the entirety of a user's progress through a program.""" + first_course_id, second_course_id = 'org/first-course/run', 'org/second-course/run' + data = [ + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=first_course_id), + ]), + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=second_course_id), + ]), + ] + ), + ] + self._mock_programs_api(data) + + # No enrollments, no program engaged. + meter = utils.ProgramProgressMeter(self.user, []) + self._assert_progress(meter) + + # One enrollment, program engaged. + enrollments = self._create_enrollments(first_course_id) + meter = utils.ProgramProgressMeter(self.user, enrollments) + program, program_id = data[0], data[0]['id'] + self._assert_progress( + meter, + factories.Progress( + id=program_id, + in_progress=self._extract_names(program, 0), + not_started=self._extract_names(program, 1) + ) + ) + + # Two enrollments, program in progress. + enrollments += self._create_enrollments(second_course_id) + meter = utils.ProgramProgressMeter(self.user, enrollments) + self._assert_progress( + meter, + factories.Progress( + id=program_id, + in_progress=self._extract_names(program, 0, 1) + ) + ) + + # One valid certificate earned, one course code complete. + mock_get_completed_courses.return_value = [ + {'course_id': first_course_id, 'mode': MODES.verified}, + ] + self._assert_progress( + meter, + factories.Progress( + id=program_id, + completed=self._extract_names(program, 0), + in_progress=self._extract_names(program, 1) + ) + ) + + # Invalid certificate earned, still one course code to complete. + mock_get_completed_courses.return_value = [ + {'course_id': first_course_id, 'mode': MODES.verified}, + {'course_id': second_course_id, 'mode': MODES.honor}, + ] + self._assert_progress( + meter, + factories.Progress( + id=program_id, + completed=self._extract_names(program, 0), + in_progress=self._extract_names(program, 1) + ) + ) + + # Second valid certificate obtained, all course codes complete. + mock_get_completed_courses.return_value = [ + {'course_id': first_course_id, 'mode': MODES.verified}, + {'course_id': second_course_id, 'mode': MODES.verified}, + ] + self._assert_progress( + meter, + factories.Progress( + id=program_id, + completed=self._extract_names(program, 0, 1) + ) + ) + + @httpretty.activate + @mock.patch(UTILS_MODULE + '.get_completed_courses') + def test_nonstandard_run_mode_completion(self, mock_get_completed_courses): + """ + A valid run mode isn't necessarily verified. Verify that the program can + still be completed when this is the case. + """ + course_id = 'org/course/run' + data = [ + factories.Program( + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode( + course_key=course_id, + mode_slug=MODES.honor + ), + ]), + ] + ), + ] + self._mock_programs_api(data) + + enrollments = self._create_enrollments(course_id) + meter = utils.ProgramProgressMeter(self.user, enrollments) + + mock_get_completed_courses.return_value = [ + {'course_id': course_id, 'mode': MODES.honor}, + ] + + program = data[0] + self._assert_progress( + meter, + factories.Progress(id=program['id'], completed=self._extract_names(program, 0)) + ) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 05da54c1f1..080e0cfe5a 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -1,6 +1,8 @@ +# -*- coding: utf-8 -*- """Helper functions for working with Programs.""" import logging +from lms.djangoapps.certificates.api import get_certificates_for_user, is_passing_status from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.lib.edx_api_utils import get_edx_api_data @@ -46,7 +48,6 @@ def flatten_programs(programs, course_ids): for run in course_code['run_modes']: run_id = run['course_key'] if run_id in course_ids: - program['display_category'] = get_display_category(program) flattened.setdefault(run_id, []).append(program) except KeyError: log.exception('Unable to parse Programs API response: %r', program) @@ -132,28 +133,142 @@ def get_display_category(program): return display_candidate -def get_engaged_programs(user, enrollments): - """Derive a list of programs in which the given user is engaged. +def get_completed_courses(student): + """ + Determine which courses have been completed by the user. + + Args: + student: + User object representing the student + + Returns: + iterable of dicts with structure {'course_id': course_key, 'mode': cert_type} + + """ + all_certs = get_certificates_for_user(student.username) + return [ + {'course_id': unicode(cert['course_key']), 'mode': cert['type']} + for cert in all_certs + if is_passing_status(cert['status']) + ] + + +class ProgramProgressMeter(object): + """Utility for gauging a user's progress towards program completion. Arguments: user (User): The user for which to find programs. - enrollments (list): The user's enrollments. - - Returns: - list of serialized programs, ordered by most recent enrollment + enrollments (list): The user's active enrollments. """ - programs = get_programs(user) + def __init__(self, user, enrollments): + self.user = user - enrollments = sorted(enrollments, key=lambda e: e.created, reverse=True) - # enrollment.course_id is really a course key. - course_ids = [unicode(e.course_id) for e in enrollments] + enrollments = sorted(enrollments, key=lambda e: e.created, reverse=True) + # enrollment.course_id is really a course key ಠ_ಠ + self.course_ids = [unicode(e.course_id) for e in enrollments] - flattened = flatten_programs(programs, course_ids) + self.engaged_programs = self._find_engaged_programs(self.user) + self.course_certs = None - engaged_programs = [] - for course_id in course_ids: - for program in flattened.get(course_id, []): - if program not in engaged_programs: - engaged_programs.append(program) + def _find_engaged_programs(self, user): + """Derive a list of programs in which the given user is engaged. - return engaged_programs + Arguments: + user (User): The user for which to find engaged programs. + + Returns: + list of program dicts, ordered by most recent enrollment. + """ + programs = get_programs(user) + flattened = flatten_programs(programs, self.course_ids) + + engaged_programs = [] + for course_id in self.course_ids: + for program in flattened.get(course_id, []): + if program not in engaged_programs: + engaged_programs.append(program) + + return engaged_programs + + @property + def progress(self): + """Gauge a user's progress towards program completion. + + Returns: + list of dict, each containing information about a user's progress + towards completing a program. + """ + self.course_certs = get_completed_courses(self.user) + + progress = [] + for program in self.engaged_programs: + completed, in_progress, not_started = [], [], [] + + for course_code in program['course_codes']: + name = course_code['display_name'] + + if self._is_complete(course_code): + completed.append(name) + elif self._is_in_progress(course_code): + in_progress.append(name) + else: + not_started.append(name) + + progress.append({ + 'id': program['id'], + 'completed': completed, + 'in_progress': in_progress, + 'not_started': not_started, + }) + + return progress + + def _is_complete(self, course_code): + """Check if a user has completed a course code. + + A course code qualifies as completed if the user has earned a + certificate in the right mode for any nested run. + + Arguments: + course_code (dict): Containing nested run modes. + + Returns: + bool, whether the course code is complete. + """ + return any([ + self._parse(run_mode) in self.course_certs + for run_mode in course_code['run_modes'] + ]) + + def _is_in_progress(self, course_code): + """Check if a user is in the process of completing a course code. + + A user is in the process of completing a course code if they're + enrolled in the course. + + Arguments: + course_code (dict): Containing nested run modes. + + Returns: + bool, whether the course code is in progress. + """ + return any([ + run_mode['course_key'] in self.course_ids + for run_mode in course_code['run_modes'] + ]) + + def _parse(self, run_mode): + """Modify the structure of a run mode dict. + + Arguments: + run_mode (dict): With `course_key` and `mode_slug` keys. + + Returns: + dict, with `course_id` and `mode` keys. + """ + parsed = { + 'course_id': run_mode['course_key'], + 'mode': run_mode['mode_slug'], + } + + return parsed