Commit c12c5c92 by Edward Zarecor

fixing post-release merge conflicts with DKH

parents 5faaca0d 1b3efaba
......@@ -270,6 +270,107 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase):
self.clear_sub_content(good_youtube_sub)
@patch('xmodule.video_module.transcripts_utils.requests.get')
def test_get_transcript_name_youtube_server_success(self, mock_get):
"""
Get transcript name from transcript_list fetch from youtube server api
depends on language code, default language in YOUTUBE Text Api is "en"
"""
youtube_text_api = copy.deepcopy(settings.YOUTUBE['TEXT_API'])
youtube_text_api['params']['v'] = 'dummy_video_id'
response_success = """
<transcript_list>
<track id="1" name="Custom" lang_code="en" />
<track id="0" name="Custom1" lang_code="en-GB"/>
</transcript_list>
"""
mock_get.return_value = Mock(status_code=200, text=response_success, content=response_success)
transcript_name = transcripts_utils.youtube_video_transcript_name(youtube_text_api)
self.assertEqual(transcript_name, 'Custom')
@patch('xmodule.video_module.transcripts_utils.requests.get')
def test_get_transcript_name_youtube_server_no_transcripts(self, mock_get):
"""
When there are no transcripts of video transcript name will be None
"""
youtube_text_api = copy.deepcopy(settings.YOUTUBE['TEXT_API'])
youtube_text_api['params']['v'] = 'dummy_video_id'
response_success = "<transcript_list></transcript_list>"
mock_get.return_value = Mock(status_code=200, text=response_success, content=response_success)
transcript_name = transcripts_utils.youtube_video_transcript_name(youtube_text_api)
self.assertIsNone(transcript_name)
@patch('xmodule.video_module.transcripts_utils.requests.get')
def test_get_transcript_name_youtube_server_language_not_exist(self, mock_get):
"""
When the language does not exist in transcript_list transcript name will be None
"""
youtube_text_api = copy.deepcopy(settings.YOUTUBE['TEXT_API'])
youtube_text_api['params']['v'] = 'dummy_video_id'
youtube_text_api['params']['lang'] = 'abc'
response_success = """
<transcript_list>
<track id="1" name="Custom" lang_code="en" />
<track id="0" name="Custom1" lang_code="en-GB"/>
</transcript_list>
"""
mock_get.return_value = Mock(status_code=200, text=response_success, content=response_success)
transcript_name = transcripts_utils.youtube_video_transcript_name(youtube_text_api)
self.assertIsNone(transcript_name)
def mocked_requests_get(*args, **kwargs):
"""
This method will be used by the mock to replace requests.get
"""
# pylint: disable=no-method-argument
response_transcript_list = """
<transcript_list>
<track id="1" name="Custom" lang_code="en" />
<track id="0" name="Custom1" lang_code="en-GB"/>
</transcript_list>
"""
response_transcript = textwrap.dedent("""
<transcript>
<text start="0" dur="0.27"></text>
<text start="0.27" dur="2.45">Test text 1.</text>
<text start="2.72">Test text 2.</text>
<text start="5.43" dur="1.73">Test text 3.</text>
</transcript>
""")
if kwargs == {'params': {'lang': 'en', 'v': 'good_id_2'}}:
return Mock(status_code=200, text='')
elif kwargs == {'params': {'type': 'list', 'v': 'good_id_2'}}:
return Mock(status_code=200, text=response_transcript_list, content=response_transcript_list)
elif kwargs == {'params': {'lang': 'en', 'v': 'good_id_2', 'name': 'Custom'}}:
return Mock(status_code=200, text=response_transcript, content=response_transcript)
return Mock(status_code=404, text='')
@patch('xmodule.video_module.transcripts_utils.requests.get', side_effect=mocked_requests_get)
def test_downloading_subs_using_transcript_name(self, mock_get):
"""
Download transcript using transcript name in url
"""
good_youtube_sub = 'good_id_2'
self.clear_sub_content(good_youtube_sub)
transcripts_utils.download_youtube_subs(good_youtube_sub, self.course, settings)
mock_get.assert_any_call(
'http://video.google.com/timedtext',
params={'lang': 'en', 'v': 'good_id_2', 'name': 'Custom'}
)
# Check asset status after import of transcript.
filename = 'subs_{0}.srt.sjson'.format(good_youtube_sub)
content_location = StaticContent.compute_location(self.course.id, filename)
self.assertTrue(contentstore().find(content_location))
self.clear_sub_content(good_youtube_sub)
class TestGenerateSubsFromSource(TestDownloadYoutubeSubs):
"""Tests for `generate_subs_from_source` function."""
......
......@@ -332,13 +332,14 @@ def xblock_outline_handler(request, usage_key_string):
response_format = request.REQUEST.get('format', 'html')
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
store = modulestore()
root_xblock = store.get_item(usage_key)
return JsonResponse(create_xblock_info(
root_xblock,
include_child_info=True,
course_outline=True,
include_children_predicate=lambda xblock: not xblock.category == 'vertical'
))
with store.bulk_operations(usage_key.course_key):
root_xblock = store.get_item(usage_key)
return JsonResponse(create_xblock_info(
root_xblock,
include_child_info=True,
course_outline=True,
include_children_predicate=lambda xblock: not xblock.category == 'vertical'
))
else:
return Http404
......
......@@ -1410,6 +1410,28 @@ class TestXBlockInfo(ItemTest):
json_response = json.loads(resp.content)
self.validate_course_xblock_info(json_response, course_outline=True)
def test_xblock_outline_handler_mongo_calls(self):
expected_calls = 5
with self.store.default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create()
chapter = ItemFactory.create(
parent_location=course.location, category='chapter', display_name='Week 1'
)
outline_url = reverse_usage_url('xblock_outline_handler', chapter.location)
with check_mongo_calls(expected_calls):
self.client.get(outline_url, HTTP_ACCEPT='application/json')
sequential = ItemFactory.create(
parent_location=chapter.location, category='sequential', display_name='Sequential 1'
)
ItemFactory.create(
parent_location=sequential.location, category='vertical', display_name='Vertical 1'
)
# calls should be same after adding two new children.
with check_mongo_calls(expected_calls):
self.client.get(outline_url, HTTP_ACCEPT='application/json')
def test_entrance_exam_chapter_xblock_info(self):
chapter = ItemFactory.create(
parent_location=self.course.location, category='chapter', display_name="Entrance Exam",
......
......@@ -2,6 +2,7 @@
Tests for user enrollment.
"""
import json
import itertools
import unittest
import datetime
......@@ -91,11 +92,11 @@ class EnrollmentTestMixin(object):
return response
def assert_enrollment_activation(self, expected_activation, expected_mode=CourseMode.VERIFIED):
def assert_enrollment_activation(self, expected_activation, expected_mode):
"""Change an enrollment's activation and verify its activation and mode are as expected."""
self.assert_enrollment_status(
as_server=True,
mode=None,
mode=expected_mode,
is_active=expected_activation,
expected_status=status.HTTP_200_OK
)
......@@ -637,6 +638,58 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.HONOR)
@ddt.data(*itertools.product(
(CourseMode.HONOR, CourseMode.VERIFIED),
(CourseMode.HONOR, CourseMode.VERIFIED),
(True, False),
(True, False),
))
@ddt.unpack
def test_change_mode_from_server(self, old_mode, new_mode, old_is_active, new_is_active):
"""
Server-to-server calls should be allowed to change the mode of any
enrollment, as long as the enrollment is not being deactivated during
the same call (this is assumed to be an error on the client's side).
"""
for mode in [CourseMode.HONOR, CourseMode.VERIFIED]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Set up the initial enrollment
self.assert_enrollment_status(as_server=True, mode=old_mode, is_active=old_is_active)
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertEqual(is_active, old_is_active)
self.assertEqual(course_mode, old_mode)
expected_status = status.HTTP_400_BAD_REQUEST if (
old_mode != new_mode and
old_is_active != new_is_active and
not new_is_active
) else status.HTTP_200_OK
# simulate the server-server api call under test
response = self.assert_enrollment_status(
as_server=True,
mode=new_mode,
is_active=new_is_active,
expected_status=expected_status,
)
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
if expected_status == status.HTTP_400_BAD_REQUEST:
# nothing should have changed
self.assertEqual(is_active, old_is_active)
self.assertEqual(course_mode, old_mode)
# error message should contain specific text. Otto checks for this text in the message.
self.assertRegexpMatches(json.loads(response.content)['message'], 'Enrollment mode mismatch')
else:
# call should have succeeded
self.assertEqual(is_active, new_is_active)
self.assertEqual(course_mode, new_mode)
def test_change_mode_invalid_user(self):
"""
Attempts to change an enrollment for a non-existent user should result in an HTTP 404 for non-server users,
......
......@@ -3,6 +3,8 @@ The Enrollment API Views should be simple, lean HTTP endpoints for API access. T
consist primarily of authentication, request validation, and serialization.
"""
import logging
from ipware.ip import get_ip
from django.core.exceptions import ObjectDoesNotExist
from django.utils.decorators import method_decorator
......@@ -31,6 +33,9 @@ from enrollment.errors import (
from student.models import User
log = logging.getLogger(__name__)
class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf):
"""Session authentication that allows inactive users and cross-domain requests. """
pass
......@@ -429,7 +434,18 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
)
enrollment = api.get_enrollment(username, unicode(course_id))
if has_api_key_permissions and enrollment and enrollment['mode'] != mode:
mode_changed = enrollment and mode is not None and enrollment['mode'] != mode
active_changed = enrollment and is_active is not None and enrollment['is_active'] != is_active
if has_api_key_permissions and (mode_changed or active_changed):
if mode_changed and active_changed and not is_active:
# if the requester wanted to deactivate but specified the wrong mode, fail
# the request (on the assumption that the requester had outdated information
# about the currently active enrollment).
msg = u"Enrollment mode mismatch: active mode={}, requested mode={}. Won't deactivate.".format(
enrollment["mode"], mode
)
log.warning(msg)
return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": msg})
response = api.update_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
else:
# Will reactivate inactive enrollments.
......
......@@ -522,12 +522,9 @@ function (VideoPlayer, i18n) {
this.youtubeXhr
.always(function (json, status) {
var err = $.isPlainObject(json.error) ||
(
status !== 'success' &&
status !== 'notmodified'
);
if (err) {
// It will work for both if statusCode is 200 or 410.
var didSucceed = (json.error && json.error.code === 410) || status === 'success' || status === 'notmodified';
if (!didSucceed) {
console.log(
'[Video info]: YouTube returned an error for ' +
'video with id "' + id + '".'
......
......@@ -94,7 +94,32 @@ def save_subs_to_store(subs, subs_id, item, language='en'):
return save_to_store(filedata, filename, 'application/json', item.location)
def get_transcripts_from_youtube(youtube_id, settings, i18n):
def youtube_video_transcript_name(youtube_text_api):
"""
Get the transcript name from available transcripts of video
with respect to language from youtube server
"""
# pylint: disable=no-member
utf8_parser = etree.XMLParser(encoding='utf-8')
transcripts_param = {'type': 'list', 'v': youtube_text_api['params']['v']}
lang = youtube_text_api['params']['lang']
# get list of transcripts of specific video
# url-form
# http://video.google.com/timedtext?type=list&v={VideoId}
youtube_response = requests.get('http://' + youtube_text_api['url'], params=transcripts_param)
if youtube_response.status_code == 200 and youtube_response.text:
# pylint: disable=no-member
youtube_data = etree.fromstring(youtube_response.content, parser=utf8_parser)
# iterate all transcripts information from youtube server
for element in youtube_data:
# search specific language code such as 'en' in transcripts info list
if element.tag == 'track' and element.get('lang_code', '') == lang:
return element.get('name')
return None
def get_transcripts_from_youtube(youtube_id, settings, i18n, youtube_transcript_name=''):
"""
Gets transcripts from youtube for youtube_id.
......@@ -109,6 +134,12 @@ def get_transcripts_from_youtube(youtube_id, settings, i18n):
youtube_text_api = copy.deepcopy(settings.YOUTUBE['TEXT_API'])
youtube_text_api['params']['v'] = youtube_id
# if the transcript name is not empty on youtube server we have to pass
# name param in url in order to get transcript
# example http://video.google.com/timedtext?lang=en&v={VideoId}&name={transcript_name}
youtube_transcript_name = youtube_video_transcript_name(youtube_text_api)
if youtube_transcript_name:
youtube_text_api['params']['name'] = youtube_transcript_name
data = requests.get('http://' + youtube_text_api['url'], params=youtube_text_api['params'])
if data.status_code != 200 or not data.text:
......
......@@ -201,6 +201,52 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
@patch('student.views.render_to_response', RENDER_MOCK)
@patch('courseware.views.render_to_response', RENDER_MOCK)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False})
def test_course_discovery_off(self):
"""
Asserts that the Course Discovery UI elements follow the
feature flag settings
"""
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
# assert that the course discovery UI is not present
self.assertNotIn('Search for a course', response.content)
# check the /courses view
response = self.client.get(reverse('branding.views.courses'))
self.assertEqual(response.status_code, 200)
# assert that the course discovery UI is not present
self.assertNotIn('Search for a course', response.content)
self.assertNotIn('<aside aria-label="Refine your search" class="search-facets phone-menu">', response.content)
# make sure we have the special css class on the section
self.assertIn('<section class="courses no-course-discovery">', response.content)
@patch('student.views.render_to_response', RENDER_MOCK)
@patch('courseware.views.render_to_response', RENDER_MOCK)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': True})
def test_course_discovery_on(self):
"""
Asserts that the Course Discovery UI elements follow the
feature flag settings
"""
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
# assert that the course discovery UI is not present
self.assertIn('Search for a course', response.content)
# check the /courses view
response = self.client.get(reverse('branding.views.courses'))
self.assertEqual(response.status_code, 200)
# assert that the course discovery UI is not present
self.assertIn('Search for a course', response.content)
self.assertIn('<aside aria-label="Refine your search" class="search-facets phone-menu">', response.content)
self.assertIn('<section class="courses">', response.content)
@patch('student.views.render_to_response', RENDER_MOCK)
@patch('courseware.views.render_to_response', RENDER_MOCK)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False})
def test_course_cards_sorted_by_default_sorting(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
......
......@@ -5,6 +5,7 @@ import logging
from urlparse import urljoin
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.mail import EmailMultiAlternatives
from django.dispatch import receiver
from django.utils.translation import ugettext as _
......@@ -32,6 +33,13 @@ def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, **kw
if course_enrollment and course_enrollment.refundable():
try:
request_user = get_request_user() or course_enrollment.user
if isinstance(request_user, AnonymousUser):
# Assume the request was initiated via server-to-server
# api call (presumably Otto). In this case we cannot
# construct a client to call Otto back anyway, because
# the client does not work anonymously, and furthermore,
# there's certainly no need to inform Otto about this request.
return
refund_seat(course_enrollment, request_user)
except: # pylint: disable=bare-except
# don't assume the signal was fired with `send_robust`.
......
"""
Tests for signal handling in commerce djangoapp.
"""
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.test.utils import override_settings
......@@ -109,6 +110,12 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_refund_seat.called)
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.requester))
# HTTP user is another server (AnonymousUser): do not try to initiate a refund at all.
mock_get_request_user.return_value = AnonymousUser()
mock_refund_seat.reset_mock()
self.send_signal()
self.assertFalse(mock_refund_seat.called)
@mock.patch('commerce.signals.log.warning')
def test_not_authorized_warning(self, mock_log_warning):
"""
......
......@@ -511,6 +511,7 @@ class TabListTestCase(TabTestCase):
{'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': 'discussion', 'name': 'fake_name'},
{'type': ExternalLinkCourseTab.type, 'name': 'fake_name', 'link': 'fake_link'},
{'type': ExternalLinkCourseTab.type, 'name': 'fake_name', 'link': 'fake_link'},
{'type': 'textbooks'},
{'type': 'pdf_textbooks'},
{'type': 'html_textbooks'},
......
......@@ -86,7 +86,8 @@
"ALLOW_AUTOMATED_SIGNUPS": true,
"AUTOMATIC_AUTH_FOR_TESTING": true,
"MODE_CREATION_FOR_TESTING": true,
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true,
"ENABLE_COURSE_DISCOVERY": true
},
"FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
......@@ -94,6 +94,9 @@ FEATURES['MILESTONES_APP'] = True
# Enable pre-requisite course
FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
# Enable Course Discovery
FEATURES['ENABLE_COURSE_DISCOVERY'] = True
# Enable student notes
FEATURES['ENABLE_EDXNOTES'] = True
......
......@@ -41,38 +41,78 @@ $facet-background-color: #007db8;
.courses {
@include rtl() { $layout-direction: "RTL"; }
@include span-columns(9);
@include media($bp-medium) {
@include span-columns(4);
}
@include media($bp-large) {
@include span-columns(8);
.courses-listing .courses-listing-item {
@include fill-parent();
margin: ($baseline*0.75) 0 ($baseline*1.5) 0;
max-height: $course-card-height;
}
@include media($bp-huge) {
/* Style grid settings if course discovery turned on */
&:not(.no-course-discovery) {
@include span-columns(9);
@include media($bp-medium) {
@include span-columns(4);
}
@include media($bp-large) {
@include span-columns(8);
}
@include media($bp-huge) {
@include span-columns(9);
}
.courses-listing .courses-listing-item {
@include media($bp-medium) {
@include span-columns(8); // 4 of 8
@include omega(1n);
}
@include media($bp-large) {
@include span-columns(6); // 6 of 12
@include omega(2n);
}
@include media($bp-huge) {
@include span-columns(4); // 4 of 12
@include omega(3n);
}
}
}
.courses-listing .courses-listing-item {
@include fill-parent();
margin: ($baseline*0.75) 0 ($baseline*1.5) 0;
max-height: $course-card-height;
/* Style grid settings if course discovery turned off */
&.no-course-discovery{
@include span-columns(12);
@include media($bp-medium) {
@include span-columns(8); // 4 of 8
@include omega(1n);
@include span-columns(8);
}
@include media($bp-large) {
@include span-columns(6); // 6 of 12
@include omega(2n);
@include span-columns(12);
}
@include media($bp-huge) {
@include span-columns(4); // 4 of 12
@include omega(3n);
@include span-columns(12);
}
.courses-listing .courses-listing-item {
@include media($bp-medium) {
@include span-columns(4); // 4 of 8
@include omega(2n);
}
@include media($bp-large) {
@include span-columns(4); // 4 of 12
@include omega(3n);
}
@include media($bp-huge) {
@include span-columns(3); // 3 of 12
@include omega(4n);
}
}
}
}
......
......@@ -80,12 +80,15 @@ footer#footer-edx-v3 {
}
}
.social-media-links,
.mobile-app-links {
@include clearfix();
position: relative;
width: 260px;
height: 42px;
}
.social-media-links {
@include clearfix();
margin-bottom: 30px;
}
......@@ -119,17 +122,20 @@ footer#footer-edx-v3 {
}
.app-link {
@include float(left);
@include margin-right(10px);
position: relative;
display: inline-block;
position: absolute;
top: 0;
&:first-of-type {
@include left(0);
}
&:last-of-type {
@include margin-right(0);
@include right(0);
}
img {
height: 40px;
max-width: 200px;
}
}
......
......@@ -29,7 +29,7 @@
<%block name="pagetitle">${_("Courses")}</%block>
<%
platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME)
course_discovery_enabled = settings.FEATURES.get('ENABLE_COURSE_DISCOVERY')
if self.stanford_theme_enabled():
course_index_overlay_text = _("Explore free courses from {university_name}.").format(university_name="Stanford University")
logo_file = static.url('themes/stanford/images/seal.png')
......@@ -66,6 +66,7 @@
<section class="courses-container">
% if course_discovery_enabled:
<div id="discovery-form" role="search" aria-label="course">
<form>
<input class="discovery-input" placeholder="${_('Search for a course')}" type="text"/><!-- removes spacing
......@@ -83,8 +84,9 @@
<div id="filter-bar" class="filters hide-phone">
</div>
% endif
<section class="courses">
<section class="courses${'' if course_discovery_enabled else ' no-course-discovery'}">
<ul class="courses-listing">
%for course in courses:
<li class="courses-listing-item">
......@@ -95,8 +97,10 @@
</section>
% if course_discovery_enabled:
<aside aria-label="${_('Refine your search')}" class="search-facets phone-menu">
</aside>
% endif
</section>
</section>
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