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
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
540 additions
and
191 deletions
+540
-191
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
+370
-99
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
"""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
):
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
has multiple enrollments.
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
()
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
],
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
(
expected
,
actual
)
self
.
assertEqual
(
meter
.
engaged_programs
,
[])
self
.
_assert_progress
(
meter
)
@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
appearing in one program.
"""
self
.
create_programs_config
()
self
.
mock_programs_api
()
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
)
)
)
enrollments
=
self
.
_create_enrollments
(
self
.
COURSE_KEYS
[
0
])
actual
=
get_engaged_programs
(
self
.
user
,
enrollments
)
@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
)
programs
=
self
.
PROGRAMS_API_RESPONSE
[
'results'
]
for
program
in
programs
:
program
[
'display_category'
]
=
get_display_category
(
program
)
expected
=
[
programs
[
0
]]
enrollments
=
self
.
_create_enrollments
(
second_course_id
,
first_course_id
)
meter
=
utils
.
ProgramProgressMeter
(
self
.
user
,
enrollments
)
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
def
test_
get_engaged_programs_shared_enroll
ment
(
self
):
def
test_
shared_enrollment_engage
ment
(
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
)
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
))
)
programs
=
self
.
PROGRAMS_API_RESPONSE
[
'results'
]
for
program
in
programs
:
program
[
'display_category'
]
=
get_display_category
(
program
)
expected
=
programs
[
-
2
:]
@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
)
)
)
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
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
()
# 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
)
)
)
actual
=
get_engaged_programs
(
self
.
user
,
[])
expected
=
[]
# 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
)
)
)
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
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
=
[]
@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
)
self
.
assertEqual
(
expected
,
actual
)
enrollments
=
self
.
_create_enrollments
(
course_id
)
meter
=
utils
.
ProgramProgressMeter
(
self
.
user
,
enrollments
)
@httpretty.activate
def
test_get_display_category_success
(
self
):
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
))
mock_get_completed_courses
.
return_value
=
[
{
'course_id'
:
course_id
,
'mode'
:
MODES
.
honor
},
]
def
test_get_display_category_none
(
self
):
self
.
assertEqual
(
''
,
get_display_category
(
None
))
self
.
assertEqual
(
''
,
get_display_category
({
"id"
:
"test"
}))
program
=
data
[
0
]
self
.
_assert_progress
(
meter
,
factories
.
Progress
(
id
=
program
[
'id'
],
completed
=
self
.
_extract_names
(
program
,
0
))
)
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