Commit 63b65ab0 by Renzo Lucioni

Merge pull request #12231 from edx/renzo/program-progress

Measuring program progress
parents d97b3cbd 5da6a598
......@@ -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')}'
});
......
"""
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.
......
......@@ -49,50 +49,6 @@ class GetApiClientTestCase(TestCase, ProgramsApiConfigMixin):
@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):
"""
Test the get_completed_programs function
......
......@@ -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',
......
# -*- 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.
Arguments:
user (User): The user for which to find programs.
enrollments (list): The user's enrollments.
Args:
student:
User object representing the student
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 = []
for course_id in course_ids:
for program in flattened.get(course_id, []):
if program not in engaged_programs:
engaged_programs.append(program)
self.engaged_programs = self._find_engaged_programs(self.user)
self.course_certs = None
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