Commit 5da6a598 by Renzo Lucioni

Measuring program progress

Introduces a utility class for gauging a user's progress towards program completion. Progress data is passed to the ProgramListFactory. ECOM-3200.
parent badb2ec3
...@@ -925,7 +925,6 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -925,7 +925,6 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
programs[unicode(course)] = [{ programs[unicode(course)] = [{
'id': _id, 'id': _id,
'category': self.category, 'category': self.category,
'display_category': self.display_category,
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-1', 'marketing_slug': 'fake-marketing-slug-xseries-1',
'status': program_status, 'status': program_status,
...@@ -968,7 +967,6 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -968,7 +967,6 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
u'edx/demox/Run_1': [{ u'edx/demox/Run_1': [{
'id': 0, 'id': 0,
'category': self.category, 'category': self.category,
'display_category': self.display_category,
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': marketing_slug, 'marketing_slug': marketing_slug,
'status': program_status, 'status': program_status,
......
...@@ -126,7 +126,7 @@ from notification_prefs.views import enable_notifications ...@@ -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.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.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.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 from openedx.core.djangoapps.programs.models import ProgramsApiConfig
...@@ -2452,8 +2452,8 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali ...@@ -2452,8 +2452,8 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali
'xseries' + '/{}' 'xseries' + '/{}'
).format(program['marketing_slug']) ).format(program['marketing_slug'])
}) })
programs_for_course['display_category'] = program.get('display_category')
programs_for_course['category'] = program.get('category') programs_for_course['category'] = program.get('category')
programs_for_course['display_category'] = get_display_category(program)
except KeyError: except KeyError:
log.warning('Program structure is invalid, skipping display: %r', program) log.warning('Program structure is invalid, skipping display: %r', program)
......
...@@ -32,6 +32,7 @@ from certificates.queue import XQueueCertInterface ...@@ -32,6 +32,7 @@ from certificates.queue import XQueueCertInterface
log = logging.getLogger("edx.certificate") log = logging.getLogger("edx.certificate")
MODES = GeneratedCertificate.MODES
def is_passing_status(cert_status): def is_passing_status(cert_status):
......
...@@ -53,6 +53,8 @@ class TestProgramListing( ...@@ -53,6 +53,8 @@ class TestProgramListing(
def _create_course_and_enroll(self, student, org, course, run): def _create_course_and_enroll(self, student, org, course, run):
""" """
Creates a course and associated enrollment. Creates a course and associated enrollment.
TODO: Use CourseEnrollmentFactory to avoid course creation.
""" """
course_location = locator.CourseLocator(org, course, run) course_location = locator.CourseLocator(org, course, run)
course = CourseFactory.create( course = CourseFactory.create(
...@@ -96,6 +98,10 @@ class TestProgramListing( ...@@ -96,6 +98,10 @@ class TestProgramListing(
self.PROGRAMS_API_RESPONSE['results'][program_id]['organizations'][0]['display_name'], 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 @httpretty.activate
def test_get_program_with_no_enrollment(self): def test_get_program_with_no_enrollment(self):
response = self._setup_and_get_program() response = self._setup_and_get_program()
...@@ -113,6 +119,8 @@ class TestProgramListing( ...@@ -113,6 +119,8 @@ class TestProgramListing(
for program_element in self._get_program_checklist(1): for program_element in self._get_program_checklist(1):
self.assertNotContains(response, program_element) self.assertNotContains(response, program_element)
self._assert_progress_data_present(response)
@httpretty.activate @httpretty.activate
def test_get_both_program(self): def test_get_both_program(self):
self._create_course_and_enroll(self.student, *self.COURSE_KEYS[0].split('/')) self._create_course_and_enroll(self.student, *self.COURSE_KEYS[0].split('/'))
...@@ -123,6 +131,8 @@ class TestProgramListing( ...@@ -123,6 +131,8 @@ class TestProgramListing(
for program_element in self._get_program_checklist(1): for program_element in self._get_program_checklist(1):
self.assertContains(response, program_element) self.assertContains(response, program_element)
self._assert_progress_data_present(response)
def test_get_programs_dashboard_not_enabled(self): def test_get_programs_dashboard_not_enabled(self):
self.create_programs_config(program_listing_enabled=False) self.create_programs_config(program_listing_enabled=False)
self.client.login(username=self.student.username, password=self.PASSWORD) self.client.login(username=self.student.username, password=self.PASSWORD)
......
...@@ -7,8 +7,8 @@ from django.views.decorators.http import require_GET ...@@ -7,8 +7,8 @@ from django.views.decorators.http import require_GET
from django.http import Http404 from django.http import Http404
from edxmako.shortcuts import render_to_response 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.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter, get_display_category
from student.views import get_course_enrollments, _get_xseries_credentials from student.views import get_course_enrollments, _get_xseries_credentials
...@@ -21,11 +21,13 @@ def view_programs(request): ...@@ -21,11 +21,13 @@ def view_programs(request):
raise Http404 raise Http404
enrollments = list(get_course_enrollments(request.user, None, [])) 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. # TODO: Pull 'xseries' string from configuration model.
marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').strip('/') marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').strip('/')
for program in programs: for program in programs:
program['display_category'] = get_display_category(program)
program['marketing_url'] = '{root}/{slug}'.format( program['marketing_url'] = '{root}/{slug}'.format(
root=marketing_root, root=marketing_root,
slug=program['marketing_slug'] slug=program['marketing_slug']
...@@ -33,6 +35,7 @@ def view_programs(request): ...@@ -33,6 +35,7 @@ def view_programs(request):
return render_to_response('learner_dashboard/programs.html', { return render_to_response('learner_dashboard/programs.html', {
'programs': programs, 'programs': programs,
'progress': meter.progress,
'xseries_url': marketing_root if ProgramsApiConfig.current().show_xseries_ad else None, 'xseries_url': marketing_root if ProgramsApiConfig.current().show_xseries_ad else None,
'nav_hidden': True, 'nav_hidden': True,
'show_program_listing': show_program_listing, 'show_program_listing': show_program_listing,
......
...@@ -13,6 +13,7 @@ from openedx.core.djangolib.js_utils import ( ...@@ -13,6 +13,7 @@ from openedx.core.djangolib.js_utils import (
ProgramListFactory({ ProgramListFactory({
programsData: ${programs | n, dump_js_escaped_json}, programsData: ${programs | n, dump_js_escaped_json},
certificatesData: ${credentials | 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}', xseriesUrl: '${xseries_url | n, js_escaped_string}',
xseriesImage: '${static.url('images/xseries-certificate-visual.png')}' xseriesImage: '${static.url('images/xseries-certificate-visual.png')}'
}); });
......
""" """
This file contains celery tasks for programs-related functionality. This file contains celery tasks for programs-related functionality.
""" """
from celery import task from celery import task
from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from edx_rest_api_client.client import EdxRestApiClient 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.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.utils import get_user_credentials from openedx.core.djangoapps.credentials.utils import get_user_credentials
from openedx.core.djangoapps.programs.models import ProgramsApiConfig 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 from openedx.core.lib.token_utils import get_id_token
...@@ -37,26 +35,6 @@ def get_api_client(api_config, student): ...@@ -37,26 +35,6 @@ def get_api_client(api_config, student):
return EdxRestApiClient(api_config.internal_api_url, jwt=id_token) 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): def get_completed_programs(client, course_certificates):
""" """
Given a set of completed courses, determine which programs are completed. Given a set of completed courses, determine which programs are completed.
......
...@@ -49,50 +49,6 @@ class GetApiClientTestCase(TestCase, ProgramsApiConfigMixin): ...@@ -49,50 +49,6 @@ class GetApiClientTestCase(TestCase, ProgramsApiConfigMixin):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @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): class GetCompletedProgramsTestCase(TestCase):
""" """
Test the get_completed_programs function Test the get_completed_programs function
......
...@@ -52,3 +52,16 @@ class RunMode(factory.Factory): ...@@ -52,3 +52,16 @@ class RunMode(factory.Factory):
course_key = FuzzyText(prefix='org/', suffix='/run') course_key = FuzzyText(prefix='org/', suffix='/run')
mode_slug = 'verified' 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 = []
...@@ -33,7 +33,10 @@ class ProgramsApiConfigMixin(object): ...@@ -33,7 +33,10 @@ class ProgramsApiConfigMixin(object):
class ProgramsDataMixin(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 = [ PROGRAM_NAMES = [
'Test Program A', 'Test Program A',
'Test Program B', 'Test Program B',
......
"""Tests covering Programs utilities.""" """Tests covering Programs utilities."""
import unittest import json
from unittest import skipUnless
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
...@@ -10,20 +11,19 @@ from nose.plugins.attrib import attr ...@@ -10,20 +11,19 @@ from nose.plugins.attrib import attr
from edx_oauth2_provider.tests.factories import ClientFactory from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL 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.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.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.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 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') @attr('shard_2')
class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
CredentialsApiConfigMixin, TestCase): CredentialsApiConfigMixin, TestCase):
...@@ -42,7 +42,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -42,7 +42,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_programs_config() self.create_programs_config()
self.mock_programs_api() self.mock_programs_api()
actual = get_programs(self.user) actual = utils.get_programs(self.user)
self.assertEqual( self.assertEqual(
actual, actual,
self.PROGRAMS_API_RESPONSE['results'] self.PROGRAMS_API_RESPONSE['results']
...@@ -58,10 +58,10 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -58,10 +58,10 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.mock_programs_api() self.mock_programs_api()
# Warm up the cache. # Warm up the cache.
get_programs(self.user) utils.get_programs(self.user)
# Hit the cache. # Hit the cache.
get_programs(self.user) utils.get_programs(self.user)
# Verify only one request was made. # Verify only one request was made.
self.assertEqual(len(httpretty.httpretty.latest_requests), 1) self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
...@@ -70,7 +70,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -70,7 +70,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
# Hit the Programs API twice. # Hit the Programs API twice.
for _ in range(2): 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). # Verify that three requests have been made (one for student, two for staff).
self.assertEqual(len(httpretty.httpretty.latest_requests), 3) self.assertEqual(len(httpretty.httpretty.latest_requests), 3)
...@@ -79,7 +79,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -79,7 +79,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
"""Verify behavior when programs is disabled.""" """Verify behavior when programs is disabled."""
self.create_programs_config(enabled=False) self.create_programs_config(enabled=False)
actual = get_programs(self.user) actual = utils.get_programs(self.user)
self.assertEqual(actual, []) self.assertEqual(actual, [])
@mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__') @mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
...@@ -88,7 +88,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -88,7 +88,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_programs_config() self.create_programs_config()
mock_init.side_effect = Exception mock_init.side_effect = Exception
actual = get_programs(self.user) actual = utils.get_programs(self.user)
self.assertEqual(actual, []) self.assertEqual(actual, [])
self.assertTrue(mock_init.called) self.assertTrue(mock_init.called)
...@@ -98,7 +98,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -98,7 +98,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_programs_config() self.create_programs_config()
self.mock_programs_api(status_code=500) self.mock_programs_api(status_code=500)
actual = get_programs(self.user) actual = utils.get_programs(self.user)
self.assertEqual(actual, []) self.assertEqual(actual, [])
@httpretty.activate @httpretty.activate
...@@ -107,10 +107,9 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -107,10 +107,9 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_programs_config() self.create_programs_config()
self.mock_programs_api() 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 = {} expected = {}
for program in self.PROGRAMS_API_RESPONSE['results']: for program in self.PROGRAMS_API_RESPONSE['results']:
program['display_category'] = get_display_category(program)
for course_code in program['course_codes']: for course_code in program['course_codes']:
for run in course_code['run_modes']: for run in course_code['run_modes']:
course_key = run['course_key'] course_key = run['course_key']
...@@ -122,7 +121,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -122,7 +121,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
"""Verify behavior when student dashboard display is disabled.""" """Verify behavior when student dashboard display is disabled."""
self.create_programs_config(enable_student_dashboard=False) 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, {}) self.assertEqual(actual, {})
@httpretty.activate @httpretty.activate
...@@ -131,7 +130,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -131,7 +130,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_programs_config() self.create_programs_config()
self.mock_programs_api(data={'results': []}) 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, {}) self.assertEqual(actual, {})
@httpretty.activate @httpretty.activate
...@@ -141,7 +140,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -141,7 +140,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
invalid_program = {'invalid_key': 'invalid_data'} invalid_program = {'invalid_key': 'invalid_data'}
self.mock_programs_api(data={'results': [invalid_program]}) 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, {}) self.assertEqual(actual, {})
@httpretty.activate @httpretty.activate
...@@ -150,7 +149,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -150,7 +149,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_programs_config() self.create_programs_config()
self.mock_programs_api() 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 = self.PROGRAMS_API_RESPONSE['results'][:2]
expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url'] expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url']
expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url'] expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url']
...@@ -165,7 +164,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -165,7 +164,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
self.create_credentials_config() self.create_credentials_config()
self.mock_programs_api(data={'results': []}) 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, []) self.assertEqual(actual, [])
@httpretty.activate @httpretty.activate
...@@ -188,113 +187,385 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ...@@ -188,113 +187,385 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
"credential_url": "www.example.com" "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, []) 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 @httpretty.activate
def test_get_engaged_programs(self): def test_get_display_category_success(self):
self.create_programs_config()
self.mock_programs_api()
actual_programs = utils.get_programs(self.user)
for program in actual_programs:
expected = 'XSeries'
self.assertEqual(expected, utils.get_display_category(program))
def test_get_display_category_none(self):
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):
""" """
Verify that correct programs are returned in the correct order when the user Helper to create dummy results from the certificates API
has multiple enrollments.
""" """
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() self.create_programs_config()
self.mock_programs_api()
enrollments = self._create_enrollments(*self.COURSE_KEYS) ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
actual = get_engaged_programs(self.user, enrollments)
def _mock_programs_api(self, data):
programs = self.PROGRAMS_API_RESPONSE['results'] """Helper for mocking out Programs API URLs."""
for program in programs: self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
program['display_category'] = get_display_category(program)
# get_engaged_programs iterates across a list returned by the programs url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
# API to create flattened lists keyed by course ID. These lists are body = json.dumps({'results': data})
# joined in order of enrollment creation time when constructing the
# list of engaged programs. As such, two programs sharing an enrollment httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json')
# 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 def _create_enrollments(self, *course_ids):
# the last two test programs. """Variadic helper used to create course enrollments."""
expected = [ return [CourseEnrollmentFactory(user=self.user, course_id=c) for c in course_ids]
programs[1],
programs[2], def _assert_progress(self, meter, *progresses):
programs[0], """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(expected, actual) self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
@httpretty.activate @httpretty.activate
def test_get_engaged_programs_single_program(self): def test_single_program_engagement(self):
""" """
Verify that correct program is returned when the user has a single enrollment Verify that correct program is returned when the user has a single enrollment
appearing in one program. appearing in one program.
""" """
self.create_programs_config() course_id = 'org/course/run'
self.mock_programs_api() 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)
)
)
enrollments = self._create_enrollments(self.COURSE_KEYS[0]) @httpretty.activate
actual = get_engaged_programs(self.user, enrollments) 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)
programs = self.PROGRAMS_API_RESPONSE['results'] enrollments = self._create_enrollments(second_course_id, first_course_id)
for program in programs: meter = utils.ProgramProgressMeter(self.user, enrollments)
program['display_category'] = get_display_category(program)
expected = [programs[0]]
self.assertEqual(expected, actual) 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 @httpretty.activate
def test_get_engaged_programs_shared_enrollment(self): def test_shared_enrollment_engagement(self):
""" """
Verify that correct programs are returned when the user has a single enrollment Verify that correct programs are returned when the user has a single enrollment
appearing in multiple programs. appearing in multiple programs.
""" """
self.create_programs_config() shared_course_id, solo_course_id = 'org/shared-course/run', 'org/solo-course/run'
self.mock_programs_api() data = [
factories.Program(
enrollments = self._create_enrollments(self.COURSE_KEYS[-1]) organizations=[factories.Organization()],
actual = get_engaged_programs(self.user, enrollments) 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))
)
programs = self.PROGRAMS_API_RESPONSE['results'] @httpretty.activate
for program in programs: @mock.patch(UTILS_MODULE + '.get_completed_courses')
program['display_category'] = get_display_category(program) def test_simulate_progress(self, mock_get_completed_courses):
expected = programs[-2:] """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)
)
)
self.assertEqual(expected, actual) # 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)
)
)
@httpretty.activate # One valid certificate earned, one course code complete.
def test_get_engaged_no_enrollments(self): mock_get_completed_courses.return_value = [
"""Verify that no programs are returned when the user has no enrollments.""" {'course_id': first_course_id, 'mode': MODES.verified},
self.create_programs_config() ]
self.mock_programs_api() self._assert_progress(
meter,
factories.Progress(
id=program_id,
completed=self._extract_names(program, 0),
in_progress=self._extract_names(program, 1)
)
)
actual = get_engaged_programs(self.user, []) # Invalid certificate earned, still one course code to complete.
expected = [] 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)
)
)
self.assertEqual(expected, actual) # 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 @httpretty.activate
def test_get_engaged_no_programs(self): @mock.patch(UTILS_MODULE + '.get_completed_courses')
"""Verify that no programs are returned when no programs exist.""" def test_nonstandard_run_mode_completion(self, mock_get_completed_courses):
self.create_programs_config() """
self.mock_programs_api(data=[]) A valid run mode isn't necessarily verified. Verify that the program can
still be completed when this is the case.
enrollments = self._create_enrollments(*self.COURSE_KEYS) """
actual = get_engaged_programs(self.user, enrollments) course_id = 'org/course/run'
expected = [] 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)
self.assertEqual(expected, actual) enrollments = self._create_enrollments(course_id)
meter = utils.ProgramProgressMeter(self.user, enrollments)
@httpretty.activate mock_get_completed_courses.return_value = [
def test_get_display_category_success(self): {'course_id': course_id, 'mode': MODES.honor},
self.create_programs_config() ]
self.mock_programs_api()
actual_programs = get_programs(self.user)
for program in actual_programs:
expected = 'XSeries'
self.assertEqual(expected, get_display_category(program))
def test_get_display_category_none(self): program = data[0]
self.assertEqual('', get_display_category(None)) self._assert_progress(
self.assertEqual('', get_display_category({"id": "test"})) meter,
factories.Progress(id=program['id'], completed=self._extract_names(program, 0))
)
# -*- coding: utf-8 -*-
"""Helper functions for working with Programs.""" """Helper functions for working with Programs."""
import logging 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.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.lib.edx_api_utils import get_edx_api_data from openedx.core.lib.edx_api_utils import get_edx_api_data
...@@ -46,7 +48,6 @@ def flatten_programs(programs, course_ids): ...@@ -46,7 +48,6 @@ def flatten_programs(programs, course_ids):
for run in course_code['run_modes']: for run in course_code['run_modes']:
run_id = run['course_key'] run_id = run['course_key']
if run_id in course_ids: if run_id in course_ids:
program['display_category'] = get_display_category(program)
flattened.setdefault(run_id, []).append(program) flattened.setdefault(run_id, []).append(program)
except KeyError: except KeyError:
log.exception('Unable to parse Programs API response: %r', program) log.exception('Unable to parse Programs API response: %r', program)
...@@ -132,28 +133,142 @@ def get_display_category(program): ...@@ -132,28 +133,142 @@ def get_display_category(program):
return display_candidate return display_candidate
def get_engaged_programs(user, enrollments): def get_completed_courses(student):
"""Derive a list of programs in which the given user is engaged. """
Determine which courses have been completed by the user.
Arguments: Args:
user (User): The user for which to find programs. student:
enrollments (list): The user's enrollments. User object representing the student
Returns: Returns:
list of serialized programs, ordered by most recent enrollment iterable of dicts with structure {'course_id': course_key, 'mode': cert_type}
""" """
programs = get_programs(user) 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'])
]
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]
flattened = flatten_programs(programs, course_ids) 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 active enrollments.
"""
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 ಠ_ಠ
self.course_ids = [unicode(e.course_id) for e in enrollments]
engaged_programs = [] self.engaged_programs = self._find_engaged_programs(self.user)
for course_id in course_ids: self.course_certs = None
for program in flattened.get(course_id, []):
if program not in engaged_programs:
engaged_programs.append(program)
return engaged_programs def _find_engaged_programs(self, user):
"""Derive a list of programs in which the given user is engaged.
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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment