Display programs from all categories on the student dashboard

Removes most remaining hardcoded references to XSeries from the LMS. Part of ECOM-4638.
This commit is contained in:
Renzo Lucioni
2016-08-02 18:32:55 -04:00
parent 1c81bd146d
commit 9c81ba47e2
26 changed files with 416 additions and 828 deletions

View File

@@ -2,53 +2,56 @@
"""
Miscellaneous tests for the student app.
"""
from datetime import datetime, timedelta
import json
import logging
import unittest
import ddt
from datetime import datetime, timedelta
from urlparse import urljoin
import pytz
from markupsafe import escape
from mock import Mock, patch
from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from pyquery import PyQuery as pq
import ddt
from django.conf import settings
from django.contrib.auth.models import User, AnonymousUser
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import Client
from edx_oauth2_provider.tests.factories import ClientFactory
import httpretty
from markupsafe import escape
from mock import Mock, patch
from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from provider.constants import CONFIDENTIAL
from pyquery import PyQuery as pq
import pytz
from bulk_email.models import Optout # pylint: disable=import-error
from certificates.models import CertificateStatuses # pylint: disable=import-error
from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
from config_models.models import cache
from course_modes.models import CourseMode
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests import factories as programs_factories
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
import shoppingcart # pylint: disable=import-error
from student.models import (
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment,
unique_id_for_user, LinkedInAddToProfileConfiguration, UserAttribute
)
from student.tests.factories import UserFactory, CourseModeFactory, CourseEnrollmentFactory
from student.views import (
process_survey_link,
_cert_info,
complete_course_mode_info,
_get_course_programs
)
from student.tests.factories import UserFactory, CourseModeFactory
from util.testing import EventTestMixin
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase,
ModuleStoreEnum,
SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ModuleStoreEnum
# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
from bulk_email.models import Optout # pylint: disable=import-error
from certificates.models import CertificateStatuses # pylint: disable=import-error
from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
import shoppingcart # pylint: disable=import-error
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache
log = logging.getLogger(__name__)
@@ -889,276 +892,95 @@ class AnonymousLookupTable(ModuleStoreTestCase):
self.assertEqual(anonymous_id, anonymous_id_for_user(self.user, course2.id, save=False))
# TODO: Clean up these tests so that they use program factories and don't mention XSeries!
@attr(shard=3)
@httpretty.activate
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
"""
Tests for dashboard for xseries program courses. Enroll student into
programs and then try different combinations to see xseries upsell
messages are appearing.
"""
class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Tests verifying that related programs appear on the course dashboard."""
url = None
maxDiff = None
password = 'test'
related_programs_preface = 'Related Programs'
@classmethod
def setUpClass(cls):
super(RelatedProgramsTests, cls).setUpClass()
cls.user = UserFactory()
cls.course = CourseFactory()
cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
cls.organization = programs_factories.Organization()
run_mode = programs_factories.RunMode(course_key=unicode(cls.course.id)) # pylint: disable=no-member
course_code = programs_factories.CourseCode(run_modes=[run_mode])
cls.programs = [
programs_factories.Program(
organizations=[cls.organization],
course_codes=[course_code]
) for __ in range(2)
]
def setUp(self):
super(DashboardTestXSeriesPrograms, self).setUp()
super(RelatedProgramsTests, self).setUp()
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
self.course_1 = CourseFactory.create()
self.course_2 = CourseFactory.create()
self.course_3 = CourseFactory.create()
self.program_name = 'Testing Program'
self.category = 'XSeries'
self.url = reverse('dashboard')
CourseModeFactory.create(
course_id=self.course_1.id,
mode_slug='verified',
mode_display_name='Verified',
expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1)
self.create_programs_config()
self.client.login(username=self.user.username, password=self.password)
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 assert_related_programs(self, response, are_programs_present=True):
"""Assertion for verifying response contents."""
assertion = getattr(self, 'assert{}Contains'.format('' if are_programs_present else 'Not'))
for program in self.programs:
assertion(response, self.expected_link_text(program))
assertion(response, self.related_programs_preface)
def expected_link_text(self, program):
"""Construct expected dashboard link text."""
return '{name} {category}'.format(name=program['name'], category=program['category'])
def test_related_programs_listed(self):
"""Verify that related programs are listed when the programs API returns data."""
self.mock_programs_api(self.programs)
response = self.client.get(self.url)
self.assert_related_programs(response)
def test_no_data_no_programs(self):
"""Verify that related programs aren't listed if the programs API returns no data."""
self.mock_programs_api([])
response = self.client.get(self.url)
self.assert_related_programs(response, are_programs_present=False)
def test_unrelated_program_not_listed(self):
"""Verify that unrelated programs don't appear in the listing."""
run_mode = programs_factories.RunMode(course_key='some/nonexistent/run')
course_code = programs_factories.CourseCode(run_modes=[run_mode])
unrelated_program = programs_factories.Program(
organizations=[self.organization],
course_codes=[course_code]
)
self.client = Client()
cache.clear()
def _create_program_data(self, data):
"""Dry method to create testing programs data."""
programs = {}
_id = 0
self.mock_programs_api(self.programs + [unrelated_program])
for course, program_status in data:
programs[unicode(course)] = [{
'id': _id,
'category': self.category,
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-1',
'status': program_status,
'course_codes': [
{
'display_name': 'Demo XSeries Program 1',
'key': unicode(course),
'run_modes': [{'sku': '', 'mode_slug': 'ABC', 'course_key': unicode(course)}]
},
{
'display_name': 'Demo XSeries Program 2',
'key': 'edx/demo/course_2',
'run_modes': [{'sku': '', 'mode_slug': 'ABC', 'course_key': 'edx/demo/course_2'}]
},
{
'display_name': 'Demo XSeries Program 3',
'key': 'edx/demo/course_3',
'run_modes': [{'sku': '', 'mode_slug': 'ABC', 'course_key': 'edx/demo/course_3'}]
}
],
'subtitle': 'sub',
'name': self.program_name
}]
_id += 1
return programs
@ddt.data(
('active', [{'sku': ''}, {'sku': ''}, {'sku': ''}, {'sku': ''}], 'marketing-slug-1'),
('active', [{'sku': ''}, {'sku': ''}, {'sku': ''}], 'marketing-slug-2'),
('active', [], ''),
('unpublished', [{'sku': ''}, {'sku': ''}, {'sku': ''}, {'sku': ''}], 'marketing-slug-3'),
)
@ddt.unpack
def test_get_xseries_programs_method(self, program_status, course_codes, marketing_slug):
"""Verify that program data is parsed correctly for a given course"""
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = {
u'edx/demox/Run_1': [{
'id': 0,
'category': self.category,
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': marketing_slug,
'status': program_status,
'course_codes': course_codes,
'subtitle': 'sub',
'name': self.program_name
}]
}
parse_data = _get_course_programs(
self.user, [
u'edx/demox/Run_1', u'valid/edX/Course'
]
)
if program_status == 'unpublished':
self.assertEqual({}, parse_data)
else:
self.assertEqual(
{
u'edx/demox/Run_1': {
'category': self.category,
'course_program_list': [{
'program_id': 0,
'course_count': len(course_codes),
'display_name': self.program_name,
'program_marketing_url': urljoin(
settings.MKTG_URLS.get('ROOT'), 'xseries' + '/{}'
).format(marketing_slug)
}]
}
},
parse_data
)
def test_program_courses_on_dashboard_without_configuration(self):
"""If programs configuration is disabled then the xseries upsell messages
will not appear on student dashboard.
"""
CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test")
with patch('student.views.get_programs_for_dashboard') as mock_method:
mock_method.return_value = self._create_program_data([])
response = self.client.get(reverse('dashboard'))
self.assertEquals(response.status_code, 200)
self.assertIn('Pursue a Certificate of Achievement to highlight', response.content)
self._assert_responses(response, 0)
@ddt.data('verified', 'honor')
def test_modes_program_courses_on_dashboard_with_configuration(self, course_mode):
"""Test that if program configuration is enabled than student can only
see those courses with xseries upsell messages which are active in
xseries programs.
"""
CourseEnrollment.enroll(self.user, self.course_1.id, mode=course_mode)
CourseEnrollment.enroll(self.user, self.course_2.id, mode=course_mode)
self.client.login(username="jack", password="test")
self.create_programs_config()
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = self._create_program_data(
[(self.course_1.id, 'active'), (self.course_2.id, 'unpublished')]
)
response = self.client.get(reverse('dashboard'))
# count total courses appearing on student dashboard
self.assertContains(response, 'course-container', 2)
self._assert_responses(response, 1)
# for verified enrollment view the program detail button will have
# the class 'base-btn'
# for other modes view the program detail button will have have the
# class border-btn
if course_mode == 'verified':
self.assertIn('xseries-base-btn', response.content)
else:
self.assertIn('xseries-border-btn', response.content)
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
@ddt.data((-2, -1), (-1, 1), (1, 2))
@ddt.unpack
def test_start_end_offsets(self, start_days_offset, end_days_offset):
"""Test that the xseries upsell messaging displays whether the course
has not yet started, is in session, or has already ended.
"""
self.course_1.start = datetime.now(pytz.UTC) + timedelta(days=start_days_offset)
self.course_1.end = datetime.now(pytz.UTC) + timedelta(days=end_days_offset)
self.update_course(self.course_1, self.user.id)
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
self.client.login(username="jack", password="test")
self.create_programs_config()
with patch(
'student.views.get_programs_for_dashboard',
return_value=self._create_program_data([(self.course_1.id, 'active')])
) as mock_get_programs:
response = self.client.get(reverse('dashboard'))
# ensure that our course id was included in the API call regardless of start/end dates
__, course_ids = mock_get_programs.call_args[0]
self.assertEqual(list(course_ids), [self.course_1.id])
# count total courses appearing on student dashboard
self._assert_responses(response, 1)
@ddt.data(
('unpublished', 'unpublished', 'unpublished', 0),
('active', 'unpublished', 'unpublished', 1),
('active', 'active', 'unpublished', 2),
('active', 'active', 'active', 3),
)
@ddt.unpack
def test_different_programs_on_dashboard(self, status_1, status_2, status_3, program_count):
"""Test the upsell on student dashboard with different programs
statuses.
"""
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
CourseEnrollment.enroll(self.user, self.course_2.id, mode='honor')
CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor')
self.client.login(username="jack", password="test")
self.create_programs_config()
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = self._create_program_data(
[(self.course_1.id, status_1),
(self.course_2.id, status_2),
(self.course_3.id, status_3)]
)
response = self.client.get(reverse('dashboard'))
# count total courses appearing on student dashboard
self.assertContains(response, 'course-container', 3)
self._assert_responses(response, program_count)
@patch('student.views.log.warning')
@ddt.data('', 'course_codes', 'marketing_slug', 'name')
def test_program_courses_with_invalid_data(self, key_remove, log_warn):
"""Test programs with invalid responses."""
CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test")
self.create_programs_config()
program_data = self._create_program_data([(self.course_1.id, 'active')])
for program in program_data[unicode(self.course_1.id)]:
if key_remove and key_remove in program:
del program[key_remove]
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = program_data
response = self.client.get(reverse('dashboard'))
# if data is invalid then warning log will be recorded.
if key_remove:
log_warn.assert_called_with(
'Program structure is invalid, skipping display: %r', program_data[
unicode(self.course_1.id)
][0]
)
# verify that no programs related upsell messages appear on the
# student dashboard.
self._assert_responses(response, 0)
else:
# in case of valid data all upsell messages will appear on dashboard.
self._assert_responses(response, 1)
# verify that only normal courses (non-programs courses) appear on
# the student dashboard.
self.assertContains(response, 'course-container', 1)
self.assertIn('Pursue a Certificate of Achievement to highlight', response.content)
def _assert_responses(self, response, count):
"""Dry method to compare different programs related upsell messages,
classes.
"""
self.assertContains(response, 'label-xseries-association', count)
self.assertContains(response, 'btn xseries-', count)
self.assertContains(response, '{category} Program Course'.format(category=self.category), count)
self.assertContains(
response,
'{category} Program: Interested in more courses in this subject?'.format(category=self.category),
count
)
self.assertContains(response, 'View {category} Details'.format(category=self.category), count)
self.assertContains(response, 'This course is 1 of 3 courses in the', count)
self.assertContains(response, self.program_name, count * 2)
response = self.client.get(self.url)
self.assert_related_programs(response)
self.assertNotContains(response, unrelated_program['name'])
class UserAttributeTests(TestCase):

View File

@@ -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.