Merge pull request #12231 from edx/renzo/program-progress
Measuring program progress
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from certificates.queue import XQueueCertInterface
|
||||
|
||||
|
||||
log = logging.getLogger("edx.certificate")
|
||||
MODES = GeneratedCertificate.MODES
|
||||
|
||||
|
||||
def is_passing_status(cert_status):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}'
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user