Commit 19acdd31 by Calen Pennington

Merge remote-tracking branch 'edx/master' into opaque-keys-merge-master

Conflicts:
	cms/djangoapps/contentstore/views/public.py
	common/djangoapps/external_auth/tests/test_ssl.py
	common/djangoapps/student/views.py
	lms/djangoapps/dashboard/sysadmin.py
	lms/templates/notes.html
parents 94c8d86a d37006c6
...@@ -17,12 +17,13 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT ...@@ -17,12 +17,13 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
DELAY = 0.5 DELAY = 0.5
ERROR_MESSAGES = { ERROR_MESSAGES = {
'url_format': u'Incorrect URL format.', 'url_format': u'Incorrect url format.',
'file_type': u'Video file types must be unique.', 'file_type': u'Link types should be unique.',
} }
STATUSES = { STATUSES = {
'found': u'Timed Transcript Found', 'found': u'Timed Transcript Found',
'not found on edx': u'No EdX Timed Transcript',
'not found': u'No Timed Transcript', 'not found': u'No Timed Transcript',
'replace': u'Timed Transcript Conflict', 'replace': u'Timed Transcript Conflict',
'uploaded_successfully': u'Timed Transcript Uploaded Successfully', 'uploaded_successfully': u'Timed Transcript Uploaded Successfully',
...@@ -39,13 +40,13 @@ SELECTORS = { ...@@ -39,13 +40,13 @@ SELECTORS = {
# button type , button css selector, button message # button type , button css selector, button message
TRANSCRIPTS_BUTTONS = { TRANSCRIPTS_BUTTONS = {
'import': ('.setting-import', 'Import YouTube Transcript'), 'import': ('.setting-import', 'Import YouTube Transcript'),
'download_to_edit': ('.setting-download', 'Download Transcript for Editing'), 'download_to_edit': ('.setting-download', 'Download Transcript for Editing'),
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download Transcript for Editing'), 'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download Transcript for Editing'),
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Timed Transcript'), 'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Transcript'),
'replace': ('.setting-replace', 'Yes, replace the edX transcript with the YouTube transcript'), 'replace': ('.setting-replace', 'Yes, replace the edX transcript with the YouTube transcript'),
'choose': ('.setting-choose', 'Timed Transcript from {}'), 'choose': ('.setting-choose', 'Timed Transcript from {}'),
'use_existing': ('.setting-use-existing', 'Use Existing Timed Transcript'), 'use_existing': ('.setting-use-existing', 'Use Current Transcript'),
} }
...@@ -209,7 +210,8 @@ def check_text_in_the_captions(_step, text): ...@@ -209,7 +210,8 @@ def check_text_in_the_captions(_step, text):
@step('I see value "([^"]*)" in the field "([^"]*)"$') @step('I see value "([^"]*)" in the field "([^"]*)"$')
def check_transcripts_field(_step, values, field_name): def check_transcripts_field(_step, values, field_name):
world.select_editor_tab('Advanced') world.select_editor_tab('Advanced')
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for'] tab = world.css_find('#settings-tab').first;
field_id = '#' + tab.find_by_xpath('.//label[text()="%s"]' % field_name.strip())[0]['for']
values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')] values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')]
assert any(values_list) assert any(values_list)
world.select_editor_tab('Basic') world.select_editor_tab('Basic')
...@@ -227,8 +229,9 @@ def open_tab(_step, tab_name): ...@@ -227,8 +229,9 @@ def open_tab(_step, tab_name):
@step('I set value "([^"]*)" to the field "([^"]*)"$') @step('I set value "([^"]*)" to the field "([^"]*)"$')
def set_value_transcripts_field(_step, value, field_name): def set_value_transcripts_field(_step, value, field_name):
XPATH = '//label[text()="{name}"]'.format(name=field_name) tab = world.css_find('#settings-tab').first;
SELECTOR = '#' + world.browser.find_by_xpath(XPATH)[0]['for'] XPATH = './/label[text()="{name}"]'.format(name=field_name)
SELECTOR = '#' + tab.find_by_xpath(XPATH)[0]['for']
element = world.css_find(SELECTOR).first element = world.css_find(SELECTOR).first
if element['type'] == 'text': if element['type'] == 'text':
SCRIPT = '$("{selector}").val("{value}").change()'.format( SCRIPT = '$("{selector}").val("{value}").change()'.format(
......
...@@ -72,8 +72,8 @@ Feature: CMS Video Component ...@@ -72,8 +72,8 @@ Feature: CMS Video Component
And Make sure captions are closed And Make sure captions are closed
And I edit the component And I edit the component
And I open tab "Advanced" And I open tab "Advanced"
And I set value "00:00:12" to the field "Start Time" And I set value "00:00:12" to the field "Video Start Time"
And I set value "00:00:24" to the field "End Time" And I set value "00:00:24" to the field "Video Stop Time"
And I save changes And I save changes
And I click video button "play" And I click video button "play"
Then I see a range on slider Then I see a range on slider
...@@ -85,8 +85,8 @@ Feature: CMS Video Component ...@@ -85,8 +85,8 @@ Feature: CMS Video Component
# And Make sure captions are closed # And Make sure captions are closed
# And I edit the component # And I edit the component
# And I open tab "Advanced" # And I open tab "Advanced"
# And I set value "00:00:12" to the field "Start Time" # And I set value "00:00:12" to the field "Video Start Time"
# And I set value "00:00:24" to the field "End Time" # And I set value "00:00:24" to the field "Video Stop Time"
# And I save changes # And I save changes
# And I click video button "play" # And I click video button "play"
# Then I see a range on slider # Then I see a range on slider
...@@ -103,8 +103,8 @@ Feature: CMS Video Component ...@@ -103,8 +103,8 @@ Feature: CMS Video Component
# And Make sure captions are closed # And Make sure captions are closed
# And I edit the component # And I edit the component
# And I open tab "Advanced" # And I open tab "Advanced"
# And I set value "00:00:12" to the field "Start Time" # And I set value "00:00:12" to the field "Video Start Time"
# And I set value "00:00:24" to the field "End Time" # And I set value "00:00:24" to the field "Video Stop Time"
# And I save changes # And I save changes
# And I click video button "play" # And I click video button "play"
# Then I see a range on slider # Then I see a range on slider
...@@ -121,8 +121,8 @@ Feature: CMS Video Component ...@@ -121,8 +121,8 @@ Feature: CMS Video Component
# And Make sure captions are closed # And Make sure captions are closed
# And I edit the component # And I edit the component
# And I open tab "Advanced" # And I open tab "Advanced"
# And I set value "00:00:12" to the field "Start Time" # And I set value "00:00:12" to the field "Video Start Time"
# And I set value "00:00:24" to the field "End Time" # And I set value "00:00:24" to the field "Video Stop Time"
# And I save changes # And I save changes
# And I click video button "play" # And I click video button "play"
# Then I see a range on slider # Then I see a range on slider
......
...@@ -15,7 +15,7 @@ Feature: CMS Video Component Editor ...@@ -15,7 +15,7 @@ Feature: CMS Video Component Editor
Given I have created a Video component Given I have created a Video component
And I edit the component And I edit the component
And I open tab "Advanced" And I open tab "Advanced"
Then I can modify the display name Then I can modify video display name
And my video display name change is persisted on save And my video display name change is persisted on save
# 3 # 3
......
...@@ -11,6 +11,7 @@ from common import upload_file, attach_file ...@@ -11,6 +11,7 @@ from common import upload_file, attach_file
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
DISPLAY_NAME = "Component Display Name"
NATIVE_LANGUAGES = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2} NATIVE_LANGUAGES = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
LANGUAGES = { LANGUAGES = {
lang: NATIVE_LANGUAGES.get(lang, display) lang: NATIVE_LANGUAGES.get(lang, display)
...@@ -76,7 +77,7 @@ def success_upload_file(filename): ...@@ -76,7 +77,7 @@ def success_upload_file(filename):
def get_translations_container(): def get_translations_container():
return world.browser.find_by_xpath('//label[text()="Transcript Translations"]/following-sibling::div') return world.browser.find_by_xpath('//label[text()="Transcript Languages"]/following-sibling::div')
def get_setting_container(lang_code): def get_setting_container(lang_code):
...@@ -114,7 +115,7 @@ def set_show_captions(step, setting): ...@@ -114,7 +115,7 @@ def set_show_captions(step, setting):
world.edit_component() world.edit_component()
world.select_editor_tab('Advanced') world.select_editor_tab('Advanced')
world.browser.select('Transcript Display', setting) world.browser.select('Show Transcript', setting)
world.save_component() world.save_component()
...@@ -136,25 +137,25 @@ def shows_captions(_step, show_captions): ...@@ -136,25 +137,25 @@ def shows_captions(_step, show_captions):
def correct_video_settings(_step): def correct_video_settings(_step):
expected_entries = [ expected_entries = [
# basic # basic
['Display Name', 'Video', False], [DISPLAY_NAME, 'Video', False],
['Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False], ['Default Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False],
# advanced # advanced
['Display Name', 'Video', False], [DISPLAY_NAME, 'Video', False],
['Download Transcript', '', False], ['Default Timed Transcript', '', False],
['End Time', '00:00:00', False], ['Download Transcript Allowed', 'False', False],
['Start Time', '00:00:00', False], ['Downloadable Transcript URL', '', False],
['Transcript (primary)', '', False], ['Show Transcript', 'True', False],
['Transcript Display', 'True', False], ['Transcript Languages', '', False],
['Transcript Download Allowed', 'False', False],
['Transcript Translations', '', False],
['Upload Handout', '', False], ['Upload Handout', '', False],
['Video Download Allowed', 'False', False], ['Video Download Allowed', 'False', False],
['Video Sources', '', False], ['Video File URLs', '', False],
['Youtube ID', 'OEoXaMPEzfM', False], ['Video Start Time', '00:00:00', False],
['Youtube ID for .75x speed', '', False], ['Video Stop Time', '00:00:00', False],
['Youtube ID for 1.25x speed', '', False], ['YouTube ID', 'OEoXaMPEzfM', False],
['Youtube ID for 1.5x speed', '', False] ['YouTube ID for .75x speed', '', False],
['YouTube ID for 1.25x speed', '', False],
['YouTube ID for 1.5x speed', '', False]
] ]
world.verify_all_setting_entries(expected_entries) world.verify_all_setting_entries(expected_entries)
...@@ -167,11 +168,18 @@ def video_name_persisted(step): ...@@ -167,11 +168,18 @@ def video_name_persisted(step):
world.edit_component() world.edit_component()
world.verify_setting_entry( world.verify_setting_entry(
world.get_setting_entry('Display Name'), world.get_setting_entry(DISPLAY_NAME),
'Display Name', '3.4', True DISPLAY_NAME, '3.4', True
) )
@step('I can modify video display name')
def i_can_modify_video_display_name(_step):
index = world.get_setting_entry_index(DISPLAY_NAME)
world.set_field_value(index, '3.4')
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True)
@step('I upload transcript file(?:s)?:$') @step('I upload transcript file(?:s)?:$')
def upload_transcript(step): def upload_transcript(step):
input_hidden = '.metadata-video-translations .input' input_hidden = '.metadata-video-translations .input'
......
...@@ -9,7 +9,8 @@ from django.conf import settings ...@@ -9,7 +9,8 @@ from django.conf import settings
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from external_auth.views import ssl_login_shortcut, ssl_get_cert_from_request from external_auth.views import (ssl_login_shortcut, ssl_get_cert_from_request,
redirect_with_get)
from microsite_configuration import microsite from microsite_configuration import microsite
__all__ = ['signup', 'login_page', 'howitworks'] __all__ = ['signup', 'login_page', 'howitworks']
...@@ -26,7 +27,7 @@ def signup(request): ...@@ -26,7 +27,7 @@ def signup(request):
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'): if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
# Redirect to course to login to process their certificate if SSL is enabled # Redirect to course to login to process their certificate if SSL is enabled
# and registration is disabled. # and registration is disabled.
return redirect(reverse('login')) return redirect_with_get('login', request.GET, False)
return render_to_response('register.html', {'csrf': csrf_token}) return render_to_response('register.html', {'csrf': csrf_token})
...@@ -43,7 +44,15 @@ def login_page(request): ...@@ -43,7 +44,15 @@ def login_page(request):
# SSL login doesn't require a login view, so redirect # SSL login doesn't require a login view, so redirect
# to course now that the user is authenticated via # to course now that the user is authenticated via
# the decorator. # the decorator.
<<<<<<< HEAD
return redirect('/course/') return redirect('/course/')
=======
next_url = request.GET.get('next')
if next_url:
return redirect(next_url)
else:
return redirect('/course')
>>>>>>> edx/master
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
# If CAS is enabled, redirect auth handling to there # If CAS is enabled, redirect auth handling to there
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
......
...@@ -318,7 +318,7 @@ PIPELINE_CSS = { ...@@ -318,7 +318,7 @@ PIPELINE_CSS = {
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css', 'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/jquery.qtip.min.css', 'css/vendor/jquery.qtip.min.css',
'js/vendor/markitup/skins/simple/style.css', 'js/vendor/markitup/skins/simple/style.css',
'js/vendor/markitup/sets/wiki/style.css' 'js/vendor/markitup/sets/wiki/style.css',
], ],
'output_filename': 'css/cms-style-vendor.css', 'output_filename': 'css/cms-style-vendor.css',
}, },
......
...@@ -245,7 +245,6 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", ...@@ -245,7 +245,6 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI",
expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled'); expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
}); });
it('should be disabled on an empty page', function () { it('should be disabled on an empty page', function () {
var requests = create_sinon.requests(this); var requests = create_sinon.requests(this);
pagingView.setPage(0); pagingView.setPage(0);
...@@ -301,6 +300,31 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI", ...@@ -301,6 +300,31 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI",
}); });
}); });
describe("Page metadata section", function() {
it('shows the correct metadata for the current page', function () {
var requests = create_sinon.requests(this),
message;
pagingView.setPage(0);
respondWithMockAssets(requests);
message = pagingHeader.$('.meta').html().trim();
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
' out of <span class="count-total">4 total</span>, ' +
'sorted by <span class="sort-order">Date</span> descending</p>');
});
it('shows the correct metadata when sorted ascending', function () {
var requests = create_sinon.requests(this),
message;
pagingView.setPage(0);
pagingView.toggleSortOrder('name-col');
respondWithMockAssets(requests);
message = pagingHeader.$('.meta').html().trim();
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
' out of <span class="count-total">4 total</span>, ' +
'sorted by <span class="sort-order">Name</span> ascending</p>');
});
});
describe("Asset count label", function () { describe("Asset count label", function () {
it('should show correct count on first page', function () { it('should show correct count on first page', function () {
var requests = create_sinon.requests(this); var requests = create_sinon.requests(this);
......
...@@ -87,12 +87,6 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"] ...@@ -87,12 +87,6 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
return sortInfo.displayName; return sortInfo.displayName;
}, },
sortDirectionName: function() {
var collection = this.collection,
ascending = collection.sortDirection === 'asc';
return ascending ? gettext("ascending") : gettext("descending");
},
setInitialSortColumn: function(sortColumn) { setInitialSortColumn: function(sortColumn) {
var collection = this.collection, var collection = this.collection,
sortInfo = this.sortableColumns[sortColumn]; sortInfo = this.sortableColumns[sortColumn];
......
...@@ -31,27 +31,48 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base ...@@ -31,27 +31,48 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base
}, },
messageHtml: function() { messageHtml: function() {
var message;
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
}
return '<p>' + interpolate(message, {
current_item_range: this.currentItemRangeLabel(),
total_items_count: this.totalItemsCountLabel(),
sort_name: this.sortNameLabel()
}, true) + "</p>";
},
currentItemRangeLabel: function() {
var view = this.view, var view = this.view,
collection = view.collection, collection = view.collection,
start = collection.start, start = collection.start,
count = collection.size(), count = collection.size(),
sortName = view.sortDisplayName(), end = start + count;
sortDirectionName = view.sortDirectionName(), return interpolate('<span class="count-current-shown">%(start)s-%(end)s</span>', {
end = start + count,
total = collection.totalCount,
fmts = gettext('Showing %(current_span)s%(start)s-%(end)s%(end_span)s out of %(total_span)s%(total)s total%(end_span)s, sorted by %(order_span)s%(sort_order)s%(end_span)s %(sort_direction)s');
return '<p>' + interpolate(fmts, {
start: Math.min(start + 1, end), start: Math.min(start + 1, end),
end: end, end: end
total: total, }, true);
sort_order: sortName, },
sort_direction: sortDirectionName,
current_span: '<span class="count-current-shown">', totalItemsCountLabel: function() {
total_span: '<span class="count-total">', var totalItemsLabel;
order_span: '<span class="sort-order">', // Translators: turns into "25 total" to be used in other sentences, e.g. "Showing 0-9 out of 25 total".
end_span: '</span>' totalItemsLabel = interpolate(gettext('%(total_items)s total'), {
}, true) + "</p>"; total_items: this.view.collection.totalCount
}, true);
return interpolate('<span class="count-total">%(total_items_label)s</span>', {
total_items_label: totalItemsLabel
}, true);
},
sortNameLabel: function() {
return interpolate('<span class="sort-order">%(sort_name)s</span>', {
sort_name: this.view.sortDisplayName()
}, true);
}, },
nextPage: function() { nextPage: function() {
......
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
<%= gettext("Error.") %> <%= gettext("Error.") %>
</p> </p>
<div class="wrapper-transcripts-buttons"> <div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>"> <button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New Transcript") %>">
<%= gettext("Upload New Timed Transcript") %> <%= gettext("Upload New Transcript") %>
</button> </button>
<a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download Transcript for Editing") %>"> <a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<%= gettext("Download Transcript for Editing") %> <%= gettext("Download Transcript for Editing") %>
......
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
<%= gettext("Error.") %> <%= gettext("Error.") %>
</p> </p>
<div class="wrapper-transcripts-buttons"> <div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>"> <button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span> <span><%= gettext("Upload New Transcript") %></span>
</button> </button>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>"> <a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<span><%= gettext("Download Transcript for Editing") %></span> <span><%= gettext("Download Transcript for Editing") %></span>
......
...@@ -18,22 +18,22 @@ ...@@ -18,22 +18,22 @@
class="action setting-use-existing" class="action setting-use-existing"
type="button" type="button"
name="setting-use-existing" name="setting-use-existing"
value="<%= gettext("Use Current Timed Transcript") %>" value="<%= gettext("Use Current Transcript") %>"
data-tooltip="<%= gettext("Use Current Timed Transcript") %>" data-tooltip="<%= gettext("Use Current Transcript") %>"
> >
<span> <span>
<%= gettext("Use Current Timed Transcript") %> <%= gettext("Use Current Transcript") %>
</span> </span>
</button> </button>
<button <button
class="action setting-upload" class="action setting-upload"
type="button" type="button"
name="setting-upload" name="setting-upload"
value="<%= gettext("Upload New Timed Transcript") %>" value="<%= gettext("Upload New Transcript") %>"
data-tooltip="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Transcript") %>"
> >
<span> <span>
<%= gettext("Upload New Timed Transcript") %> <%= gettext("Upload New Transcript") %>
</span> </span>
</button> </button>
</div> </div>
...@@ -19,19 +19,33 @@ from mock import Mock ...@@ -19,19 +19,33 @@ from mock import Mock
from edxmako.middleware import MakoMiddleware from edxmako.middleware import MakoMiddleware
from external_auth.models import ExternalAuthMap from external_auth.models import ExternalAuthMap
import external_auth.views import external_auth.views
from student.roles import CourseStaffRole
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
<<<<<<< HEAD
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
=======
from student.models import CourseEnrollment
from xmodule.modulestore import Location
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.exceptions import InsufficientSpecificationError
from xmodule.modulestore.tests.django_utils import (ModuleStoreTestCase,
mixed_store_config)
from xmodule.modulestore.tests.factories import CourseFactory
>>>>>>> edx/master
FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy() FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy()
FEATURES_WITH_SSL_AUTH['AUTH_USE_CERTIFICATES'] = True FEATURES_WITH_SSL_AUTH['AUTH_USE_CERTIFICATES'] = True
FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = FEATURES_WITH_SSL_AUTH.copy() FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = FEATURES_WITH_SSL_AUTH.copy()
FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'] = True FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'] = True
FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE = FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP.copy()
FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True
FEATURES_WITHOUT_SSL_AUTH = settings.FEATURES.copy() FEATURES_WITHOUT_SSL_AUTH = settings.FEATURES.copy()
FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_CERTIFICATES'] = False FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_CERTIFICATES'] = False
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH) @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH)
class SSLClientTest(TestCase): class SSLClientTest(ModuleStoreTestCase):
""" """
Tests SSL Authentication code sections of external_auth Tests SSL Authentication code sections of external_auth
""" """
...@@ -168,7 +182,8 @@ class SSLClientTest(TestCase): ...@@ -168,7 +182,8 @@ class SSLClientTest(TestCase):
response = self.client.get( response = self.client.get(
reverse('dashboard'), follow=True, reverse('dashboard'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertIn(reverse('dashboard'), response['location']) self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session) self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
...@@ -181,7 +196,8 @@ class SSLClientTest(TestCase): ...@@ -181,7 +196,8 @@ class SSLClientTest(TestCase):
response = self.client.get( response = self.client.get(
reverse('register_user'), follow=True, reverse('register_user'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertIn(reverse('dashboard'), response['location']) self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session) self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
...@@ -228,7 +244,8 @@ class SSLClientTest(TestCase): ...@@ -228,7 +244,8 @@ class SSLClientTest(TestCase):
response = self.client.get( response = self.client.get(
reverse('signin_user'), follow=True, reverse('signin_user'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertIn(reverse('dashboard'), response['location']) self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session) self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
...@@ -318,3 +335,94 @@ class SSLClientTest(TestCase): ...@@ -318,3 +335,94 @@ class SSLClientTest(TestCase):
self.assertEqual(1, len(ExternalAuthMap.objects.all())) self.assertEqual(1, len(ExternalAuthMap.objects.all()))
self.assertTrue(self.mock.called) self.assertTrue(self.mock.called)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE,
MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
def test_ssl_lms_redirection(self):
"""
Auto signup auth user and ensure they return to the original
url they visited after being logged in.
"""
course = CourseFactory.create(
org='MITx',
number='999',
display_name='Robot Super Course'
)
external_auth.views.ssl_login(self._create_ssl_request('/'))
user = User.objects.get(email=self.USER_EMAIL)
CourseEnrollment.enroll(user, course.id)
course_private_url = '/courses/MITx/999/Robot_Super_Course/courseware'
self.assertFalse(SESSION_KEY in self.client.session)
response = self.client.get(
course_private_url,
follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL),
HTTP_ACCEPT='text/html'
)
self.assertEqual(('http://testserver{0}'.format(course_private_url), 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE)
def test_ssl_cms_redirection(self):
"""
Auto signup auth user and ensure they return to the original
url they visited after being logged in.
"""
course = CourseFactory.create(
org='MITx',
number='999',
display_name='Robot Super Course'
)
external_auth.views.ssl_login(self._create_ssl_request('/'))
user = User.objects.get(email=self.USER_EMAIL)
CourseEnrollment.enroll(user, course.id)
CourseStaffRole(course.location).add_users(user)
location = Location(['i4x', 'MITx', '999', 'course',
Location.clean('Robot Super Course'), None])
new_location = loc_mapper().translate_location(
location.course_id, location, True, True
)
course_private_url = new_location.url_reverse('course/', '')
self.assertFalse(SESSION_KEY in self.client.session)
response = self.client.get(
course_private_url,
follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL),
HTTP_ACCEPT='text/html'
)
self.assertEqual(('http://testserver{0}'.format(course_private_url), 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE)
def test_ssl_logout(self):
"""
Because the branding view is cached for anonymous users and we
use that to login users, the browser wasn't actually making the
request to that view as the redirect was being cached. This caused
a redirect loop, and this test confirms that that won't happen.
Test is only in LMS because we don't use / in studio to login SSL users.
"""
response = self.client.get(
reverse('dashboard'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session)
response = self.client.get(
reverse('logout'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)
)
# Make sure that even though we logged out, we have logged back in
self.assertIn(SESSION_KEY, self.client.session)
...@@ -440,7 +440,10 @@ def ssl_login(request): ...@@ -440,7 +440,10 @@ def ssl_login(request):
(_user, email, fullname) = _ssl_dn_extract_info(cert) (_user, email, fullname) = _ssl_dn_extract_info(cert)
retfun = functools.partial(redirect, '/') redirect_to = request.GET.get('next')
if not redirect_to:
redirect_to = '/'
retfun = functools.partial(redirect, redirect_to)
return _external_login_or_signup( return _external_login_or_signup(
request, request,
external_id=email, external_id=email,
...@@ -579,14 +582,14 @@ def course_specific_login(request, course_id): ...@@ -579,14 +582,14 @@ def course_specific_login(request, course_id):
course = student.views.course_from_id(course_id) course = student.views.course_from_id(course_id)
if not course: if not course:
# couldn't find the course, will just return vanilla signin page # couldn't find the course, will just return vanilla signin page
return _redirect_with_get_querydict('signin_user', request.GET) return redirect_with_get('signin_user', request.GET)
# now the dispatching conditionals. Only shib for now # now the dispatching conditionals. Only shib for now
if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX): if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
return _redirect_with_get_querydict('shib-login', request.GET) return redirect_with_get('shib-login', request.GET)
# Default fallthrough to normal signin page # Default fallthrough to normal signin page
return _redirect_with_get_querydict('signin_user', request.GET) return redirect_with_get('signin_user', request.GET)
def course_specific_register(request, course_id): def course_specific_register(request, course_id):
...@@ -598,24 +601,28 @@ def course_specific_register(request, course_id): ...@@ -598,24 +601,28 @@ def course_specific_register(request, course_id):
if not course: if not course:
# couldn't find the course, will just return vanilla registration page # couldn't find the course, will just return vanilla registration page
return _redirect_with_get_querydict('register_user', request.GET) return redirect_with_get('register_user', request.GET)
# now the dispatching conditionals. Only shib for now # now the dispatching conditionals. Only shib for now
if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX): if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
# shib-login takes care of both registration and login flows # shib-login takes care of both registration and login flows
return _redirect_with_get_querydict('shib-login', request.GET) return redirect_with_get('shib-login', request.GET)
# Default fallthrough to normal registration page # Default fallthrough to normal registration page
return _redirect_with_get_querydict('register_user', request.GET) return redirect_with_get('register_user', request.GET)
def _redirect_with_get_querydict(view_name, get_querydict): def redirect_with_get(view_name, get_querydict, do_reverse=True):
""" """
Helper function to carry over get parameters across redirects Helper function to carry over get parameters across redirects
Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded
""" """
if do_reverse:
url = reverse(view_name)
else:
url = view_name
if get_querydict: if get_querydict:
return redirect("%s?%s" % (reverse(view_name), get_querydict.urlencode(safe='/'))) return redirect("%s?%s" % (url, get_querydict.urlencode(safe='/')))
return redirect(view_name) return redirect(view_name)
......
'''
Firebase - library to generate a token
License: https://github.com/firebase/firebase-token-generator-python/blob/master/LICENSE
Tweaked and Edited by @danielcebrianr and @lduarte1991
This library will take either objects or strings and use python's built-in encoding
system as specified by RFC 3548. Thanks to the firebase team for their open-source
library. This was made specifically for speaking with the annotation_storage_url and
can be used and expanded, but not modified by anyone else needing such a process.
'''
from base64 import urlsafe_b64encode
import hashlib
import hmac
import sys
try:
import json
except ImportError:
import simplejson as json
__all__ = ['create_token']
TOKEN_SEP = '.'
def create_token(secret, data):
'''
Simply takes in the secret key and the data and
passes it to the local function _encode_token
'''
return _encode_token(secret, data)
if sys.version_info < (2, 7):
def _encode(bytes_data):
'''
Takes a json object, string, or binary and
uses python's urlsafe_b64encode to encode data
and make it safe pass along in a url.
To make sure it does not conflict with variables
we make sure equal signs are removed.
More info: docs.python.org/2/library/base64.html
'''
encoded = urlsafe_b64encode(bytes(bytes_data))
return encoded.decode('utf-8').replace('=', '')
else:
def _encode(bytes_info):
'''
Same as above function but for Python 2.7 or later
'''
encoded = urlsafe_b64encode(bytes_info)
return encoded.decode('utf-8').replace('=', '')
def _encode_json(obj):
'''
Before a python dict object can be properly encoded,
it must be transformed into a jason object and then
transformed into bytes to be encoded using the function
defined above.
'''
return _encode(bytearray(json.dumps(obj), 'utf-8'))
def _sign(secret, to_sign):
'''
This function creates a sign that goes at the end of the
message that is specific to the secret and not the actual
content of the encoded body.
More info on hashing: http://docs.python.org/2/library/hmac.html
The function creates a hashed values of the secret and to_sign
and returns the digested values based the secure hash
algorithm, 256
'''
def portable_bytes(string):
'''
Simply transforms a string into a bytes object,
which is a series of immutable integers 0<=x<=256.
Always try to encode as utf-8, unless it is not
compliant.
'''
try:
return bytes(string, 'utf-8')
except TypeError:
return bytes(string)
return _encode(hmac.new(portable_bytes(secret), portable_bytes(to_sign), hashlib.sha256).digest()) # pylint: disable=E1101
def _encode_token(secret, claims):
'''
This is the main function that takes the secret token and
the data to be transmitted. There is a header created for decoding
purposes. Token_SEP means that a period/full stop separates the
header, data object/message, and signatures.
'''
encoded_header = _encode_json({'typ': 'JWT', 'alg': 'HS256'})
encoded_claims = _encode_json(claims)
secure_bits = '%s%s%s' % (encoded_header, TOKEN_SEP, encoded_claims)
sig = _sign(secret, secure_bits)
return '%s%s%s' % (secure_bits, TOKEN_SEP, sig)
"""
This test will run for firebase_token_generator.py.
"""
from django.test import TestCase
from student.firebase_token_generator import _encode, _encode_json, _encode_token, create_token
class TokenGenerator(TestCase):
"""
Tests for the file firebase_token_generator.py
"""
def test_encode(self):
"""
This tests makes sure that no matter what version of python
you have, the _encode function still returns the appropriate result
for a string.
"""
expected = "dGVzdDE"
result = _encode("test1")
self.assertEqual(expected, result)
def test_encode_json(self):
"""
Same as above, but this one focuses on a python dict type
transformed into a json object and then encoded.
"""
expected = "eyJ0d28iOiAidGVzdDIiLCAib25lIjogInRlc3QxIn0"
result = _encode_json({'one': 'test1', 'two': 'test2'})
self.assertEqual(expected, result)
def test_create_token(self):
"""
Unlike its counterpart in student/views.py, this function
just checks for the encoding of a token. The other function
will test depending on time and user.
"""
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.-p1sr7uwCapidTQ0qB7DdU2dbF-hViKpPNN_5vD10t8"
result1 = _encode_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
result2 = create_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
self.assertEqual(expected, result1)
self.assertEqual(expected, result2)
...@@ -27,7 +27,7 @@ from mock import Mock, patch ...@@ -27,7 +27,7 @@ from mock import Mock, patch
from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
from student.views import (process_survey_link, _cert_info, from student.views import (process_survey_link, _cert_info,
change_enrollment, complete_course_mode_info, token) change_enrollment, complete_course_mode_info)
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
import shoppingcart import shoppingcart
...@@ -491,26 +491,3 @@ class AnonymousLookupTable(TestCase): ...@@ -491,26 +491,3 @@ class AnonymousLookupTable(TestCase):
anonymous_id = anonymous_id_for_user(self.user, self.course.id) anonymous_id = anonymous_id_for_user(self.user, self.course.id)
real_user = user_by_anonymous_id(anonymous_id) real_user = user_by_anonymous_id(anonymous_id)
self.assertEqual(self.user, real_user) self.assertEqual(self.user, real_user)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class Token(ModuleStoreTestCase):
"""
Test for the token generator. This creates a random course and passes it through the token file which generates the
token that will be passed in to the annotation_storage_url.
"""
request_factory = RequestFactory()
COURSE_SLUG = "100"
COURSE_NAME = "test_course"
COURSE_ORG = "edx"
def setUp(self):
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.user = User.objects.create(username="username", email="username")
self.req = self.request_factory.post('/token?course_id=edx/100/test_course', {'user': self.user})
self.req.user = self.user
def test_token(self):
expected = HttpResponse("eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAxLTIzVDE5OjM1OjE3LjUyMjEwNC01OjAwIiwgImNvbnN1bWVyS2V5IjogInh4eHh4eHh4LXh4eHgteHh4eC14eHh4LXh4eHh4eHh4eHh4eCIsICJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.OjWz9mzqJnYuzX-f3uCBllqJUa8PVWJjcDy_McfxLvc", mimetype="text/plain")
response = token(self.req)
self.assertEqual(expected.content.split('.')[0], response.content.split('.')[0])
...@@ -26,6 +26,7 @@ from django.shortcuts import redirect ...@@ -26,6 +26,7 @@ from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date, base36_to_int from django.utils.http import cookie_date, base36_to_int
from django.utils.translation import ugettext as _, get_language from django.utils.translation import ugettext as _, get_language
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST, require_GET from django.views.decorators.http import require_POST, require_GET
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
...@@ -43,7 +44,6 @@ from student.models import ( ...@@ -43,7 +44,6 @@ from student.models import (
create_comments_service_user, PasswordHistory create_comments_service_user, PasswordHistory
) )
from student.forms import PasswordResetFormNoActive from student.forms import PasswordResetFormNoActive
from student.firebase_token_generator import create_token
from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
...@@ -328,7 +328,7 @@ def signin_user(request): ...@@ -328,7 +328,7 @@ def signin_user(request):
# SSL login doesn't require a view, so redirect # SSL login doesn't require a view, so redirect
# branding and allow that to process the login if it # branding and allow that to process the login if it
# is enabled and the header is in the request. # is enabled and the header is in the request.
return redirect(reverse('root')) return external_auth.views.redirect_with_get('root', request.GET)
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
# If CAS is enabled, redirect auth handling to there # If CAS is enabled, redirect auth handling to there
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
...@@ -361,7 +361,7 @@ def register_user(request, extra_context=None): ...@@ -361,7 +361,7 @@ def register_user(request, extra_context=None):
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'): if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
# Redirect to branding to process their certificate if SSL is enabled # Redirect to branding to process their certificate if SSL is enabled
# and registration is disabled. # and registration is disabled.
return redirect(reverse('root')) return external_auth.views.redirect_with_get('root', request.GET)
context = { context = {
'course_id': request.GET.get('course_id'), 'course_id': request.GET.get('course_id'),
...@@ -676,6 +676,7 @@ def _get_course_enrollment_domain(course_id): ...@@ -676,6 +676,7 @@ def _get_course_enrollment_domain(course_id):
return course.enrollment_domain return course.enrollment_domain
@never_cache
@ensure_csrf_cookie @ensure_csrf_cookie
def accounts_login(request): def accounts_login(request):
""" """
...@@ -685,9 +686,9 @@ def accounts_login(request): ...@@ -685,9 +686,9 @@ def accounts_login(request):
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
if settings.FEATURES['AUTH_USE_CERTIFICATES']: if settings.FEATURES['AUTH_USE_CERTIFICATES']:
# SSL login doesn't require a view, so redirect # SSL login doesn't require a view, so login
# to branding and allow that to process the login. # directly here
return redirect(reverse('root')) return external_auth.views.ssl_login(request)
# see if the "next" parameter has been set, whether it has a course context, and if so, whether # see if the "next" parameter has been set, whether it has a course context, and if so, whether
# there is a course-specific place to redirect # there is a course-specific place to redirect
redirect_to = request.GET.get('next') redirect_to = request.GET.get('next')
...@@ -1855,6 +1856,7 @@ def change_email_settings(request): ...@@ -1855,6 +1856,7 @@ def change_email_settings(request):
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
return JsonResponse({"success": True}) return JsonResponse({"success": True})
<<<<<<< HEAD
@login_required @login_required
...@@ -1878,3 +1880,5 @@ def token(request): ...@@ -1878,3 +1880,5 @@ def token(request):
newtoken = create_token(secret, custom_data) newtoken = create_token(secret, custom_data)
response = HttpResponse(newtoken, mimetype="text/plain") response = HttpResponse(newtoken, mimetype="text/plain")
return response return response
=======
>>>>>>> edx/master
...@@ -620,6 +620,7 @@ class LoncapaProblem(object): ...@@ -620,6 +620,7 @@ class LoncapaProblem(object):
""" """
context = {} context = {}
context['seed'] = self.seed context['seed'] = self.seed
context['anonymous_student_id'] = self.capa_system.anonymous_student_id
all_code = '' all_code = ''
python_path = [] python_path = []
......
...@@ -73,6 +73,24 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -73,6 +73,24 @@ class CapaHtmlRenderTest(unittest.TestCase):
span_element = rendered_html.find('span') span_element = rendered_html.find('span')
self.assertEqual(span_element.text, 'Test text') self.assertEqual(span_element.text, 'Test text')
def test_anonymous_student_id(self):
# make sure anonymous_student_id is rendered properly as a context variable
xml_str = textwrap.dedent("""
<problem>
<span>Welcome $anonymous_student_id</span>
</problem>
""")
# Create the problem
problem = new_loncapa_problem(xml_str)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# Expect that the anonymous_student_id was converted to "student"
span_element = rendered_html.find('span')
self.assertEqual(span_element.text, 'Welcome student')
def test_render_script(self): def test_render_script(self):
# Generate some XML with a <script> tag # Generate some XML with a <script> tag
xml_str = textwrap.dedent(""" xml_str = textwrap.dedent("""
......
"""
This file contains a function used to retrieve the token for the annotation backend
without having to create a view, but just returning a string instead.
It can be called from other files by using the following:
from xmodule.annotator_token import retrieve_token
"""
import datetime
from firebase_token_generator import create_token
def retrieve_token(userid, secret):
'''
Return a token for the backend of annotations.
It uses the course id to retrieve a variable that contains the secret
token found in inheritance.py. It also contains information of when
the token was issued. This will be stored with the user along with
the id for identification purposes in the backend.
'''
# the following five lines of code allows you to include the default timezone in the iso format
# for more information: http://stackoverflow.com/questions/3401428/how-to-get-an-isoformat-datetime-string-including-the-default-timezone
dtnow = datetime.datetime.now()
dtutcnow = datetime.datetime.utcnow()
delta = dtnow - dtutcnow
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
# uses the issued time (UTC plus timezone), the consumer key and the user's email to maintain a
# federated system in the annotation backend server
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": userid, "ttl": 86400}
newtoken = create_token(secret, custom_data)
return newtoken
...@@ -177,59 +177,6 @@ ...@@ -177,59 +177,6 @@
}); });
}); });
describe('YouTube video in FireFox will cue first', function () {
var oldUserAgent;
beforeEach(function () {
oldUserAgent = window.navigator.userAgent;
window.navigator.userAgent = 'firefox';
state = jasmine.initializePlayer('video.html', {
start: 10,
end: 30
});
});
afterEach(function () {
window.navigator.userAgent = oldUserAgent;
});
it('cue is called, skipOnEndedStartEndReset is set', function () {
state.videoPlayer.updatePlayTime(10);
expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 10);
expect(state.videoPlayer.skipOnEndedStartEndReset).toBe(true);
});
it('when position is not 0: cue is called with stored position value', function () {
state.config.savedVideoPosition = 15;
state.videoPlayer.updatePlayTime(10);
expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 15);
});
it('Handling cue state', function () {
spyOn(state.videoPlayer, 'play');
state.videoPlayer.seekToTimeOnCued = 10;
state.videoPlayer.onStateChange({data: 5});
expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(10, true);
expect(state.videoPlayer.play).toHaveBeenCalled();
});
it('even when cued, onEnded does not resets start and end time', function () {
state.videoPlayer.skipOnEndedStartEndReset = true;
state.videoPlayer.onEnded();
expect(state.videoPlayer.startTime).toBe(10);
expect(state.videoPlayer.endTime).toBe(30);
state.videoPlayer.skipOnEndedStartEndReset = undefined;
state.videoPlayer.onEnded();
expect(state.videoPlayer.startTime).toBe(10);
expect(state.videoPlayer.endTime).toBe(30);
});
});
describe('checking start and end times', function () { describe('checking start and end times', function () {
var miniTestSuite = [ var miniTestSuite = [
{ {
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
state.storage.clear(); state.storage.clear();
window.Video.previousState = null;
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
}); });
...@@ -37,7 +38,7 @@ ...@@ -37,7 +38,7 @@
}); });
it('add ARIA attributes to time control', function () { it('add ARIA attributes to time control', function () {
var timeControl = $('div.slider>a'); var timeControl = $('div.slider > a');
expect(timeControl).toHaveAttrs({ expect(timeControl).toHaveAttrs({
'role': 'slider', 'role': 'slider',
...@@ -135,8 +136,6 @@ ...@@ -135,8 +136,6 @@
expectedValue = sliderEl.slider('option', 'value'); expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10); expect(expectedValue).toBe(10);
state.storage.clear();
}); });
}); });
...@@ -389,7 +388,7 @@ ...@@ -389,7 +388,7 @@
runs(function () { runs(function () {
state = jasmine.initializePlayer({ state = jasmine.initializePlayer({
end: 20, end: 20,
savedVideoPosition: 'a' savedVideoPosition: 'a'
}); });
sliderEl = state.videoProgressSlider.slider; sliderEl = state.videoProgressSlider.slider;
spyOn(state.videoPlayer, 'duration').andReturn(60); spyOn(state.videoPlayer, 'duration').andReturn(60);
......
...@@ -17,6 +17,7 @@ function (VideoPlayer) { ...@@ -17,6 +17,7 @@ function (VideoPlayer) {
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
window.Video.previousState = null;
if (state.storage) { if (state.storage) {
state.storage.clear(); state.storage.clear();
} }
...@@ -179,6 +180,11 @@ function (VideoPlayer) { ...@@ -179,6 +180,11 @@ function (VideoPlayer) {
it('autoplay the first video', function () { it('autoplay the first video', function () {
expect(state.videoPlayer.play).not.toHaveBeenCalled(); expect(state.videoPlayer.play).not.toHaveBeenCalled();
}); });
it('invalid endTime is reset to null', function () {
expect(state.videoPlayer.endTime).toBe(null);
});
}); });
describe('onReady YouTube', function () { describe('onReady YouTube', function () {
...@@ -752,17 +758,6 @@ function (VideoPlayer) { ...@@ -752,17 +758,6 @@ function (VideoPlayer) {
isFlashMode: jasmine.createSpy().andReturn(false) isFlashMode: jasmine.createSpy().andReturn(false)
}; };
}); });
it('invalid endTime is reset to null', function () {
VideoPlayer.prototype.updatePlayTime.call(state, 0);
expect(state.videoPlayer.figureOutStartingTime).toHaveBeenCalled();
VideoPlayer.prototype.figureOutStartEndTime.call(state, 60);
VideoPlayer.prototype.figureOutStartingTime.call(state, 60);
expect(state.videoPlayer.endTime).toBe(null);
});
}); });
describe('toggleFullScreen', function () { describe('toggleFullScreen', function () {
...@@ -1087,9 +1082,12 @@ function (VideoPlayer) { ...@@ -1087,9 +1082,12 @@ function (VideoPlayer) {
isHtml5Mode: jasmine.createSpy().andReturn(true), isHtml5Mode: jasmine.createSpy().andReturn(true),
isYoutubeType: jasmine.createSpy().andReturn(true), isYoutubeType: jasmine.createSpy().andReturn(true),
setPlayerMode: jasmine.createSpy(), setPlayerMode: jasmine.createSpy(),
trigger: jasmine.createSpy(),
videoPlayer: { videoPlayer: {
currentTime: 60, currentTime: 60,
isPlaying: jasmine.createSpy(), isPlaying: jasmine.createSpy(),
seekTo: jasmine.createSpy(),
duration: jasmine.createSpy().andReturn(60),
updatePlayTime: jasmine.createSpy(), updatePlayTime: jasmine.createSpy(),
setPlaybackRate: jasmine.createSpy(), setPlaybackRate: jasmine.createSpy(),
player: jasmine.createSpyObj('player', [ player: jasmine.createSpyObj('player', [
...@@ -1115,6 +1113,12 @@ function (VideoPlayer) { ...@@ -1115,6 +1113,12 @@ function (VideoPlayer) {
state.videoPlayer.isPlaying.andReturn(false); state.videoPlayer.isPlaying.andReturn(false);
VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); VideoPlayer.prototype.setPlaybackRate.call(state, '0.75');
expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60);
expect(state.videoPlayer.seekTo).toHaveBeenCalledWith(60);
expect(state.trigger).toHaveBeenCalledWith(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: 60
});
expect(state.videoPlayer.player.cueVideoById) expect(state.videoPlayer.player.cueVideoById)
.toHaveBeenCalledWith('videoId', 60); .toHaveBeenCalledWith('videoId', 60);
}); });
......
...@@ -44,6 +44,7 @@ function (HTML5Video, Resizer) { ...@@ -44,6 +44,7 @@ function (HTML5Video, Resizer) {
onVolumeChange: onVolumeChange, onVolumeChange: onVolumeChange,
pause: pause, pause: pause,
play: play, play: play,
seekTo: seekTo,
setPlaybackRate: setPlaybackRate, setPlaybackRate: setPlaybackRate,
update: update, update: update,
figureOutStartEndTime: figureOutStartEndTime, figureOutStartEndTime: figureOutStartEndTime,
...@@ -94,7 +95,7 @@ function (HTML5Video, Resizer) { ...@@ -94,7 +95,7 @@ function (HTML5Video, Resizer) {
state.videoPlayer.ready = _.once(function () { state.videoPlayer.ready = _.once(function () {
$(window).on('unload', state.saveState); $(window).on('unload', state.saveState);
if (!state.isFlashMode()) { if (!state.isFlashMode() && state.speed != '1.0') {
state.videoPlayer.setPlaybackRate(state.speed); state.videoPlayer.setPlaybackRate(state.speed);
} }
state.videoPlayer.player.setVolume(state.currentVolume); state.videoPlayer.player.setVolume(state.currentVolume);
...@@ -352,7 +353,8 @@ function (HTML5Video, Resizer) { ...@@ -352,7 +353,8 @@ function (HTML5Video, Resizer) {
} }
function setPlaybackRate(newSpeed) { function setPlaybackRate(newSpeed) {
var time = this.videoPlayer.currentTime, var duration = this.videoPlayer.duration(),
time = this.videoPlayer.currentTime,
methodName, youtubeId; methodName, youtubeId;
if ( if (
...@@ -378,7 +380,22 @@ function (HTML5Video, Resizer) { ...@@ -378,7 +380,22 @@ function (HTML5Video, Resizer) {
} }
this.videoPlayer.player[methodName](youtubeId, time); this.videoPlayer.player[methodName](youtubeId, time);
// We need to call play() explicitly because after the call
// to functions cueVideoById() followed by seekTo() the video
// is in a PAUSED state.
//
// Why? This is how the YouTube API is implemented.
this.videoPlayer.updatePlayTime(time); this.videoPlayer.updatePlayTime(time);
if (time > 0 && this.isFlashMode()) {
this.videoPlayer.seekTo(time);
this.trigger(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: duration
}
);
}
} }
} }
...@@ -414,59 +431,62 @@ function (HTML5Video, Resizer) { ...@@ -414,59 +431,62 @@ function (HTML5Video, Resizer) {
// It is created on a onPlay event. Cleared on a onPause event. // It is created on a onPlay event. Cleared on a onPause event.
// Reinitialized on a onSeek event. // Reinitialized on a onSeek event.
function onSeek(params) { function onSeek(params) {
var duration = this.videoPlayer.duration(), var time = params.time,
newTime = params.time; type = params.type;
if ( // After the user seeks, the video will start playing from
(typeof newTime !== 'number') || // the sought point, and stop playing at the end.
(newTime > duration) || this.videoPlayer.goToStartTime = false;
(newTime < 0) if (time > this.videoPlayer.endTime || this.videoPlayer.endTime === null) {
) { this.videoPlayer.stopAtEndTime = false;
return;
} }
this.el.off('play.seek'); this.videoPlayer.seekTo(time);
this.videoPlayer.log( this.videoPlayer.log(
'seek_video', 'seek_video',
{ {
old_time: this.videoPlayer.currentTime, old_time: this.videoPlayer.currentTime,
new_time: newTime, new_time: time,
type: params.type type: type
} }
); );
}
// After the user seeks, the video will start playing from function seekTo(time) {
// the sought point, and stop playing at the end. var duration = this.videoPlayer.duration();
this.videoPlayer.goToStartTime = false;
if (newTime > this.videoPlayer.endTime || this.videoPlayer.endTime === null) { if ((typeof time !== 'number') || (time > duration) || (time < 0)) {
this.videoPlayer.stopAtEndTime = false; return false;
} }
this.el.off('play.seek');
if (this.videoPlayer.isPlaying()) { if (this.videoPlayer.isPlaying()) {
this.videoPlayer.stopTimer(); this.videoPlayer.stopTimer();
} else { } else {
this.videoPlayer.currentTime = newTime; this.videoPlayer.currentTime = time;
} }
var isUnplayed = this.videoPlayer.isUnstarted() || var isUnplayed = this.videoPlayer.isUnstarted() ||
this.videoPlayer.isCued(); this.videoPlayer.isCued();
// Use `cueVideoById` method for youtube video that is not played before. // Use `cueVideoById` method for youtube video that is not played before.
if (isUnplayed && this.isYoutubeType()) { if (isUnplayed && this.isYoutubeType()) {
this.videoPlayer.player.cueVideoById(this.youtubeId(), newTime); this.videoPlayer.player.cueVideoById(this.youtubeId(), time);
} else { } else {
// Youtube video cannot be rewinded during bufferization, so wait to // Youtube video cannot be rewinded during bufferization, so wait to
// finish bufferization and then rewind the video. // finish bufferization and then rewind the video.
if (this.isYoutubeType() && this.videoPlayer.isBuffering()) { if (this.isYoutubeType() && this.videoPlayer.isBuffering()) {
this.el.on('play.seek', function () { this.el.on('play.seek', function () {
this.videoPlayer.player.seekTo(newTime, true); this.videoPlayer.player.seekTo(time, true);
}.bind(this)); }.bind(this));
} else { } else {
// Otherwise, just seek the video // Otherwise, just seek the video
this.videoPlayer.player.seekTo(newTime, true); this.videoPlayer.player.seekTo(time, true);
} }
} }
this.videoPlayer.updatePlayTime(newTime, true); this.videoPlayer.updatePlayTime(time, true);
this.el.trigger('seek', arguments); this.el.trigger('seek', arguments);
} }
...@@ -609,6 +629,7 @@ function (HTML5Video, Resizer) { ...@@ -609,6 +629,7 @@ function (HTML5Video, Resizer) {
// have 1 speed available, we fall back to Flash. // have 1 speed available, we fall back to Flash.
_restartUsingFlash(this); _restartUsingFlash(this);
return false;
} else if (availablePlaybackRates.length > 1) { } else if (availablePlaybackRates.length > 1) {
this.setPlayerMode('html5'); this.setPlayerMode('html5');
...@@ -646,16 +667,15 @@ function (HTML5Video, Resizer) { ...@@ -646,16 +667,15 @@ function (HTML5Video, Resizer) {
this.videoPlayer.player.setPlaybackRate(this.speed); this.videoPlayer.player.setPlaybackRate(this.speed);
} }
this.el.trigger('ready', arguments);
/* The following has been commented out to make sure autoplay is var duration = this.videoPlayer.duration(),
disabled for students. time = this.videoPlayer.figureOutStartingTime(duration);
if (
!this.isTouch && if (time > 0 && this.videoPlayer.goToStartTime) {
$('.video:first').data('autoplay') === 'True' this.videoPlayer.seekTo(time);
) {
this.videoPlayer.play();
} }
*/
this.el.trigger('ready', arguments);
} }
function onStateChange(event) { function onStateChange(event) {
...@@ -687,13 +707,9 @@ function (HTML5Video, Resizer) { ...@@ -687,13 +707,9 @@ function (HTML5Video, Resizer) {
break; break;
case this.videoPlayer.PlayerState.CUED: case this.videoPlayer.PlayerState.CUED:
this.el.addClass('is-cued'); this.el.addClass('is-cued');
this.videoPlayer.player.seekTo(this.videoPlayer.seekToTimeOnCued, true); if (this.isFlashMode()) {
// We need to call play() explicitly because after the call this.videoPlayer.play();
// to functions cueVideoById() followed by seekTo() the video }
// is in a PAUSED state.
//
// Why? This is how the YouTube API is implemented.
this.videoPlayer.play();
break; break;
} }
} }
...@@ -769,57 +785,6 @@ function (HTML5Video, Resizer) { ...@@ -769,57 +785,6 @@ function (HTML5Video, Resizer) {
duration = this.videoPlayer.duration(), duration = this.videoPlayer.duration(),
youTubeId; youTubeId;
if (duration > 0 && videoPlayer.goToStartTime && !skip_seek) {
videoPlayer.goToStartTime = false;
// The duration might have changed. Update the start-end time region to
// reflect this fact.
this.trigger(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: duration
}
);
time = videoPlayer.figureOutStartingTime(duration);
// When the video finishes playing, we will start from the
// start-time, or from the beginning (rather than from the remembered
// position).
this.config.savedVideoPosition = 0;
if (time > 0) {
// After a bug came up (BLD-708: "In Firefox YouTube video with
// start-time plays from 00:00:00") the video refused to play
// from start-time, and only played from the beginning.
//
// It turned out that for some reason if Firefox you couldn't
// seek beyond some amount of time before the video loaded.
// Very strange, but in Chrome there is no such bug.
//
// HTML5 video sources play fine from start-time in both Chrome
// and Firefox.
if (this.browserIsFirefox && this.isYoutubeType()) {
youTubeId = this.youtubeId();
// When we will call cueVideoById() for some strange reason
// an ENDED event will be fired. It really does no damage
// except for the fact that the end-time is reset to null.
// We do not want this.
//
// The flag `skipOnEndedStartEndReset` will notify the
// onEnded() callback for the ENDED event that there
// is no need in resetting the start-time and end-time.
videoPlayer.skipOnEndedStartEndReset = true;
videoPlayer.seekToTimeOnCued = time;
videoPlayer.player.cueVideoById(youTubeId, time);
} else {
videoPlayer.player.seekTo(time);
}
}
}
this.trigger( this.trigger(
'videoProgressSlider.updatePlayTime', 'videoProgressSlider.updatePlayTime',
{ {
......
...@@ -57,18 +57,11 @@ ...@@ -57,18 +57,11 @@
VideoCaption VideoCaption
) { ) {
var youtubeXhr = null, var youtubeXhr = null,
oldVideo = window.Video, oldVideo = window.Video;
// Because this constructor can be called multiple times on a single page (when the user switches
// verticals, the page doesn't reload, but the content changes), we must will check each time if there
// is a previous copy of 'state' object. If there is, we will make sure that copy exists cleanly. We
// have to do this because when verticals switch, the code does not handle any Xmodule JS code that is
// running - it simply removes DOM elements from the page. Any functions that were running during this,
// and that will run afterwards (expecting the DOM elements to be present) must be stopped by hand.
previousState = null;
window.Video = function (element) { window.Video = function (element) {
var state; var previousState = window.Video.previousState,
state;
// Check for existance of previous state, uninitialize it if necessary, and create a new state. Store // Check for existance of previous state, uninitialize it if necessary, and create a new state. Store
// new state for future invocation of this module consturctor function. // new state for future invocation of this module consturctor function.
...@@ -78,7 +71,13 @@ ...@@ -78,7 +71,13 @@
} }
state = {}; state = {};
previousState = state; // Because this constructor can be called multiple times on a single page (when the user switches
// verticals, the page doesn't reload, but the content changes), we must will check each time if there
// is a previous copy of 'state' object. If there is, we will make sure that copy exists cleanly. We
// have to do this because when verticals switch, the code does not handle any Xmodule JS code that is
// running - it simply removes DOM elements from the page. Any functions that were running during this,
// and that will run afterwards (expecting the DOM elements to be present) must be stopped by hand.
window.Video.previousState = state;
state.modules = [ state.modules = [
FocusGrabber, FocusGrabber,
......
"""
This test will run for annotator_token.py
"""
import unittest
from xmodule.annotator_token import retrieve_token
class TokenRetriever(unittest.TestCase):
"""
Tests to make sure that when passed in a username and secret token, that it will be encoded correctly
"""
def test_token(self):
"""
Test for the token generator. Give an a random username and secret token, it should create the properly encoded string of text.
"""
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAyLTI3VDE3OjAwOjQyLjQwNjQ0MSswOjAwIiwgImNvbnN1bWVyS2V5IjogImZha2Vfc2VjcmV0IiwgInVzZXJJZCI6ICJ1c2VybmFtZSIsICJ0dGwiOiA4NjQwMH0.Dx1PoF-7mqBOOSGDMZ9R_s3oaaLRPnn6CJgGGF2A5CQ"
response = retrieve_token("username", "fake_secret")
self.assertEqual(expected.split('.')[0], response.split('.')[0])
self.assertNotEqual(expected.split('.')[2], response.split('.')[2])
\ No newline at end of file
...@@ -38,17 +38,6 @@ class TextAnnotationModuleTestCase(unittest.TestCase): ...@@ -38,17 +38,6 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
ScopeIds(None, None, None, None) ScopeIds(None, None, None, None)
) )
def test_render_content(self):
"""
Tests to make sure the sample xml is rendered and that it forms a valid xmltree
that does not contain a display_name.
"""
content = self.mod._render_content() # pylint: disable=W0212
self.assertIsNotNone(content)
element = etree.fromstring(content)
self.assertIsNotNone(element)
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
def test_extract_instructions(self): def test_extract_instructions(self):
""" """
Tests to make sure that the instructions are correctly pulled from the sample xml above. Tests to make sure that the instructions are correctly pulled from the sample xml above.
...@@ -70,5 +59,5 @@ class TextAnnotationModuleTestCase(unittest.TestCase): ...@@ -70,5 +59,5 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
Tests the function that passes in all the information in the context that will be used in templates/textannotation.html Tests the function that passes in all the information in the context that will be used in templates/textannotation.html
""" """
context = self.mod.get_html() context = self.mod.get_html()
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage']: for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage', 'token']:
self.assertIn(key, context) self.assertIn(key, context)
...@@ -34,100 +34,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase): ...@@ -34,100 +34,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
ScopeIds(None, None, None, None) ScopeIds(None, None, None, None)
) )
def test_annotation_class_attr_default(self):
"""
Makes sure that it can detect annotation values in text-form if user
decides to add text to the area below video, video functionality is completely
found in javascript.
"""
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
element = etree.fromstring(xml)
expected_attr = {'class': {'value': 'annotatable-span highlight'}}
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
self.assertIsInstance(actual_attr, dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_valid_highlight(self):
"""
Same as above but more specific to an area that is highlightable in the appropriate
color designated.
"""
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for color in self.mod.highlight_colors:
element = etree.fromstring(xml.format(highlight=color))
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
expected_attr = {'class': {
'value': value,
'_delete': 'highlight'}
}
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
self.assertIsInstance(actual_attr, dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_invalid_highlight(self):
"""
Same as above, but checked with invalid colors.
"""
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
element = etree.fromstring(xml.format(highlight=invalid_color))
expected_attr = {'class': {
'value': 'annotatable-span highlight',
'_delete': 'highlight'}
}
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
self.assertIsInstance(actual_attr, dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_data_attr(self):
"""
Test that each highlight contains the data information from the annotation itself.
"""
element = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
expected_attr = {
'data-comment-body': {'value': 'foo', '_delete': 'body'},
'data-comment-title': {'value': 'bar', '_delete': 'title'},
'data-problem-id': {'value': '0', '_delete': 'problem'}
}
actual_attr = self.mod._get_annotation_data_attr(element) # pylint: disable=W0212
self.assertIsInstance(actual_attr, dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_render_annotation(self):
"""
Tests to make sure that the spans designating annotations acutally visually render as annotations.
"""
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
expected_el = etree.fromstring(expected_html)
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
self.mod._render_annotation(actual_el) # pylint: disable=W0212
self.assertEqual(expected_el.tag, actual_el.tag)
self.assertEqual(expected_el.text, actual_el.text)
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
def test_render_content(self):
"""
Like above, but using the entire text, it makes sure that display_name is removed and that there is only one
div encompassing the annotatable area.
"""
content = self.mod._render_content() # pylint: disable=W0212
element = etree.fromstring(content)
self.assertIsNotNone(element)
self.assertEqual('div', element.tag, 'root tag is a div')
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
def test_extract_instructions(self): def test_extract_instructions(self):
""" """
This test ensures that if an instruction exists it is pulled and This test ensures that if an instruction exists it is pulled and
...@@ -160,6 +66,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase): ...@@ -160,6 +66,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
""" """
Tests to make sure variables passed in truly exist within the html once it is all rendered. Tests to make sure variables passed in truly exist within the html once it is all rendered.
""" """
context = self.mod.get_html() context = self.mod.get_html() # pylint: disable=W0212
for key in ['display_name', 'content_html', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'alert', 'annotation_storage']: for key in ['display_name', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'annotation_storage']:
self.assertIn(key, context) self.assertIn(key, context)
...@@ -6,6 +6,7 @@ from pkg_resources import resource_string ...@@ -6,6 +6,7 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from xblock.core import Scope, String from xblock.core import Scope, String
from xmodule.annotator_token import retrieve_token
import textwrap import textwrap
...@@ -30,7 +31,7 @@ class AnnotatableFields(object): ...@@ -30,7 +31,7 @@ class AnnotatableFields(object):
scope=Scope.settings, scope=Scope.settings,
default='Text Annotation', default='Text Annotation',
) )
tags = String( instructor_tags = String(
display_name="Tags for Assignments", display_name="Tags for Assignments",
help="Add tags that automatically highlight in a certain color using the comma-separated form, i.e. imagery:red,parallelism:blue", help="Add tags that automatically highlight in a certain color using the comma-separated form, i.e. imagery:red,parallelism:blue",
scope=Scope.settings, scope=Scope.settings,
...@@ -43,6 +44,7 @@ class AnnotatableFields(object): ...@@ -43,6 +44,7 @@ class AnnotatableFields(object):
default='None', default='None',
) )
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage") annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
class TextAnnotationModule(AnnotatableFields, XModule): class TextAnnotationModule(AnnotatableFields, XModule):
...@@ -59,15 +61,9 @@ class TextAnnotationModule(AnnotatableFields, XModule): ...@@ -59,15 +61,9 @@ class TextAnnotationModule(AnnotatableFields, XModule):
self.instructions = self._extract_instructions(xmltree) self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode') self.content = etree.tostring(xmltree, encoding='unicode')
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] self.user_email = ""
if self.runtime.get_real_user is not None:
def _render_content(self): self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
""" Renders annotatable content with annotation spans and returns HTML. """
xmltree = etree.fromstring(self.content)
if 'display_name' in xmltree.attrib:
del xmltree.attrib['display_name']
return etree.tostring(xmltree, encoding='unicode')
def _extract_instructions(self, xmltree): def _extract_instructions(self, xmltree):
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """ """ Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
...@@ -82,13 +78,13 @@ class TextAnnotationModule(AnnotatableFields, XModule): ...@@ -82,13 +78,13 @@ class TextAnnotationModule(AnnotatableFields, XModule):
""" Renders parameters to template. """ """ Renders parameters to template. """
context = { context = {
'display_name': self.display_name_with_default, 'display_name': self.display_name_with_default,
'tag': self.tags, 'tag': self.instructor_tags,
'source': self.source, 'source': self.source,
'instructions_html': self.instructions, 'instructions_html': self.instructions,
'content_html': self._render_content(), 'content_html': self.content,
'annotation_storage': self.annotation_storage_url 'annotation_storage': self.annotation_storage_url,
'token': retrieve_token(self.user_email, self.annotation_token_secret),
} }
return self.system.render_template('textannotation.html', context) return self.system.render_template('textannotation.html', context)
...@@ -101,6 +97,7 @@ class TextAnnotationDescriptor(AnnotatableFields, RawDescriptor): ...@@ -101,6 +97,7 @@ class TextAnnotationDescriptor(AnnotatableFields, RawDescriptor):
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):
non_editable_fields = super(TextAnnotationDescriptor, self).non_editable_metadata_fields non_editable_fields = super(TextAnnotationDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([ non_editable_fields.extend([
TextAnnotationDescriptor.annotation_storage_url TextAnnotationDescriptor.annotation_storage_url,
TextAnnotationDescriptor.annotation_token_secret,
]) ])
return non_editable_fields return non_editable_fields
...@@ -7,6 +7,7 @@ from pkg_resources import resource_string ...@@ -7,6 +7,7 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from xblock.core import Scope, String from xblock.core import Scope, String
from xmodule.annotator_token import retrieve_token
import textwrap import textwrap
...@@ -31,7 +32,7 @@ class AnnotatableFields(object): ...@@ -31,7 +32,7 @@ class AnnotatableFields(object):
sourceurl = String(help="The external source URL for the video.", display_name="Source URL", scope=Scope.settings, default="http://video-js.zencoder.com/oceans-clip.mp4") sourceurl = String(help="The external source URL for the video.", display_name="Source URL", scope=Scope.settings, default="http://video-js.zencoder.com/oceans-clip.mp4")
poster_url = String(help="Poster Image URL", display_name="Poster URL", scope=Scope.settings, default="") poster_url = String(help="Poster Image URL", display_name="Poster URL", scope=Scope.settings, default="")
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage") annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
class VideoAnnotationModule(AnnotatableFields, XModule): class VideoAnnotationModule(AnnotatableFields, XModule):
'''Video Annotation Module''' '''Video Annotation Module'''
...@@ -55,73 +56,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule): ...@@ -55,73 +56,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
self.instructions = self._extract_instructions(xmltree) self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode') self.content = etree.tostring(xmltree, encoding='unicode')
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] self.user_email = ""
if self.runtime.get_real_user is not None:
def _get_annotation_class_attr(self, element): self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
""" Returns a dict with the CSS class attribute to set on the annotation
and an XML key to delete from the element.
"""
attr = {}
cls = ['annotatable-span', 'highlight']
highlight_key = 'highlight'
color = element.get(highlight_key)
if color is not None:
if color in self.highlight_colors:
cls.append('highlight-' + color)
attr['_delete'] = highlight_key
attr['value'] = ' '.join(cls)
return {'class': attr}
def _get_annotation_data_attr(self, element):
""" Returns a dict in which the keys are the HTML data attributes
to set on the annotation element. Each data attribute has a
corresponding 'value' and (optional) '_delete' key to specify
an XML attribute to delete.
"""
data_attrs = {}
attrs_map = {
'body': 'data-comment-body',
'title': 'data-comment-title',
'problem': 'data-problem-id'
}
for xml_key in attrs_map.keys():
if xml_key in element.attrib:
value = element.get(xml_key, '')
html_key = attrs_map[xml_key]
data_attrs[html_key] = {'value': value, '_delete': xml_key}
return data_attrs
def _render_annotation(self, element):
""" Renders an annotation element for HTML output. """
attr = {}
attr.update(self._get_annotation_class_attr(element))
attr.update(self._get_annotation_data_attr(element))
element.tag = 'span'
for key in attr.keys():
element.set(key, attr[key]['value'])
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
delete_key = attr[key]['_delete']
del element.attrib[delete_key]
def _render_content(self):
""" Renders annotatable content with annotation spans and returns HTML. """
xmltree = etree.fromstring(self.content)
xmltree.tag = 'div'
if 'display_name' in xmltree.attrib:
del xmltree.attrib['display_name']
for element in xmltree.findall('.//annotation'):
self._render_annotation(element)
return etree.tostring(xmltree, encoding='unicode')
def _extract_instructions(self, xmltree): def _extract_instructions(self, xmltree):
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """ """ Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
...@@ -154,9 +91,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule): ...@@ -154,9 +91,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
'sourceUrl': self.sourceurl, 'sourceUrl': self.sourceurl,
'typeSource': extension, 'typeSource': extension,
'poster': self.poster_url, 'poster': self.poster_url,
'alert': self, 'content_html': self.content,
'content_html': self._render_content(), 'annotation_storage': self.annotation_storage_url,
'annotation_storage': self.annotation_storage_url 'token': retrieve_token(self.user_email, self.annotation_token_secret),
} }
return self.system.render_template('videoannotation.html', context) return self.system.render_template('videoannotation.html', context)
...@@ -171,6 +108,7 @@ class VideoAnnotationDescriptor(AnnotatableFields, RawDescriptor): ...@@ -171,6 +108,7 @@ class VideoAnnotationDescriptor(AnnotatableFields, RawDescriptor):
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):
non_editable_fields = super(VideoAnnotationDescriptor, self).non_editable_metadata_fields non_editable_fields = super(VideoAnnotationDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([ non_editable_fields.extend([
VideoAnnotationDescriptor.annotation_storage_url VideoAnnotationDescriptor.annotation_storage_url,
VideoAnnotationDescriptor.annotation_token_secret,
]) ])
return non_editable_fields return non_editable_fields
Annotator.Plugin.Auth.prototype.haveValidToken = function() {
return (
this._unsafeToken &&
this._unsafeToken.d.issuedAt &&
this._unsafeToken.d.ttl &&
this._unsafeToken.d.consumerKey &&
this.timeToExpiry() > 0
);
};
Annotator.Plugin.Auth.prototype.timeToExpiry = function() {
var expiry, issue, now, timeToExpiry;
now = new Date().getTime() / 1000;
issue = createDateFromISO8601(this._unsafeToken.d.issuedAt).getTime() / 1000;
expiry = issue + this._unsafeToken.d.ttl;
timeToExpiry = expiry - now;
if (timeToExpiry > 0) {
return timeToExpiry;
} else {
return 0;
}
};
\ No newline at end of file
...@@ -25,6 +25,12 @@ def index(request): ...@@ -25,6 +25,12 @@ def index(request):
if settings.FEATURES.get('AUTH_USE_CERTIFICATES'): if settings.FEATURES.get('AUTH_USE_CERTIFICATES'):
from external_auth.views import ssl_login from external_auth.views import ssl_login
# Set next URL to dashboard if it isn't set to avoid
# caching a redirect to / that causes a redirect loop on logout
if not request.GET.get('next'):
req_new = request.GET.copy()
req_new['next'] = reverse('dashboard')
request.GET = req_new
return ssl_login(request) return ssl_login(request)
enable_mktg_site = microsite.get_value( enable_mktg_site = microsite.get_value(
......
...@@ -21,7 +21,7 @@ from xmodule.modulestore.keys import CourseKey ...@@ -21,7 +21,7 @@ from xmodule.modulestore.keys import CourseKey
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
GIT_REPO_DIR = getattr(settings, 'GIT_REPO_DIR', '/opt/edx/course_repos') GIT_REPO_DIR = getattr(settings, 'GIT_REPO_DIR', '/edx/var/app/edxapp/course_repos')
GIT_IMPORT_STATIC = getattr(settings, 'GIT_IMPORT_STATIC', True) GIT_IMPORT_STATIC = getattr(settings, 'GIT_IMPORT_STATIC', True)
......
...@@ -27,6 +27,7 @@ from django.views.decorators.http import condition ...@@ -27,6 +27,7 @@ from django.views.decorators.http import condition
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
import mongoengine import mongoengine
from path import path
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
import dashboard.git_import as git_import import dashboard.git_import as git_import
...@@ -330,8 +331,12 @@ class Courses(SysadminDashboardView): ...@@ -330,8 +331,12 @@ class Courses(SysadminDashboardView):
cmd = '' cmd = ''
gdir = settings.DATA_DIR / cdir gdir = settings.DATA_DIR / cdir
info = ['', '', ''] info = ['', '', '']
if not os.path.exists(gdir):
return info # Try the data dir, then try to find it in the git import dir
if not gdir.exists():
gdir = path(git_import.GIT_REPO_DIR) / cdir
if not gdir.exists():
return info
cmd = ['git', 'log', '-1', cmd = ['git', 'log', '-1',
'--format=format:{ "commit": "%H", "author": "%an %ae", "date": "%ad"}', ] '--format=format:{ "commit": "%H", "author": "%an %ae", "date": "%ad"}', ]
...@@ -345,7 +350,7 @@ class Courses(SysadminDashboardView): ...@@ -345,7 +350,7 @@ class Courses(SysadminDashboardView):
return info return info
def get_course_from_git(self, gitloc, branch, datatable): def get_course_from_git(self, gitloc, branch):
"""This downloads and runs the checks for importing a course in git""" """This downloads and runs the checks for importing a course in git"""
if not (gitloc.endswith('.git') or gitloc.startswith('http:') or if not (gitloc.endswith('.git') or gitloc.startswith('http:') or
...@@ -356,7 +361,7 @@ class Courses(SysadminDashboardView): ...@@ -356,7 +361,7 @@ class Courses(SysadminDashboardView):
if self.is_using_mongo: if self.is_using_mongo:
return self.import_mongo_course(gitloc, branch) return self.import_mongo_course(gitloc, branch)
return self.import_xml_course(gitloc, branch, datatable) return self.import_xml_course(gitloc, branch)
def import_mongo_course(self, gitloc, branch): def import_mongo_course(self, gitloc, branch):
""" """
...@@ -408,7 +413,7 @@ class Courses(SysadminDashboardView): ...@@ -408,7 +413,7 @@ class Courses(SysadminDashboardView):
msg += "<pre>{0}</pre>".format(escape(ret)) msg += "<pre>{0}</pre>".format(escape(ret))
return msg return msg
def import_xml_course(self, gitloc, branch, datatable): def import_xml_course(self, gitloc, branch):
"""Imports a git course into the XMLModuleStore""" """Imports a git course into the XMLModuleStore"""
msg = u'' msg = u''
...@@ -475,8 +480,7 @@ class Courses(SysadminDashboardView): ...@@ -475,8 +480,7 @@ class Courses(SysadminDashboardView):
msg += u'<li><pre>{0}: {1}</pre></li>'.format(escape(summary), msg += u'<li><pre>{0}: {1}</pre></li>'.format(escape(summary),
escape(err)) escape(err))
msg += u'</ul>' msg += u'</ul>'
datatable['data'].append([course.display_name, cdir]
+ self.git_info_for_course(cdir))
return msg return msg
def make_datatable(self): def make_datatable(self):
...@@ -484,9 +488,17 @@ class Courses(SysadminDashboardView): ...@@ -484,9 +488,17 @@ class Courses(SysadminDashboardView):
data = [] data = []
<<<<<<< HEAD
for course in self.get_courses(): for course in self.get_courses():
gdir = course.id.run gdir = course.id.run
data.append([course.display_name, course.id.to_deprecated_string()] data.append([course.display_name, course.id.to_deprecated_string()]
=======
for (cdir, course) in courses.items():
gdir = cdir
if '/' in cdir:
gdir = cdir.split('/')[1]
data.append([course.display_name, cdir]
>>>>>>> edx/master
+ self.git_info_for_course(gdir)) + self.git_info_for_course(gdir))
return dict(header=[_('Course Name'), _('Directory/ID'), return dict(header=[_('Course Name'), _('Directory/ID'),
...@@ -524,8 +536,7 @@ class Courses(SysadminDashboardView): ...@@ -524,8 +536,7 @@ class Courses(SysadminDashboardView):
if action == 'add_course': if action == 'add_course':
gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '') gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '')
branch = request.POST.get('repo_branch', '').strip().replace(' ', '').replace(';', '') branch = request.POST.get('repo_branch', '').strip().replace(' ', '').replace(';', '')
datatable = self.make_datatable() self.msg += self.get_course_from_git(gitloc, branch)
self.msg += self.get_course_from_git(gitloc, branch, datatable)
elif action == 'del_course': elif action == 'del_course':
course_id = request.POST.get('course_id', '').strip() course_id = request.POST.get('course_id', '').strip()
...@@ -569,12 +580,17 @@ class Courses(SysadminDashboardView): ...@@ -569,12 +580,17 @@ class Courses(SysadminDashboardView):
delete_course(self.def_ms, content_store, course.id, commit) delete_course(self.def_ms, content_store, course.id, commit)
# don't delete user permission groups, though # don't delete user permission groups, though
self.msg += \ self.msg += \
<<<<<<< HEAD
u"<font color='red'>{0} {1} ({2})</font>".format( u"<font color='red'>{0} {1} ({2})</font>".format(
_('Deleted'), course.id.to_deprecated_string(), course.display_name) _('Deleted'), course.id.to_deprecated_string(), course.display_name)
datatable = self.make_datatable() datatable = self.make_datatable()
=======
u"<font color='red'>{0} {1} = {2} ({3})</font>".format(
_('Deleted'), loc, course.id, course.display_name)
>>>>>>> edx/master
context = { context = {
'datatable': datatable, 'datatable': self.make_datatable(),
'msg': self.msg, 'msg': self.msg,
'djangopid': os.getpid(), 'djangopid': os.getpid(),
'modeflag': {'courses': 'active-section'}, 'modeflag': {'courses': 'active-section'},
......
...@@ -4,6 +4,7 @@ Provide tests for sysadmin dashboard feature in sysadmin.py ...@@ -4,6 +4,7 @@ Provide tests for sysadmin dashboard feature in sysadmin.py
import glob import glob
import os import os
import re
import shutil import shutil
import unittest import unittest
...@@ -458,6 +459,31 @@ class TestSysAdminMongoCourseImport(SysadminBaseTestCase): ...@@ -458,6 +459,31 @@ class TestSysAdminMongoCourseImport(SysadminBaseTestCase):
course = def_ms.get_course(SlashSeparatedCourseKey('MITx', 'edx4edx', 'edx4edx')) course = def_ms.get_course(SlashSeparatedCourseKey('MITx', 'edx4edx', 'edx4edx'))
self.assertIsNone(course) self.assertIsNone(course)
def test_course_info(self):
"""
Check to make sure we are getting git info for courses
"""
# Regex of first 3 columns of course information table row for
# test course loaded from git. Would not have sha1 if
# git_info_for_course failed.
table_re = re.compile(r"""
<tr>\s+
<td>edX\sAuthor\sCourse</td>\s+ # expected test git course name
<td>MITx/edx4edx/edx4edx</td>\s+ # expected test git course_id
<td>[a-fA-F\d]{40}</td> # git sha1 hash
""", re.VERBOSE)
self._setstaff_login()
self._mkdir(getattr(settings, 'GIT_REPO_DIR'))
# Make sure we don't have any git hashes on the page
response = self.client.get(reverse('sysadmin_courses'))
self.assertNotRegexpMatches(response.content, table_re)
# Now add the course and make sure it does match
response = self._add_edx4edx()
self.assertRegexpMatches(response.content, table_re)
def test_gitlogs(self): def test_gitlogs(self):
""" """
Create a log entry and make sure it exists Create a log entry and make sure it exists
......
...@@ -4,6 +4,7 @@ from edxmako.shortcuts import render_to_response ...@@ -4,6 +4,7 @@ from edxmako.shortcuts import render_to_response
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from notes.models import Note from notes.models import Note
from notes.utils import notes_enabled_for_course from notes.utils import notes_enabled_for_course
from xmodule.annotator_token import retrieve_token
@login_required @login_required
...@@ -22,7 +23,8 @@ def notes(request, course_id): ...@@ -22,7 +23,8 @@ def notes(request, course_id):
'course': course, 'course': course,
'notes': notes, 'notes': notes,
'student': student, 'student': student,
'storage': storage 'storage': storage,
'token': retrieve_token(student.email, course.annotation_token_secret),
} }
return render_to_response('notes.html', context) return render_to_response('notes.html', context)
...@@ -826,6 +826,7 @@ main_vendor_js = [ ...@@ -826,6 +826,7 @@ main_vendor_js = [
'js/vendor/swfobject/swfobject.js', 'js/vendor/swfobject/swfobject.js',
'js/vendor/jquery.ba-bbq.min.js', 'js/vendor/jquery.ba-bbq.min.js',
'js/vendor/ova/annotator-full.js', 'js/vendor/ova/annotator-full.js',
'js/vendor/ova/annotator-full-firebase-auth.js',
'js/vendor/ova/video.dev.js', 'js/vendor/ova/video.dev.js',
'js/vendor/ova/vjs.youtube.js', 'js/vendor/ova/vjs.youtube.js',
'js/vendor/ova/rangeslider.js', 'js/vendor/ova/rangeslider.js',
......
...@@ -64,12 +64,21 @@ ...@@ -64,12 +64,21 @@
<section id="catchDIV"> <section id="catchDIV">
<div class="annotationListContainer">${_('You do not have any notes.')}</div> <div class="annotationListContainer">${_('You do not have any notes.')}</div>
</section> </section>
<<<<<<< HEAD
<script> <script>
//Grab uri of the course //Grab uri of the course
var parts = window.location.href.split("/"), var parts = window.location.href.split("/"),
uri = ''; uri = '';
for (var index = 0; index <= 6; index += 1) uri += parts[index]+"/"; //Get the unit url for (var index = 0; index <= 6; index += 1) uri += parts[index]+"/"; //Get the unit url
=======
<script>
//Grab uri of the course
var parts = window.location.href.split("/"),
uri = '';
for (var index = 0; index <= 6; index += 1) uri += parts[index]+"/"; //Get the unit url
>>>>>>> edx/master
var pagination = 100, var pagination = 100,
is_staff = false, is_staff = false,
options = { options = {
...@@ -164,6 +173,28 @@ ...@@ -164,6 +173,28 @@
extended_valid_elements : "iframe[src|frameborder|style|scrolling|class|width|height|name|align|id]", extended_valid_elements : "iframe[src|frameborder|style|scrolling|class|width|height|name|align|id]",
toolbar: "insertfile undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media rubric | code ", toolbar: "insertfile undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media rubric | code ",
} }
<<<<<<< HEAD
=======
return true;
},
},
auth: {
token: "${token}"
},
store: {
// The endpoint of the store on your server.
prefix: "${storage}",
annotationData: {},
urls: {
// These are the default URLs.
create: '/create',
read: '/read/:id',
update: '/update/:id',
destroy: '/delete/:id',
search: '/search'
>>>>>>> edx/master
}, },
}; };
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<div class="annotatable-wrapper"> <div class="annotatable-wrapper">
<div class="annotatable-header"> <div class="annotatable-header">
% if display_name is not UNDEFINED and display_name is not None: % if display_name is not UNDEFINED and display_name is not None:
<div class="annotatable-title">${display_name}</div> <div class="annotatable-title">${display_name}</div>
% endif % endif
</div> </div>
% if instructions_html is not UNDEFINED and instructions_html is not None: % if instructions_html is not UNDEFINED and instructions_html is not None:
<div class="annotatable-section shaded"> <div class="annotatable-section shaded">
<div class="annotatable-section-title"> <div class="annotatable-section-title">
${_('Instructions')} ${_('Instructions')}
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">${_('Collapse Instructions')}</a> <a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">${_('Collapse Instructions')}</a>
</div> </div>
<div class="annotatable-section-body annotatable-instructions"> <div class="annotatable-section-body annotatable-instructions">
${instructions_html} ${instructions_html}
</div> </div>
</div> </div>
% endif % endif
<div class="annotatable-section"> <div class="annotatable-section">
<div class="annotatable-content"> <div class="annotatable-content">
<div id="textHolder">${content_html}</div> <div id="textHolder">${content_html}</div>
<div id="sourceCitation">${_('Source:')} ${source}</div> <div id="sourceCitation">${_('Source:')} ${source}</div>
<div id="catchDIV"> <div id="catchDIV">
<div class="annotationListContainer">${_('You do not have any notes.')}</div> <div class="annotationListContainer">${_('You do not have any notes.')}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
function onClickHideInstructions(){ function onClickHideInstructions(){
//Reset function if there is more than one event handler //Reset function if there is more than one event handler
$(this).off(); $(this).off();
$(this).on('click',onClickHideInstructions); $(this).on('click',onClickHideInstructions);
var hide = $(this).html()=='Collapse Instructions'?true:false, var hide = $(this).html()=='Collapse Instructions'?true:false,
cls, txt,slideMethod; cls, txt,slideMethod;
txt = (hide ? 'Expand' : 'Collapse') + ' Instructions'; txt = (hide ? 'Expand' : 'Collapse') + ' Instructions';
cls = (hide ? ['expanded', 'collapsed'] : ['collapsed', 'expanded']); cls = (hide ? ['expanded', 'collapsed'] : ['collapsed', 'expanded']);
slideMethod = (hide ? 'slideUp' : 'slideDown'); slideMethod = (hide ? 'slideUp' : 'slideDown');
$(this).text(txt).removeClass(cls[0]).addClass(cls[1]); $(this).text(txt).removeClass(cls[0]).addClass(cls[1]);
$(this).parents('.annotatable-section:first').find('.annotatable-instructions')[slideMethod](); $(this).parents('.annotatable-section:first').find('.annotatable-instructions')[slideMethod]();
} }
$('.annotatable-toggle-instructions').on('click', onClickHideInstructions); $('.annotatable-toggle-instructions').on('click', onClickHideInstructions);
//Grab uri of the course //Grab uri of the course
var parts = window.location.href.split("/"), var parts = window.location.href.split("/"),
uri = '', uri = '';
courseid; for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url
for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url //Change uri in cms
courseid = parts[4] + "/" + parts[5] + "/" + parts[6]; var lms_location = $('.sidebar .preview-button').attr('href');
//Change uri in cms if (typeof lms_location!='undefined'){
var lms_location = $('.sidebar .preview-button').attr('href'); uri = window.location.protocol;
if (typeof lms_location!='undefined'){ for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url
courseid = parts[4].split(".").join("/"); }
uri = window.location.protocol; var unit_id = $('#sequence-list').find('.active').attr("data-element");
for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url uri += unit_id;
} var pagination = 100,
var pagination = 100, is_staff = !('${user.is_staff}'=='False'),
is_staff = !('${user.is_staff}'=='False'),
options = { options = {
optionsAnnotator: { optionsAnnotator: {
permissions:{ permissions:{
...@@ -89,7 +88,7 @@ ...@@ -89,7 +88,7 @@
if (annotation.permissions) { if (annotation.permissions) {
tokens = annotation.permissions[action] || []; tokens = annotation.permissions[action] || [];
if (is_staff){ if (is_staff){
return true; return true;
} }
if (tokens.length === 0) { if (tokens.length === 0) {
return true; return true;
...@@ -115,7 +114,7 @@ ...@@ -115,7 +114,7 @@
}, },
}, },
auth: { auth: {
tokenUrl: location.protocol+'//'+location.host+"/token?course_id="+courseid token: "${token}"
}, },
store: { store: {
// The endpoint of the store on your server. // The endpoint of the store on your server.
...@@ -140,11 +139,14 @@ ...@@ -140,11 +139,14 @@
offset:0, offset:0,
uri:uri, uri:uri,
media:'text', media:'text',
userid:'${user.email}', userid:'${user.email}',
} }
}, },
highlightTags:{ highlightTags:{
tag: "${tag}", tag: "${tag}",
},
diacriticMarks:{
diacritics: "${diacritic_marks}"
} }
}, },
optionsVideoJS: {techOrder: ["html5","flash","youtube"]}, optionsVideoJS: {techOrder: ["html5","flash","youtube"]},
...@@ -161,12 +163,11 @@ ...@@ -161,12 +163,11 @@
} }
}, },
}; };
var imgURLRoot = "${settings.STATIC_URL}" + "js/vendor/ova/catch/img/"; var imgURLRoot = "${settings.STATIC_URL}" + "js/vendor/ova/catch/img/";
tinyMCE.baseURL = "${settings.STATIC_URL}" + "js/vendor/ova"; tinyMCE.baseURL = "${settings.STATIC_URL}" + "js/vendor/ova";
//remove old instances //remove old instances
if (Annotator._instances.length !== 0) { if (Annotator._instances.length !== 0) {
$('#textHolder').annotator("destroy"); $('#textHolder').annotator("destroy");
} }
...@@ -174,7 +175,6 @@ ...@@ -174,7 +175,6 @@
//Load the plugin Video/Text Annotation //Load the plugin Video/Text Annotation
var ova = new OpenVideoAnnotation.Annotator($('#textHolder'),options); var ova = new OpenVideoAnnotation.Annotator($('#textHolder'),options);
//Catch //Catch
var annotator = ova.annotator, var annotator = ova.annotator,
catchOptions = { catchOptions = {
...@@ -183,7 +183,7 @@ ...@@ -183,7 +183,7 @@
imageUrlRoot:imgURLRoot, imageUrlRoot:imgURLRoot,
showMediaSelector: false, showMediaSelector: false,
showPublicPrivate: true, showPublicPrivate: true,
userId:'${user.email}', userId:'${user.email}',
pagination:pagination,//Number of Annotations per load in the pagination, pagination:pagination,//Number of Annotations per load in the pagination,
flags:is_staff flags:is_staff
}, },
......
...@@ -49,18 +49,16 @@ ...@@ -49,18 +49,16 @@
//Grab uri of the course //Grab uri of the course
var parts = window.location.href.split("/"), var parts = window.location.href.split("/"),
uri = '', uri = '';
courseid;
for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url
courseid = parts[4] + "/" + parts[5] + "/" + parts[6];
//Change uri in cms //Change uri in cms
var lms_location = $('.sidebar .preview-button').attr('href'); var lms_location = $('.sidebar .preview-button').attr('href');
if (typeof lms_location!='undefined'){ if (typeof lms_location!='undefined'){
courseid = parts[4].split(".").join("/");
uri = window.location.protocol; uri = window.location.protocol;
for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url
} }
var unit_id = $('#sequence-list').find('.active').attr("data-element");
uri += unit_id;
var pagination = 100, var pagination = 100,
is_staff = !('${user.is_staff}'=='False'), is_staff = !('${user.is_staff}'=='False'),
options = { options = {
...@@ -119,7 +117,7 @@ ...@@ -119,7 +117,7 @@
}, },
}, },
auth: { auth: {
tokenUrl: location.protocol+'//'+location.host+"/token?course_id="+courseid token: "${token}"
}, },
store: { store: {
// The endpoint of the store on your server. // The endpoint of the store on your server.
...@@ -175,8 +173,6 @@ ...@@ -175,8 +173,6 @@
var ova = new OpenVideoAnnotation.Annotator($('#videoHolder'),options); var ova = new OpenVideoAnnotation.Annotator($('#videoHolder'),options);
ova.annotator.addPlugin('Tags'); ova.annotator.addPlugin('Tags');
//Catch //Catch
var annotator = ova.annotator, var annotator = ova.annotator,
...@@ -186,7 +182,7 @@ ...@@ -186,7 +182,7 @@
imageUrlRoot:imgURLRoot, imageUrlRoot:imgURLRoot,
showMediaSelector: false, showMediaSelector: false,
showPublicPrivate: true, showPublicPrivate: true,
userId:'${user.email}', userId:'${user.email}',
pagination:pagination,//Number of Annotations per load in the pagination, pagination:pagination,//Number of Annotations per load in the pagination,
flags:is_staff flags:is_staff
}, },
......
...@@ -15,7 +15,6 @@ urlpatterns = ('', # nopep8 ...@@ -15,7 +15,6 @@ urlpatterns = ('', # nopep8
url(r'^request_certificate$', 'certificates.views.request_certificate'), url(r'^request_certificate$', 'certificates.views.request_certificate'),
url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware
url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), url(r'^dashboard$', 'student.views.dashboard', name="dashboard"),
url(r'^token$', 'student.views.token', name="token"),
url(r'^login$', 'student.views.signin_user', name="signin_user"), url(r'^login$', 'student.views.signin_user', name="signin_user"),
url(r'^register$', 'student.views.register_user', name="register_user"), url(r'^register$', 'student.views.register_user', name="register_user"),
......
...@@ -35,6 +35,7 @@ django-method-override==0.1.0 ...@@ -35,6 +35,7 @@ django-method-override==0.1.0
djangorestframework==2.3.5 djangorestframework==2.3.5
django==1.4.12 django==1.4.12
feedparser==5.1.3 feedparser==5.1.3
firebase-token-generator==1.3.2
fs==0.4.0 fs==0.4.0
GitPython==0.3.2.RC1 GitPython==0.3.2.RC1
glob2==0.3 glob2==0.3
......
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