Files
edx-platform/common/djangoapps/student/tests/test_views.py

579 lines
27 KiB
Python

"""
Test the student dashboard view.
"""
import datetime
import itertools
import json
import unittest
import ddt
import pytz
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import RequestFactory, TestCase
from django.test.utils import override_settings
from edx_oauth2_provider.constants import AUTHORIZED_CLIENTS_SESSION_KEY
from edx_oauth2_provider.tests.factories import ClientFactory, TrustedClientFactory
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import patch
from opaque_keys import InvalidKeyError
from pyquery import PyQuery as pq
from bulk_email.models import BulkEmailFlag
from entitlements.tests.factories import CourseEntitlementFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.cookies import get_user_info_cookie_data
from student.helpers import DISABLE_UNENROLL_CERT_STATES
from student.models import CourseEnrollment, UserProfile
from student.signals import REFUND_ORDER
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.milestones_helpers import get_course_milestones, remove_prerequisite_course, set_prerequisite_courses
from util.testing import UrlResetMixin
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
PASSWORD = 'test'
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase):
"""
Test to ensure that the student dashboard does not show the unenroll button for users with certificates.
"""
UNENROLL_ELEMENT_ID = "#actions-item-unenroll-0"
@classmethod
def setUpClass(cls):
super(TestStudentDashboardUnenrollments, cls).setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
""" Create a course and user, then log in. """
super(TestStudentDashboardUnenrollments, self).setUp()
self.user = UserFactory()
self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
self.cert_status = 'processing'
self.client.login(username=self.user.username, password=PASSWORD)
def mock_cert(self, _user, _course_overview, _course_mode):
""" Return a preset certificate status. """
return {
'status': self.cert_status,
'can_unenroll': self.cert_status not in DISABLE_UNENROLL_CERT_STATES,
'download_url': 'fake_url',
'linked_in_url': False,
'grade': 100,
'show_survey_button': False
}
@ddt.data(
('notpassing', 1),
('restricted', 1),
('processing', 1),
('generating', 0),
('downloadable', 0),
)
@ddt.unpack
def test_unenroll_available(self, cert_status, unenroll_action_count):
""" Assert that the unenroll action is shown or not based on the cert status."""
self.cert_status = cert_status
with patch('student.views.cert_info', side_effect=self.mock_cert):
response = self.client.get(reverse('dashboard'))
self.assertEqual(pq(response.content)(self.UNENROLL_ELEMENT_ID).length, unenroll_action_count)
@ddt.data(
('notpassing', 200),
('restricted', 200),
('processing', 200),
('generating', 400),
('downloadable', 400),
)
@ddt.unpack
@patch.object(CourseEnrollment, 'unenroll')
def test_unenroll_request(self, cert_status, status_code, course_enrollment):
""" Assert that the unenroll method is called or not based on the cert status"""
self.cert_status = cert_status
with patch('student.views.cert_info', side_effect=self.mock_cert):
with patch('lms.djangoapps.commerce.signals.handle_refund_order') as mock_refund_handler:
REFUND_ORDER.connect(mock_refund_handler)
response = self.client.post(
reverse('change_enrollment'),
{'enrollment_action': 'unenroll', 'course_id': self.course.id}
)
self.assertEqual(response.status_code, status_code)
if status_code == 200:
course_enrollment.assert_called_with(self.user, self.course.id)
self.assertTrue(mock_refund_handler.called)
else:
course_enrollment.assert_not_called()
def test_cant_unenroll_status(self):
""" Assert that the dashboard loads when cert_status does not allow for unenrollment"""
with patch('certificates.models.certificate_status_for_student', return_value={'status': 'downloadable'}):
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 200)
def test_course_run_refund_status_successful(self):
""" Assert that view:course_run_refund_status returns correct Json for successful refund call."""
with patch('student.models.CourseEnrollment.refundable', return_value=True):
response = self.client.get(reverse('course_run_refund_status', kwargs={'course_id': self.course.id}))
self.assertEquals(json.loads(response.content), {'course_refundable_status': True})
self.assertEqual(response.status_code, 200)
with patch('student.models.CourseEnrollment.refundable', return_value=False):
response = self.client.get(reverse('course_run_refund_status', kwargs={'course_id': self.course.id}))
self.assertEquals(json.loads(response.content), {'course_refundable_status': False})
self.assertEqual(response.status_code, 200)
def test_course_run_refund_status_invalid_course_key(self):
""" Assert that view:course_run_refund_status returns correct Json for Invalid Course Key ."""
with patch('opaque_keys.edx.keys.CourseKey.from_string') as mock_method:
mock_method.side_effect = InvalidKeyError('CourseKey', 'The course key used to get refund status caused \
InvalidKeyError during look up.')
response = self.client.get(reverse('course_run_refund_status', kwargs={'course_id': self.course.id}))
self.assertEquals(json.loads(response.content), {'course_refundable_status': ''})
self.assertEqual(response.status_code, 406)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class LogoutTests(TestCase):
""" Tests for the logout functionality. """
def setUp(self):
""" Create a course and user, then log in. """
super(LogoutTests, self).setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password=PASSWORD)
def create_oauth_client(self):
""" Creates a trusted OAuth client. """
client = ClientFactory(logout_uri='https://www.example.com/logout/')
TrustedClientFactory(client=client)
return client
def assert_session_logged_out(self, oauth_client, **logout_headers):
""" Authenticates a user via OAuth 2.0, logs out, and verifies the session is logged out. """
self.authenticate_with_oauth(oauth_client)
# Logging out should remove the session variables, and send a list of logout URLs to the template.
# The template will handle loading those URLs and redirecting the user. That functionality is not tested here.
response = self.client.get(reverse('logout'), **logout_headers)
self.assertEqual(response.status_code, 200)
self.assertNotIn(AUTHORIZED_CLIENTS_SESSION_KEY, self.client.session)
return response
def authenticate_with_oauth(self, oauth_client):
""" Perform an OAuth authentication using the current web client.
This should add an AUTHORIZED_CLIENTS_SESSION_KEY entry to the current session.
"""
data = {
'client_id': oauth_client.client_id,
'client_secret': oauth_client.client_secret,
'response_type': 'code'
}
# Authenticate with OAuth to set the appropriate session values
self.client.post(reverse('oauth2:capture'), data, follow=True)
self.assertListEqual(self.client.session[AUTHORIZED_CLIENTS_SESSION_KEY], [oauth_client.client_id])
def assert_logout_redirects_to_root(self):
""" Verify logging out redirects the user to the homepage. """
response = self.client.get(reverse('logout'))
self.assertRedirects(response, '/', fetch_redirect_response=False)
def assert_logout_redirects_with_target(self):
""" Verify logging out with a redirect_url query param redirects the user to the target. """
url = '{}?{}'.format(reverse('logout'), 'redirect_url=/courses')
response = self.client.get(url)
self.assertRedirects(response, '/courses', fetch_redirect_response=False)
def test_without_session_value(self):
""" Verify logout works even if the session does not contain an entry with
the authenticated OpenID Connect clients."""
self.assert_logout_redirects_to_root()
self.assert_logout_redirects_with_target()
def test_client_logout(self):
""" Verify the context includes a list of the logout URIs of the authenticated OpenID Connect clients.
The list should only include URIs of the clients for which the user has been authenticated.
"""
client = self.create_oauth_client()
response = self.assert_session_logged_out(client)
expected = {
'logout_uris': [client.logout_uri + '?no_redirect=1'], # pylint: disable=no-member
'target': '/',
}
self.assertDictContainsSubset(expected, response.context_data) # pylint: disable=no-member
def test_filter_referring_service(self):
""" Verify that, if the user is directed to the logout page from a service, that service's logout URL
is not included in the context sent to the template.
"""
client = self.create_oauth_client()
response = self.assert_session_logged_out(client, HTTP_REFERER=client.logout_uri) # pylint: disable=no-member
expected = {
'logout_uris': [],
'target': '/',
}
self.assertDictContainsSubset(expected, response.context_data) # pylint: disable=no-member
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Tests for the student dashboard.
"""
EMAIL_SETTINGS_ELEMENT_ID = "#actions-item-email-settings-0"
ENABLED_SIGNALS = ['course_published']
TOMORROW = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
THREE_YEARS_FROM_NOW = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=(365 * 3))
THREE_YEARS_AGO = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=(365 * 3))
MOCK_SETTINGS = {
'FEATURES': {
'DISABLE_START_DATES': False,
'ENABLE_MKTG_SITE': True
},
'SOCIAL_SHARING_SETTINGS': {
'CUSTOM_COURSE_URLS': True,
'DASHBOARD_FACEBOOK': True,
'DASHBOARD_TWITTER': True,
},
}
def setUp(self):
"""
Create a course and user, then log in.
"""
super(StudentDashboardTests, self).setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password=PASSWORD)
self.path = reverse('dashboard')
def set_course_sharing_urls(self, set_marketing, set_social_sharing):
"""
Set course sharing urls (i.e. social_sharing_url, marketing_url)
"""
course_overview = self.course_enrollment.course_overview
if set_marketing:
course_overview.marketing_url = 'http://www.testurl.com/marketing/url/'
if set_social_sharing:
course_overview.social_sharing_url = 'http://www.testurl.com/social/url/'
course_overview.save()
def test_user_info_cookie(self):
"""
Verify visiting the learner dashboard sets the user info cookie.
"""
self.assertNotIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies)
request = RequestFactory().get(self.path)
request.user = self.user
expected = json.dumps(get_user_info_cookie_data(request))
self.client.get(self.path)
actual = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME].value
self.assertEqual(actual, expected)
def test_redirect_account_settings(self):
"""
Verify if user does not have profile he/she is redirected to account_settings.
"""
UserProfile.objects.get(user=self.user).delete()
response = self.client.get(self.path)
self.assertRedirects(response, reverse('account_settings'))
@patch.multiple('django.conf.settings', **MOCK_SETTINGS)
@ddt.data(
*itertools.product(
[True, False],
[True, False],
[ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split],
)
)
@ddt.unpack
def test_sharing_icons_for_future_course(self, set_marketing, set_social_sharing, modulestore_type):
"""
Verify that the course sharing icons show up if course is starting in future and
any of marketing or social sharing urls are set.
"""
self.course = CourseFactory.create(start=self.TOMORROW, emit_signals=True, default_store=modulestore_type)
self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
self.set_course_sharing_urls(set_marketing, set_social_sharing)
# Assert course sharing icons
response = self.client.get(reverse('dashboard'))
self.assertEqual('Share on Twitter' in response.content, set_marketing or set_social_sharing)
self.assertEqual('Share on Facebook' in response.content, set_marketing or set_social_sharing)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
def test_pre_requisites_appear_on_dashboard(self):
"""
When a course has a prerequisite, the dashboard should display the prerequisite.
If we remove the prerequisite and access the dashboard again, the prerequisite
should not appear.
"""
self.pre_requisite_course = CourseFactory.create(org='edx', number='999', display_name='Pre requisite Course')
self.course = CourseFactory.create(
org='edx',
number='998',
display_name='Test Course',
pre_requisite_courses=[unicode(self.pre_requisite_course.id)]
)
self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
set_prerequisite_courses(self.course.id, [unicode(self.pre_requisite_course.id)])
response = self.client.get(reverse('dashboard'))
self.assertIn('<div class="prerequisites">', response.content)
remove_prerequisite_course(self.course.id, get_course_milestones(self.course.id)[0])
response = self.client.get(reverse('dashboard'))
self.assertNotIn('<div class="prerequisites">', response.content)
@patch('openedx.core.djangoapps.programs.utils.get_programs')
@patch('student.views.get_visible_course_runs_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
def test_unfulfilled_entitlement(self, mock_course_overview, mock_course_runs, mock_get_programs):
"""
When a learner has an unfulfilled entitlement, their course dashboard should have:
- a hidden 'View Course' button
- the text 'In order to view the course you must select a session:'
- an unhidden course-entitlement-selection-container
- a related programs message
"""
program = ProgramFactory()
CourseEntitlementFactory(user=self.user, course_uuid=program['courses'][0]['uuid'])
mock_get_programs.return_value = [program]
mock_course_overview.return_value = CourseOverviewFactory(start=self.TOMORROW)
mock_course_runs.return_value = [
{
'key': 'course-v1:FAKE+FA1-MA1.X+3T2017',
'enrollment_end': str(self.TOMORROW),
'pacing_type': 'instructor_paced',
'type': 'verified'
}
]
response = self.client.get(self.path)
self.assertIn('class="enter-course hidden"', response.content)
self.assertIn('You must select a session to access the course.', response.content)
self.assertIn('<div class="course-entitlement-selection-container ">', response.content)
self.assertIn('Related Programs:', response.content)
@patch('student.views.get_visible_course_runs_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
def test_unfulfilled_expired_entitlement(self, mock_course_overview, mock_course_runs):
"""
When a learner has an unfulfilled, expired entitlement, a card should NOT appear on the dashboard.
This use case represents either an entitlement that the user waited too long to fulfill, or an entitlement
for which they received a refund.
"""
CourseEntitlementFactory(
user=self.user,
created=self.THREE_YEARS_AGO,
expired_at=datetime.datetime.now()
)
mock_course_overview.return_value = CourseOverviewFactory(start=self.TOMORROW)
mock_course_runs.return_value = [
{
'key': 'course-v1:FAKE+FA1-MA1.X+3T2017',
'enrollment_end': str(self.TOMORROW),
'pacing_type': 'instructor_paced',
'type': 'verified'
}
]
response = self.client.get(self.path)
self.assertEqual(response.content.count('<li class="course-item">'), 0)
@patch('entitlements.api.v1.views.get_course_runs_for_course')
@patch.object(CourseOverview, 'get_from_id')
@patch('opaque_keys.edx.keys.CourseKey.from_string')
def test_sessions_for_entitlement_course_runs(self, mock_course_key, mock_course_overview, mock_course_runs):
"""
When a learner has a fulfilled entitlement for a course run in the past, there should be no availableSession
data passed to the JS view. When a learner has a fulfilled entitlement for a course run enrollment ending in the
future, there should not be an empty availableSession variable. When a learner has a fulfilled entitlement
for a course that doesn't have an enrollment ending, there should not be an empty availableSession variable.
NOTE: We commented out the assertions to move this to the catalog utils test suite.
"""
# noAvailableSessions = "availableSessions: '[]'"
# Test an enrollment end in the past
mocked_course_overview = CourseOverviewFactory.create(
start=self.TOMORROW, end=self.THREE_YEARS_FROM_NOW, self_paced=True, enrollment_end=self.THREE_YEARS_AGO
)
mock_course_overview.return_value = mocked_course_overview
mock_course_key.return_value = mocked_course_overview.id
course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=unicode(mocked_course_overview.id))
mock_course_runs.return_value = [
{
'key': str(mocked_course_overview.id),
'enrollment_end': str(mocked_course_overview.enrollment_end),
'pacing_type': 'self_paced',
'type': 'verified'
}
]
CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment)
# response = self.client.get(self.path)
# self.assertIn(noAvailableSessions, response.content)
# Test an enrollment end in the future sets an availableSession
mocked_course_overview.enrollment_end = self.TOMORROW
mocked_course_overview.save()
mock_course_overview.return_value = mocked_course_overview
mock_course_key.return_value = mocked_course_overview.id
mock_course_runs.return_value = [
{
'key': str(mocked_course_overview.id),
'enrollment_end': str(mocked_course_overview.enrollment_end),
'pacing_type': 'self_paced',
'type': 'verified'
}
]
# response = self.client.get(self.path)
# self.assertNotIn(noAvailableSessions, response.content)
# Test an enrollment end that doesn't exist sets an availableSession
mocked_course_overview.enrollment_end = None
mocked_course_overview.save()
mock_course_overview.return_value = mocked_course_overview
mock_course_key.return_value = mocked_course_overview.id
mock_course_runs.return_value = [
{
'key': str(mocked_course_overview.id),
'enrollment_end': None,
'pacing_type': 'self_paced',
'type': 'verified'
}
]
# response = self.client.get(self.path)
# self.assertNotIn(noAvailableSessions, response.content)
@patch('openedx.core.djangoapps.programs.utils.get_programs')
@patch('student.views.get_visible_course_runs_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
@patch('opaque_keys.edx.keys.CourseKey.from_string')
def test_fulfilled_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs, mock_get_programs):
"""
When a learner has a fulfilled entitlement, their course dashboard should have:
- exactly one course item, meaning it:
- has an entitlement card
- does NOT have a course card referencing the selected session
- an unhidden Change or Leave Session button
- a related programs message
"""
mocked_course_overview = CourseOverviewFactory(
start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW
)
mock_course_overview.return_value = mocked_course_overview
mock_course_key.return_value = mocked_course_overview.id
course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=unicode(mocked_course_overview.id))
mock_course_runs.return_value = [
{
'key': str(mocked_course_overview.id),
'enrollment_end': str(mocked_course_overview.enrollment_end),
'pacing_type': 'self_paced',
'type': 'verified'
}
]
entitlement = CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment)
program = ProgramFactory()
program['courses'][0]['course_runs'] = [{'key': unicode(mocked_course_overview.id)}]
program['courses'][0]['uuid'] = entitlement.course_uuid
mock_get_programs.return_value = [program]
response = self.client.get(self.path)
self.assertEqual(response.content.count('<li class="course-item">'), 1)
self.assertIn('<button class="change-session btn-link "', response.content)
self.assertIn('Related Programs:', response.content)
@patch('openedx.core.djangoapps.programs.utils.get_programs')
@patch('student.views.get_visible_course_runs_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
@patch('opaque_keys.edx.keys.CourseKey.from_string')
def test_fulfilled_expired_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs, mock_get_programs):
"""
When a learner has a fulfilled entitlement that is expired, their course dashboard should have:
- exactly one course item, meaning it:
- has an entitlement card
- Message that the learner can no longer change sessions
- a related programs message
"""
mocked_course_overview = CourseOverviewFactory(
start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW
)
mock_course_overview.return_value = mocked_course_overview
mock_course_key.return_value = mocked_course_overview.id
course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=unicode(mocked_course_overview.id), created=self.THREE_YEARS_AGO)
mock_course_runs.return_value = [
{
'key': str(mocked_course_overview.id),
'enrollment_end': str(mocked_course_overview.enrollment_end),
'pacing_type': 'self_paced',
'type': 'verified'
}
]
entitlement = CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment, created=self.THREE_YEARS_AGO)
program = ProgramFactory()
program['courses'][0]['course_runs'] = [{'key': unicode(mocked_course_overview.id)}]
program['courses'][0]['uuid'] = entitlement.course_uuid
mock_get_programs.return_value = [program]
response = self.client.get(self.path)
self.assertEqual(response.content.count('<li class="course-item">'), 1)
self.assertIn('You can no longer change sessions.', response.content)
self.assertIn('Related Programs:', response.content)
@patch.object(CourseOverview, 'get_from_id')
@patch.object(BulkEmailFlag, 'feature_enabled')
def test_email_settings_fulfilled_entitlement(self, mock_email_feature, mock_course_overview):
"""
Assert that the Email Settings action is shown when the user has a fulfilled entitlement.
"""
mock_email_feature.return_value = True
mock_course_overview.return_value = CourseOverviewFactory(
start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW
)
course_enrollment = CourseEnrollmentFactory(user=self.user)
CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment)
response = self.client.get(self.path)
self.assertEqual(pq(response.content)(self.EMAIL_SETTINGS_ELEMENT_ID).length, 1)
@patch.object(CourseOverview, 'get_from_id')
@patch.object(BulkEmailFlag, 'feature_enabled')
def test_email_settings_unfulfilled_entitlement(self, mock_email_feature, mock_course_overview):
"""
Assert that the Email Settings action is not shown when the entitlement is not fulfilled.
"""
mock_email_feature.return_value = True
mock_course_overview.return_value = CourseOverviewFactory(start=self.TOMORROW)
CourseEntitlementFactory(user=self.user)
response = self.client.get(self.path)
self.assertEqual(pq(response.content)(self.EMAIL_SETTINGS_ELEMENT_ID).length, 0)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(BRANCH_IO_KEY='test_key')
class TextMeTheAppViewTests(UrlResetMixin, TestCase):
""" Tests for the TextMeTheAppView. """
def test_text_me_the_app(self):
response = self.client.get(reverse('text_me_the_app'))
self.assertContains(response, 'Send me a text with the link')