Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
63b65ab0
Commit
63b65ab0
authored
Apr 27, 2016
by
Renzo Lucioni
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #12231 from edx/renzo/program-progress
Measuring program progress
parents
d97b3cbd
5da6a598
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
170 additions
and
92 deletions
+170
-92
common/djangoapps/student/tests/tests.py
+0
-2
common/djangoapps/student/views.py
+2
-2
lms/djangoapps/certificates/api.py
+1
-0
lms/djangoapps/learner_dashboard/tests/test_programs.py
+10
-0
lms/djangoapps/learner_dashboard/views.py
+5
-2
lms/templates/learner_dashboard/programs.html
+1
-0
openedx/core/djangoapps/programs/tasks/v1/tasks.py
+1
-23
openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py
+0
-44
openedx/core/djangoapps/programs/tests/factories.py
+13
-0
openedx/core/djangoapps/programs/tests/mixins.py
+4
-1
openedx/core/djangoapps/programs/tests/test_utils.py
+0
-0
openedx/core/djangoapps/programs/utils.py
+133
-18
No files found.
common/djangoapps/student/tests/tests.py
View file @
63b65ab0
...
...
@@ -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
,
...
...
common/djangoapps/student/views.py
View file @
63b65ab0
...
...
@@ -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
)
...
...
lms/djangoapps/certificates/api.py
View file @
63b65ab0
...
...
@@ -32,6 +32,7 @@ from certificates.queue import XQueueCertInterface
log
=
logging
.
getLogger
(
"edx.certificate"
)
MODES
=
GeneratedCertificate
.
MODES
def
is_passing_status
(
cert_status
):
...
...
lms/djangoapps/learner_dashboard/tests/test_programs.py
View file @
63b65ab0
...
...
@@ -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
)
...
...
lms/djangoapps/learner_dashboard/views.py
View file @
63b65ab0
...
...
@@ -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
,
...
...
lms/templates/learner_dashboard/programs.html
View file @
63b65ab0
...
...
@@ -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')}'
});
...
...
openedx/core/djangoapps/programs/tasks/v1/tasks.py
View file @
63b65ab0
"""
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.
...
...
openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py
View file @
63b65ab0
...
...
@@ -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
...
...
openedx/core/djangoapps/programs/tests/factories.py
View file @
63b65ab0
...
...
@@ -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
=
[]
openedx/core/djangoapps/programs/tests/mixins.py
View file @
63b65ab0
...
...
@@ -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'
,
...
...
openedx/core/djangoapps/programs/tests/test_utils.py
View file @
63b65ab0
This diff is collapsed.
Click to expand it.
openedx/core/djangoapps/programs/utils.py
View file @
63b65ab0
# -*- 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.
Arg
ument
s:
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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment