Commit 9c81ba47 by Renzo Lucioni

Display programs from all categories on the student dashboard

Removes most remaining hardcoded references to XSeries from the LMS. Part of ECOM-4638.
parent 1c81bd14
......@@ -16,9 +16,8 @@ from openedx.core.lib.token_utils import JwtBuilder
class ProgramAuthoringView(View):
"""View rendering a template which hosts the Programs authoring app.
The Programs authoring app is a Backbone SPA maintained in a separate repository.
The app handles its own routing and provides a UI which can be used to create and
publish new Programs (e.g, XSeries).
The Programs authoring app is a Backbone SPA. The app handles its own routing
and provides a UI which can be used to create and publish new Programs.
"""
@method_decorator(login_required)
......
......@@ -120,8 +120,8 @@ from notification_prefs.views import enable_notifications
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.models import ProgramsApiConfig
from openedx.core.djangoapps.programs import utils as programs_utils
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
......@@ -609,10 +609,11 @@ def dashboard(request):
and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
)
# Get any programs associated with courses being displayed.
# This is passed along in the template context to allow rendering of
# program-related information on the dashboard.
course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments])
# Find programs associated with courses being displayed. This information
# is passed in the template context to allow rendering of program-related
# information on the dashboard.
meter = programs_utils.ProgramProgressMeter(user, enrollments=course_enrollments)
programs_by_run = meter.engaged_programs(by_run=True)
# Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict
......@@ -736,9 +737,9 @@ def dashboard(request):
'order_history_list': order_history_list,
'courses_requirements_not_met': courses_requirements_not_met,
'nav_hidden': True,
'course_programs': course_programs,
'disable_courseware_js': True,
'programs_by_run': programs_by_run,
'show_program_listing': ProgramsApiConfig.current().show_program_listing,
'disable_courseware_js': True,
}
ecommerce_service = EcommerceService()
......@@ -2478,44 +2479,6 @@ def change_email_settings(request):
return JsonResponse({"success": True})
def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invalid-name
"""Build a dictionary of program data required for display on the student dashboard.
Given a user and an iterable of course keys, find all programs relevant to the
user and return them in a dictionary keyed by course key.
Arguments:
user (User): The user to authenticate as when requesting programs.
user_enrolled_courses (list): List of course keys representing the courses in which
the given user has active enrollments.
Returns:
dict, containing programs keyed by course.
"""
course_programs = get_programs_for_dashboard(user, user_enrolled_courses)
programs_data = {}
for course_key, programs in course_programs.viewitems():
for program in programs:
if program.get('status') == 'active' and program.get('category') == 'XSeries':
try:
programs_for_course = programs_data.setdefault(course_key, {})
programs_for_course.setdefault('course_program_list', []).append({
'course_count': len(program['course_codes']),
'display_name': program['name'],
'program_id': program['id'],
'program_marketing_url': urljoin(
settings.MKTG_URLS.get('ROOT'),
'xseries' + '/{}'
).format(program['marketing_slug'])
})
programs_for_course['category'] = program.get('category')
except KeyError:
log.warning('Program structure is invalid, skipping display: %r', program)
return programs_data
class LogoutView(TemplateView):
"""
Logs out user and redirects.
......
"""Learner dashboard views"""
from urlparse import urljoin
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import Http404
......@@ -23,19 +20,13 @@ def program_listing(request):
raise Http404
meter = utils.ProgramProgressMeter(request.user)
programs = meter.engaged_programs
marketing_url = urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/')
for program in programs:
program['detail_url'] = utils.get_program_detail_url(program, marketing_url)
context = {
'credentials': get_programs_credentials(request.user),
'disable_courseware_js': True,
'marketing_url': marketing_url,
'marketing_url': utils.get_program_marketing_url(programs_config),
'nav_hidden': True,
'programs': programs,
'programs': meter.engaged_programs(),
'progress': meter.progress,
'show_program_listing': programs_config.show_program_listing,
'uses_pattern_library': True,
......
......@@ -20,17 +20,6 @@ var edx = edx || {};
return properties;
};
// Generate object to be passed with programs events
edx.dashboard.generateProgramProperties = function(element) {
var $el = $(element);
return {
category: 'dashboard',
course_id: $el.closest('.course-container').find('.info-course-id').html(),
program_id: $el.data('program-id')
};
};
// Emit an event when the 'course title link' is clicked.
edx.dashboard.trackCourseTitleClicked = function($courseTitleLink, properties) {
var trackProperty = properties || edx.dashboard.generateTrackProperties;
......@@ -92,24 +81,6 @@ var edx = edx || {};
);
};
// Emit an event when the 'View XSeries Details' button is clicked
edx.dashboard.trackXseriesBtnClicked = function($xseriesBtn, properties) {
var trackProperty = properties || edx.dashboard.generateProgramProperties;
window.analytics.trackLink(
$xseriesBtn,
'edx.bi.dashboard.xseries_cta_message.clicked',
trackProperty
);
};
edx.dashboard.xseriesTrackMessages = function() {
$('.xseries-action .btn').each(function(i, element) {
var data = edx.dashboard.generateProgramProperties($(element));
window.analytics.track('edx.bi.dashboard.xseries_cta_message.viewed', data);
});
};
$(document).ready(function() {
if (!window.analytics) {
return;
......@@ -120,7 +91,5 @@ var edx = edx || {};
edx.dashboard.trackCourseOptionDropdownClicked($('.wrapper-action-more'));
edx.dashboard.trackLearnVerifiedLinkClicked($('.verified-info'));
edx.dashboard.trackFindCourseBtnClicked($('.btn-find-courses'));
edx.dashboard.trackXseriesBtnClicked($('.xseries-action .btn'));
edx.dashboard.xseriesTrackMessages();
});
})(jQuery);
......@@ -27,10 +27,6 @@
<div class="course-container">
<div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">XSeries Program Course</p>
</div>
<div class="course honor">
<section class="details" aria-labelledby="course-details-heading">
<h2 class="hd hd-2 sr" id="course-details-heading">Course details</h2>
......@@ -96,32 +92,11 @@
</div>
</div>
</div>
<div class="message message-status is-shown credit-message">
<div class="xseries-action">
<div class="message-copy xseries-msg">
<p><b class="message-copy-bold">XSeries Program: Interested in more courses in this subject?</b></p>
<p></p>
<p class="message-copy">
This course is 1 of 3 courses in the <a href="https://www.edx.org/xseries/water-management">Water Management</a> XSeries.
</p>
</div>
<a class="btn xseries-border-btn" href="https://www.edx.org/xseries/water-management" target="_blank"
data-program-id="xseries007">
<span class="action-xseries-icon" aria-hidden="true"></span>
<span>View XSeries Details</span>
</a>
</div>
</div>
</ul>
</footer>
</div>
</div>
<div class="course-container">
<div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">XSeries Program Course</p>
</div>
<div class="course honor">
<div class="details">
<div class="wrapper-course-image" aria-hidden="true">
......@@ -186,22 +161,6 @@
</div>
</div>
</div>
<div class="message message-status is-shown credit-message">
<div class="xseries-action">
<div class="message-copy xseries-msg">
<p><b class="message-copy-bold">XSeries Program: Interested in more courses in this subject?</b></p>
<p class="message-copy">
This course is 1 of 3 courses in the <a href="https://www.edx.org/xseries/water-management">Water Management</a> XSeries.
</p>
</div>
<a class="btn xseries-border-btn" href="https://www.edx.org/xseries/water-management" target="_blank"
data-program-id="xseries007">
<span class="action-xseries-icon" aria-hidden="true"></span>
<span>View XSeries Details</span>
</a>
</div>
</div>
</ul>
</footer>
</div>
......
......@@ -92,31 +92,6 @@
property
);
});
it('sends an analytics event when the user clicks the \'View XSeries Details\' button', function() {
var $xseries = $('.xseries-action .btn');
window.edx.dashboard.trackXseriesBtnClicked(
$xseries,
window.edx.dashboard.generateProgramProperties);
expect(window.analytics.trackLink).toHaveBeenCalledWith(
$xseries,
'edx.bi.dashboard.xseries_cta_message.clicked',
window.edx.dashboard.generateProgramProperties
);
});
it('sends an analytics event when xseries messages are present in the DOM on page load', function() {
window.edx.dashboard.xseriesTrackMessages();
expect(window.analytics.track).toHaveBeenCalledWith(
'edx.bi.dashboard.xseries_cta_message.viewed',
{
category: 'dashboard',
course_id: 'CTB3365DWx',
program_id: 'xseries007'
}
);
});
});
});
}).call(this, window.define);
......@@ -3,8 +3,8 @@
// Uses the Pattern Library
@import 'elements/banners';
@import 'elements/program-card';
@import 'elements/course-card';
@import 'elements/icons';
@import 'views/program-list';
@import 'elements/program-card';
@import 'elements-v2/icons';
@import 'views/program-details';
@import 'views/program-list';
......@@ -13,11 +13,12 @@
@import 'base/base';
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/creative-commons';
@import 'elements/icons';
@import 'elements/navigation';
@import 'elements/pagination';
@import 'elements/creative-commons';
@import 'elements/typography';
// shared - course
@import 'shared/fields';
......
.xseries-icon {
background: url('#{$static-path}/images/programs/xseries-icon.svg') no-repeat;
}
.micromasters-icon {
margin-top: $baseline * 0.05;
background: url('#{$static-path}/images/programs/micromasters-icon.svg') no-repeat;
}
.certificate-body {
// Use the ampersand to reference parent selectors.
.certificate-icon & {
@include float(left);
@include margin-right($baseline*0.4);
margin-top: ($baseline/10);
width: 23px;
height: 20px;
padding: 2px;
background-color: $white;
border-style: solid;
border-width: 2px;
}
.green-icon & {
fill: palette(success, text);
border-color: palette(success, text);
}
.blue-icon & {
fill: palette(primary, dark);
border-color: palette(primary, dark);
}
}
.certificate-icon .certificate-body {
@include float(left);
@include margin-right($baseline*0.4);
margin-top: ($baseline/10);
width: 23px;
height: 20px;
padding: 2px;
background-color: $white;
border-style: solid;
border-width: 2px;
}
.green-certificate-icon .certificate-body {
fill: palette(success, accent);
border-color: palette(success, accent);
.xseries-icon {
background: url('#{$static-path}/images/programs/xseries-icon.svg') no-repeat;
}
.blue-certificate-icon .certificate-body {
fill: palette(primary, dark);
border-color: palette(primary, dark);
.micromasters-icon {
margin-top: $baseline * 0.05;
background: url('#{$static-path}/images/programs/micromasters-icon.svg') no-repeat;
}
......@@ -97,15 +97,6 @@
width: ($baseline*0.7);
height: ($baseline*0.7);
}
.xseries-icon{
background: url('#{$static-path}/images/programs/xseries-icon.svg') no-repeat;
}
.micromasters-icon{
margin-top: $baseline * 0.05;
background: url('#{$static-path}/images/programs/micromasters-icon.svg') no-repeat;
}
}
.hd-3 {
......
......@@ -62,32 +62,6 @@
}
}
.wrapper-xseries-certificates{
@include float(right);
@include margin-left(flex-gutter());
width: flex-grid(3);
.title{
@extend %t-title7;
@extend %t-weight4;
}
ul{
@include padding-left(0);
margin-top: ($baseline/2);
}
li{
@include line-height(20);
list-style-type: none;
}
.copy {
@extend %t-copy-sub1;
margin-top: ($baseline/2);
}
}
.profile-sidebar {
background: transparent;
@include float(right);
......@@ -304,31 +278,11 @@
border-bottom: 4px solid $border-color-l4;
padding-bottom: $baseline;
.course-container{
.course-container {
border: 1px solid $border-color-l4;
border-radius: 3px;
// CASE: Xseries associated course
.label-xseries-association{
@include margin($baseline/2, $baseline/5, 0, $baseline/2);
.xseries-icon{
@include float(left);
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
.message-copy{
padding-top: ($baseline/5);
@extend %t-action3;
}
}
}
&:last-child {
margin-bottom: 0;
border-bottom: none;
......@@ -860,100 +814,6 @@
}
}
.xseries-action{
.xseries-msg{
@include float(left);
width: flex-grid(9, 12);
}
.message-copy{
@extend %t-demi-strong;
margin-top: 0;
}
.message-copy-bold{
@extend %t-strong;
}
.xseries-border-btn {
@extend %btn-pl-black-border;
@include float(right);
position: relative;
@include left(10px);
padding: ($baseline*0.4) ($baseline*0.6);
background-image: none ;
text-shadow: none;
box-shadow: none;
text-transform: none;
.action-xseries-icon{
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
&:hover,
&:focus {
.action-xseries-icon{
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-white.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
}
}
.xseries-base-btn {
@extend %btn-pl-black-base;
@include float(right);
position: relative;
@include left(10px);
padding: ($baseline*0.4) ($baseline*0.6);
background-image: none ;
text-shadow: none;
box-shadow: none;
text-transform: none;
.action-xseries-icon{
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-white.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
&:hover,
&:focus {
.action-xseries-icon {
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
}
}
}
.actions {
.action {
......@@ -1129,6 +989,46 @@
}
}
&.message-related-programs {
background: none;
border: none;
margin-top: ($baseline/4);
padding-bottom: 0;
.related-programs-preface {
@include float(left);
font-weight: bold;
}
ul {
display: inline;
padding: 0;
margin: 0;
}
li {
@include float(left);
display: inline;
padding: 0 0.5em;
border-right: 1px solid;
.category-icon {
@include float(left);
@include margin-right($baseline/4);
margin-top: ($baseline/10);
background-color: transparent;
background-size: 100%;
width: ($baseline*0.7);
height: ($baseline*0.7);
}
}
// Remove separator from last list item.
li:last-child {
border: 0;
}
}
// TYPE: pre-requisites
.prerequisites {
@include clearfix;
......
......@@ -98,8 +98,8 @@ from openedx.core.djangolib.markup import HTML, Text
<% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" />
<% related_programs = programs_by_run.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs" />
% endfor
</ul>
......
<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, course_program_info" expression_filter="h"/>
<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs" expression_filter="h"/>
<%!
import urllib
......@@ -53,12 +53,6 @@ from student.helpers import (
<% mode_class = '' %>
% endif
<div class="course-container">
% if course_program_info and course_program_info.get('category')=='XSeries':
<div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">${_("{category} Program Course").format(category=course_program_info['category'])}</p>
</div>
% endif
<article class="course${mode_class}">
<% course_target = reverse('info', args=[unicode(course_overview.id)]) %>
<section class="details" aria-labelledby="details-heading-${course_overview.number}">
......@@ -283,6 +277,20 @@ from student.helpers import (
</section>
<footer class="wrapper-messages-primary">
<ul class="messages-list">
% if related_programs:
<div class="message message-related-programs is-shown">
<span class="related-programs-preface">${_('Related Programs')}:</span>
<ul>
% for program in related_programs:
<li>
<span class="category-icon ${program['category'].lower()}-icon" aria-hidden="true"></span>
<span><a href="${program['detail_url']}">${'{name} {category}'.format(name=program['name'], category=program['category'])}</a></span>
</li>
% endfor
</ul>
</div>
% endif
% if course_overview.may_certify() and cert_status:
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course_overview=course_overview, enrollment=enrollment, reverify_link=reverify_link'/>
% endif
......
<%page expression_filter="h" args="program_data, enrollment_mode, category" />
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%namespace name='static' file='../static_content.html'/>
<div class="message message-status is-shown credit-message">
<div class="xseries-action">
<div class="message-copy xseries-msg">
<p class="message-copy-bold">
${_("{category} Program: Interested in more courses in this subject?").format(category=category)}
</p>
<p class="message-copy">
${Text(_("This course is 1 of {course_count} courses in the {link_start}{program_display_name}{link_end} {program_category}.")).format(
course_count=program_data['course_count'],
link_start=HTML('<a href="{}">').format(program_data['program_marketing_url']),
link_end=HTML('</a>'),
program_display_name=program_data['display_name'],
program_category=category,
)}
</p>
</div>
<%
xseries_btn_class = "xseries-border-btn"
if enrollment_mode == "verified":
xseries_btn_class = "xseries-base-btn";
%>
<a class="btn ${xseries_btn_class}" href="${program_data['program_marketing_url']}" target="_blank"
data-program-id="${program_data['program_id']}" >
<span class="sr">${program_data['display_name']}</span>
<span class="action-xseries-icon" aria-hidden="true"></span>
${_("View {category} Details").format(category=category)}
</a>
</div>
</div>
<div class="message col-12 md-col-8">
<% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon green-certificate-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="certificate-icon green-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="card-msg"><%- gettext('Congratulations! You have earned a certificate for this course.') %></span>
</div>
<div class="action col-12 md-col-4">
<a href="<%- certificate_url %>" class="btn-brand cta-secondary">
<% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon blue-certificate-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="certificate-icon blue-icon" aria-hidden="true"><%= certificateSvg %></span>
<%- gettext('View Certificate') %>
</a>
</div>
<div class="message col-12 md-col-8">
<% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon green-certificate-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="certificate-icon green-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="card-msg"><%- gettext('You need a certificate in this course to be eligible for a program certificate.') %></span>
</div>
<div class="action col-12 md-col-4">
<a href="<%- upgrade_url %>" class="btn-brand cta-primary">
<% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon green-certificate-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="certificate-icon green-icon" aria-hidden="true"><%= certificateSvg %></span>
<%- gettext('Upgrade Now') %>
</a>
</div>
......@@ -7,6 +7,7 @@ from django.db import models
from config_models.models import ConfigurationModel
# TODO: To be simplified as part of ECOM-5136.
class ProgramsApiConfig(ConfigurationModel):
"""
Manages configuration for connecting to the Programs service and using its
......@@ -29,7 +30,6 @@ class ProgramsApiConfig(ConfigurationModel):
)
)
# TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995
authoring_app_js_path = models.CharField(
verbose_name=_("Path to authoring app's JS"),
max_length=255,
......@@ -39,7 +39,6 @@ class ProgramsApiConfig(ConfigurationModel):
)
)
# TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995
authoring_app_css_path = models.CharField(
verbose_name=_("Path to authoring app's CSS"),
max_length=255,
......@@ -81,7 +80,6 @@ class ProgramsApiConfig(ConfigurationModel):
)
)
# TODO: Remove unused field.
xseries_ad_enabled = models.BooleanField(
verbose_name=_("Do we want to show xseries program advertising"),
default=False
......@@ -117,14 +115,6 @@ class ProgramsApiConfig(ConfigurationModel):
return self.cache_ttl > 0
@property
def is_student_dashboard_enabled(self):
"""
Indicates whether LMS dashboard functionality related to Programs should
be enabled or not.
"""
return self.enabled and self.enable_student_dashboard
@property
def is_studio_tab_enabled(self):
"""
Indicates whether Studio functionality related to Programs should
......
......@@ -14,7 +14,7 @@ class Program(factory.Factory):
name = FuzzyText(prefix='Program ')
subtitle = FuzzyText(prefix='Subtitle ')
category = 'FooBar'
status = 'unpublished'
status = 'active'
marketing_slug = FuzzyText(prefix='slug_')
organizations = []
course_codes = []
......
......@@ -16,7 +16,6 @@ class ProgramsApiConfigMixin(object):
'internal_service_url': 'http://internal.programs.org/',
'public_service_url': 'http://public.programs.org/',
'cache_ttl': 0,
'enable_student_dashboard': True,
'enable_studio_tab': True,
'enable_certification': True,
'program_listing_enabled': True,
......
......@@ -36,20 +36,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
programs_config = self.create_programs_config(cache_ttl=cache_ttl)
self.assertEqual(programs_config.is_cache_enabled, is_cache_enabled)
def test_is_student_dashboard_enabled(self, _mock_cache):
"""
Verify that the property controlling display on the student dashboard is only True
when configuration is enabled and all required configuration is provided.
"""
programs_config = self.create_programs_config(enabled=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
programs_config = self.create_programs_config(enable_student_dashboard=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
programs_config = self.create_programs_config()
self.assertTrue(programs_config.is_student_dashboard_enabled)
def test_is_studio_tab_enabled(self, _mock_cache):
"""
Verify that the property controlling display of the Studio tab is only True
......
......@@ -12,9 +12,11 @@ from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import timezone
from django.utils.text import slugify
import httpretty
import mock
from nose.plugins.attrib import attr
from opaque_keys.edx.keys import CourseKey
from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
......@@ -141,36 +143,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
actual = utils.get_programs(self.user)
self.assertEqual(actual, [])
def test_get_programs_for_dashboard(self):
"""Verify programs data can be retrieved and parsed correctly."""
self.create_programs_config()
self.mock_programs_api()
actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS)
expected = {}
for program in self.PROGRAMS_API_RESPONSE['results']:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
course_key = run['course_key']
expected.setdefault(course_key, []).append(program)
self.assertEqual(actual, expected)
def test_get_programs_for_dashboard_dashboard_display_disabled(self):
"""Verify behavior when student dashboard display is disabled."""
self.create_programs_config(enable_student_dashboard=False)
actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
def test_get_programs_for_dashboard_no_data(self):
"""Verify behavior when no programs data is found for the user."""
self.create_programs_config()
self.mock_programs_api(data={'results': []})
actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
def test_get_program_for_certificates(self):
"""Verify programs data can be retrieved and parsed correctly for certificates."""
self.create_programs_config()
......@@ -219,6 +191,78 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class GetProgramsByRunTests(TestCase):
"""Tests verifying that programs are inverted correctly."""
maxDiff = None
@classmethod
def setUpClass(cls):
super(GetProgramsByRunTests, cls).setUpClass()
cls.user = UserFactory()
course_keys = [
CourseKey.from_string('some/course/run'),
CourseKey.from_string('some/other/run'),
]
cls.enrollments = [CourseEnrollmentFactory(user=cls.user, course_id=c) for c in course_keys]
cls.course_ids = [unicode(c) for c in course_keys]
organization = factories.Organization()
joint_programs = sorted([
factories.Program(
organizations=[organization],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=cls.course_ids[0]),
]),
]
) for __ in range(2)
], key=lambda p: p['name'])
cls.programs = joint_programs + [
factories.Program(
organizations=[organization],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=cls.course_ids[1]),
]),
]
),
factories.Program(
organizations=[organization],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key='yet/another/run'),
]),
]
),
]
def test_get_programs_by_run(self):
"""Verify that programs are organized by run ID."""
programs_by_run, course_ids = utils.get_programs_by_run(self.programs, self.enrollments)
self.assertEqual(programs_by_run[self.course_ids[0]], self.programs[:2])
self.assertEqual(programs_by_run[self.course_ids[1]], self.programs[2:3])
self.assertEqual(course_ids, self.course_ids)
def test_no_programs(self):
"""Verify that the utility can cope with missing programs data."""
programs_by_run, course_ids = utils.get_programs_by_run([], self.enrollments)
self.assertEqual(programs_by_run, {})
self.assertEqual(course_ids, self.course_ids)
def test_no_enrollments(self):
"""Verify that the utility can cope with missing enrollment data."""
programs_by_run, course_ids = utils.get_programs_by_run(self.programs, [])
self.assertEqual(programs_by_run, {})
self.assertEqual(course_ids, [])
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class GetCompletedCoursesTestCase(TestCase):
"""
Test the get_completed_courses function
......@@ -297,6 +341,14 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
"""Construct a list containing the display names of the indicated course codes."""
return [program['course_codes'][cc]['display_name'] for cc in course_codes]
def _attach_detail_url(self, programs):
"""Add expected detail URLs to a list of program dicts."""
for program in programs:
base = reverse('program_details_view', kwargs={'program_id': program['id']}).rstrip('/')
slug = slugify(program['name'])
program['detail_url'] = '{base}/{slug}'.format(base=base, slug=slug)
def test_no_enrollments(self):
"""Verify behavior when programs exist, but no relevant enrollments do."""
data = [
......@@ -311,7 +363,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
meter = utils.ProgramProgressMeter(self.user)
self.assertEqual(meter.engaged_programs, [])
self.assertEqual(meter.engaged_programs(), [])
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
......@@ -322,7 +374,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments('org/course/run')
meter = utils.ProgramProgressMeter(self.user)
self.assertEqual(meter.engaged_programs, [])
self.assertEqual(meter.engaged_programs(), [])
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
......@@ -353,8 +405,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments(course_id)
meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
program = data[0]
self.assertEqual(meter.engaged_programs, [program])
self.assertEqual(meter.engaged_programs(), [program])
self._assert_progress(
meter,
factories.Progress(
......@@ -399,8 +452,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments(second_course_id, first_course_id)
meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
programs = data[:2]
self.assertEqual(meter.engaged_programs, programs)
self.assertEqual(meter.engaged_programs(), programs)
self._assert_progress(
meter,
factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)),
......@@ -414,15 +468,8 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
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),
]),
]
),
joint_programs = sorted([
factories.Program(
organizations=[factories.Organization()],
course_codes=[
......@@ -430,7 +477,10 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
factories.RunMode(course_key=shared_course_id),
]),
]
),
) for __ in range(2)
], key=lambda p: p['name'])
data = joint_programs + [
factories.Program(
organizations=[factories.Organization()],
course_codes=[
......@@ -446,14 +496,16 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
]
),
]
self._mock_programs_api(data)
# Enrollment for the shared course ID created last (most recently).
self._create_enrollments(solo_course_id, shared_course_id)
meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
programs = data[:3]
self.assertEqual(meter.engaged_programs, programs)
self.assertEqual(meter.engaged_programs(), programs)
self._assert_progress(
meter,
factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)),
......
......@@ -2,10 +2,11 @@
"""Helper functions for working with Programs."""
import datetime
import logging
from urlparse import urljoin
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify
from opaque_keys.edx.keys import CourseKey
import pytz
......@@ -52,63 +53,6 @@ def get_programs(user, program_id=None):
return get_edx_api_data(programs_config, user, 'programs', resource_id=program_id, cache_key=cache_key)
def flatten_programs(programs, course_ids):
"""Flatten the result returned by the Programs API.
Arguments:
programs (list): Serialized programs
course_ids (list): Course IDs to key on.
Returns:
dict, programs keyed by course ID
"""
flattened = {}
for program in programs:
try:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
run_id = run['course_key']
if run_id in course_ids:
flattened.setdefault(run_id, []).append(program)
except KeyError:
log.exception('Unable to parse Programs API response: %r', program)
return flattened
def get_programs_for_dashboard(user, course_keys):
"""Build a dictionary of programs, keyed by course.
Given a user and an iterable of course keys, find all the programs relevant
to the user's dashboard and return them in a dictionary keyed by course key.
Arguments:
user (User): The user to authenticate as when requesting programs.
course_keys (list): List of course keys representing the courses in which
the given user has active enrollments.
Returns:
dict, containing programs keyed by course. Empty if programs cannot be retrieved.
"""
programs_config = ProgramsApiConfig.current()
course_programs = {}
if not programs_config.is_student_dashboard_enabled:
log.debug('Display of programs on the student dashboard is disabled.')
return course_programs
programs = get_programs(user)
if not programs:
log.debug('No programs found for the user with ID %d.', user.id)
return course_programs
course_ids = [unicode(c) for c in course_keys]
course_programs = flatten_programs(programs, course_ids)
return course_programs
def get_programs_for_credentials(user, programs_credentials):
""" Given a user and an iterable of credentials, get corresponding programs
data and return it as a list of dictionaries.
......@@ -137,24 +81,71 @@ def get_programs_for_credentials(user, programs_credentials):
return certificate_programs
def get_program_detail_url(program, marketing_root):
"""Construct the URL to be used when linking to program details.
def get_programs_by_run(programs, enrollments):
"""Intersect programs and enrollments.
Builds a dictionary of program dict lists keyed by course ID. The resulting dictionary
is suitable for use in applications where programs must be filtered by the course
runs they contain (e.g., student dashboard).
Arguments:
programs (list): Containing dictionaries representing programs.
enrollments (list): Enrollments from which course IDs to key on can be extracted.
Returns:
tuple, dict of programs keyed by course ID and list of course IDs themselves
"""
programs_by_run = {}
# enrollment.course_id is really a course key (╯ಠ_ಠ)╯︵ ┻━┻
course_ids = [unicode(e.course_id) for e in enrollments]
for program in programs:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
run_id = run['course_key']
if run_id in course_ids:
program_list = programs_by_run.setdefault(run_id, list())
if program not in program_list:
program_list.append(program)
# Sort programs by name for consistent presentation.
for program_list in programs_by_run.itervalues():
program_list.sort(key=lambda p: p['name'])
return programs_by_run, course_ids
def get_program_marketing_url(programs_config):
"""Build a URL to be used when linking to program details on a marketing site."""
return urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/')
def attach_program_detail_url(programs):
"""Extend program representations by attaching a URL to be used when linking to program details.
Facilitates the building of context to be passed to templates containing program data.
Arguments:
program (dict): Representation of a program.
marketing_root (str): Root URL used to build links to program marketing pages.
programs (list): Containing dicts representing programs.
Returns:
str, a link to program details
list, containing extended program dicts
"""
if ProgramsApiConfig.current().show_program_details:
programs_config = ProgramsApiConfig.current()
marketing_url = get_program_marketing_url(programs_config)
for program in programs:
if programs_config.show_program_details:
base = reverse('program_details_view', kwargs={'program_id': program['id']}).rstrip('/')
slug = slugify(program['name'])
else:
base = marketing_root.rstrip('/')
# TODO: Remove. Learners should always be sent to the LMS' program details page.
base = marketing_url
slug = program['marketing_slug']
return '{base}/{slug}'.format(base=base, slug=slug)
program['detail_url'] = '{base}/{slug}'.format(base=base, slug=slug)
return programs
def get_completed_courses(student):
......@@ -182,35 +173,40 @@ class ProgramProgressMeter(object):
Arguments:
user (User): The user for which to find programs.
Keyword Arguments:
enrollments (list): List of the user's enrollments.
"""
def __init__(self, user):
def __init__(self, user, enrollments=None):
self.user = user
self.enrollments = enrollments
self.course_ids = None
self.course_certs = None
self.programs = get_programs(self.user)
self.course_certs = get_completed_courses(self.user)
self.programs = attach_program_detail_url(get_programs(self.user))
@cached_property
def engaged_programs(self):
def engaged_programs(self, by_run=False):
"""Derive a list of programs in which the given user is engaged.
Returns:
list of program dicts, ordered by most recent enrollment.
list of program dicts, ordered by most recent enrollment,
or dict of programs, keyed by course ID.
"""
enrollments = CourseEnrollment.enrollments_for_user(self.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]
self.enrollments = self.enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
self.enrollments.sort(key=lambda e: e.created, reverse=True)
programs_by_run, self.course_ids = get_programs_by_run(self.programs, self.enrollments)
flattened = flatten_programs(self.programs, self.course_ids)
if by_run:
return programs_by_run
engaged_programs = []
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)
for program in programs_by_run.get(course_id, []):
if program not in programs:
programs.append(program)
return engaged_programs
return programs
@property
def progress(self):
......@@ -221,7 +217,7 @@ class ProgramProgressMeter(object):
towards completing a program.
"""
progress = []
for program in self.engaged_programs:
for program in self.engaged_programs():
completed, in_progress, not_started = [], [], []
for course_code in program['course_codes']:
......@@ -277,6 +273,8 @@ class ProgramProgressMeter(object):
Returns:
bool, whether the course code is complete.
"""
self.course_certs = self.course_certs or get_completed_courses(self.user)
return any(self._parse(run_mode) in self.course_certs for run_mode in course_code['run_modes'])
def _is_course_code_in_progress(self, course_code):
......
......@@ -1877,6 +1877,7 @@ class TestGoogleRegistrationView(
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase):
"""Tests the UpdateEmailOptInPreference view. """
......
......@@ -99,8 +99,8 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
<% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" />
<% related_programs = programs_by_run.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs" />
% endfor
</ul>
......
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