Commit 020e1649 by Clinton Blackburn

Merge pull request #9630 from edx/rc/2015-09-09

Release 2015-09-09
parents 7b492f1d 6c09ceae
......@@ -233,4 +233,6 @@ Dongwook Yoon <dy252@cornell.edu>
Awais Qureshi <awais.qureshi@arbisoft.com>
Eric Fischer <efischer@edx.org>
Brian Beggs <macdiesel@gmail.com>
Bill DeRusha <bill@edx.org>
\ No newline at end of file
Bill DeRusha <bill@edx.org>
Kevin Falcone <kevin@edx.org>
Mirjam Škarica <mirjamskarica@gmail.com>
......@@ -67,20 +67,20 @@ def _click_advanced():
world.wait_for_visible(tab2_css)
def _find_matching_link(category, component_type):
def _find_matching_button(category, component_type):
"""
Find the link with the specified text. There should be one and only one.
Find the button with the specified text. There should be one and only one.
"""
# The tab shows links for the given category
links = world.css_find('div.new-component-{} a'.format(category))
# The tab shows buttons for the given category
buttons = world.css_find('div.new-component-{} button'.format(category))
# Find the link whose text matches what you're looking for
matched_links = [link for link in links if link.text == component_type]
# Find the button whose text matches what you're looking for
matched_buttons = [btn for btn in buttons if btn.text == component_type]
# There should be one and only one
assert_equal(len(matched_links), 1)
return matched_links[0]
assert_equal(len(matched_buttons), 1)
return matched_buttons[0]
def click_component_from_menu(category, component_type, is_advanced):
......@@ -100,7 +100,7 @@ def click_component_from_menu(category, component_type, is_advanced):
# Retry this in case the list is empty because you tried too fast.
link = world.retry_on_exception(
lambda: _find_matching_link(category, component_type),
lambda: _find_matching_button(category, component_type),
ignored_exceptions=AssertionError
)
......
......@@ -18,18 +18,6 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
def print_out_all_courses():
"""
Print out all the courses available in the course_key format so that
the user can correct any course_key mistakes
"""
courses = modulestore().get_courses_keys()
print 'Available courses:'
for course in courses:
print str(course)
print ''
class Command(BaseCommand):
"""
Delete a MongoDB backed course
......@@ -58,8 +46,6 @@ class Command(BaseCommand):
elif len(args) > 2:
raise CommandError("Too many arguments! Expected <course_key> <commit>")
print_out_all_courses()
if not modulestore().get_course(course_key):
raise CommandError("Course with '%s' key not found." % args[0])
......@@ -67,4 +53,4 @@ class Command(BaseCommand):
if query_yes_no("Deleting course {0}. Confirm?".format(course_key), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
delete_course_and_groups(course_key, ModuleStoreEnum.UserID.mgmt_command)
print_out_all_courses()
print "Deleted course {}".format(course_key)
""" Management command to update courses' search index """
import logging
from django.core.management import BaseCommand, CommandError
from optparse import make_option
from textwrap import dedent
from contentstore.courseware_index import CoursewareSearchIndexer
from search.search_engine_base import SearchEngine
from elasticsearch import exceptions
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import CourseLocator
from .prompt import query_yes_no
from xmodule.modulestore.django import modulestore
class Command(BaseCommand):
"""
Command to re-index courses
Examples:
./manage.py reindex_course <course_id_1> <course_id_2> - reindexes courses with keys course_id_1 and course_id_2
./manage.py reindex_course --all - reindexes all available courses
./manage.py reindex_course --setup - reindexes all courses for devstack setup
"""
help = dedent(__doc__)
can_import_settings = True
args = "<course_id course_id ...>"
all_option = make_option('--all',
action='store_true',
dest='all',
default=False,
help='Reindex all courses')
setup_option = make_option('--setup',
action='store_true',
dest='setup',
default=False,
help='Reindex all courses on developers stack setup')
option_list = BaseCommand.option_list + (all_option, setup_option)
CONFIRMATION_PROMPT = u"Re-indexing all courses might be a time consuming operation. Do you want to continue?"
def _parse_course_key(self, raw_value):
""" Parses course key from string """
try:
result = CourseKey.from_string(raw_value)
except InvalidKeyError:
raise CommandError("Invalid course_key: '%s'." % raw_value)
if not isinstance(result, CourseLocator):
raise CommandError(u"Argument {0} is not a course key".format(raw_value))
return result
def handle(self, *args, **options):
"""
By convention set by Django developers, this method actually executes command's actions.
So, there could be no better docstring than emphasize this once again.
"""
all_option = options.get('all', False)
setup_option = options.get('setup', False)
index_all_courses_option = all_option or setup_option
if len(args) == 0 and not index_all_courses_option:
raise CommandError(u"reindex_course requires one or more arguments: <course_id>")
store = modulestore()
if index_all_courses_option:
index_name = CoursewareSearchIndexer.INDEX_NAME
doc_type = CoursewareSearchIndexer.DOCUMENT_TYPE
if setup_option:
try:
# try getting the ElasticSearch engine
searcher = SearchEngine.get_search_engine(index_name)
except exceptions.ElasticsearchException as exc:
logging.exception('Search Engine error - %s', unicode(exc))
return
index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access
doc_type_exists = searcher._es.indices.exists_type( # pylint: disable=protected-access
index=index_name,
doc_type=doc_type
)
index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access
index=index_name,
doc_type=doc_type
) if index_exists and doc_type_exists else {}
if index_exists and index_mapping:
return
# if reindexing is done during devstack setup step, don't prompt the user
if setup_option or query_yes_no(self.CONFIRMATION_PROMPT, default="no"):
# in case of --setup or --all, get the list of course keys from all courses
# that are stored in the modulestore
course_keys = [course.id for course in modulestore().get_courses()]
else:
return
else:
# in case course keys are provided as arguments
course_keys = map(self._parse_course_key, args)
for course_key in course_keys:
CoursewareSearchIndexer.do_course_reindex(store, course_key)
......@@ -5,6 +5,7 @@ Unittests for deleting a course in an chosen modulestore
import unittest
import mock
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.core.management import CommandError
from contentstore.management.commands.delete_course import Command # pylint: disable=import-error
from contentstore.tests.utils import CourseTestCase # pylint: disable=import-error
......@@ -94,27 +95,23 @@ class DeleteCourseTest(CourseTestCase):
run=course_run
)
def test_courses_keys_listing(self):
"""
Test if the command lists out available course key courses
"""
courses = [str(key) for key in modulestore().get_courses_keys()]
self.assertIn("TestX/TS01/2015_Q1", courses)
def test_course_key_not_found(self):
"""
Test for when a non-existing course key is entered
"""
errstring = "Course with 'TestX/TS01/2015_Q7' key not found."
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle("TestX/TS01/2015_Q7", "commit")
self.command.handle('TestX/TS01/2015_Q7', "commit")
def test_course_deleted(self):
"""
Testing if the entered course was deleted
"""
#Test if the course that is about to be deleted exists
self.assertIsNotNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
patched_yes_no.return_value = True
self.command.handle("TestX/TS01/2015_Q1", "commit")
courses = [unicode(key) for key in modulestore().get_courses_keys()]
self.assertNotIn("TestX/TS01/2015_Q1", courses)
self.command.handle('TestX/TS01/2015_Q1', "commit")
self.assertIsNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
""" Tests for course reindex command """
import ddt
from django.core.management import call_command, CommandError
import mock
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from common.test.utils import nostderr
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from contentstore.management.commands.reindex_course import Command as ReindexCommand
from contentstore.courseware_index import SearchIndexingError
@ddt.ddt
class TestReindexCourse(ModuleStoreTestCase):
""" Tests for course reindex command """
def setUp(self):
""" Setup method - create courses """
super(TestReindexCourse, self).setUp()
self.store = modulestore()
self.first_lib = LibraryFactory.create(
org="test", library="lib1", display_name="run1", default_store=ModuleStoreEnum.Type.split
)
self.second_lib = LibraryFactory.create(
org="test", library="lib2", display_name="run2", default_store=ModuleStoreEnum.Type.split
)
self.first_course = CourseFactory.create(
org="test", course="course1", display_name="run1"
)
self.second_course = CourseFactory.create(
org="test", course="course2", display_name="run1"
)
REINDEX_PATH_LOCATION = 'contentstore.management.commands.reindex_course.CoursewareSearchIndexer.do_course_reindex'
MODULESTORE_PATCH_LOCATION = 'contentstore.management.commands.reindex_course.modulestore'
YESNO_PATCH_LOCATION = 'contentstore.management.commands.reindex_course.query_yes_no'
def _get_lib_key(self, library):
""" Get's library key as it is passed to indexer """
return library.location.library_key
def _build_calls(self, *courses):
""" Builds a list of mock.call instances representing calls to reindexing method """
return [mock.call(self.store, course.id) for course in courses]
def test_given_no_arguments_raises_command_error(self):
""" Test that raises CommandError for incorrect arguments """
with self.assertRaises(SystemExit), nostderr():
with self.assertRaisesRegexp(CommandError, ".* requires one or more arguments .*"):
call_command('reindex_course')
@ddt.data('qwerty', 'invalid_key', 'xblock-v1:qwe+rty')
def test_given_invalid_course_key_raises_not_found(self, invalid_key):
""" Test that raises InvalidKeyError for invalid keys """
errstring = "Invalid course_key: '%s'." % invalid_key
with self.assertRaises(SystemExit) as ex:
with self.assertRaisesRegexp(CommandError, errstring):
call_command('reindex_course', invalid_key)
self.assertEqual(ex.exception.code, 1)
def test_given_library_key_raises_command_error(self):
""" Test that raises CommandError if library key is passed """
with self.assertRaises(SystemExit), nostderr():
with self.assertRaisesRegexp(SearchIndexingError, ".* is not a course key"):
call_command('reindex_course', unicode(self._get_lib_key(self.first_lib)))
with self.assertRaises(SystemExit), nostderr():
with self.assertRaisesRegexp(SearchIndexingError, ".* is not a course key"):
call_command('reindex_course', unicode(self._get_lib_key(self.second_lib)))
with self.assertRaises(SystemExit), nostderr():
with self.assertRaisesRegexp(SearchIndexingError, ".* is not a course key"):
call_command(
'reindex_course',
unicode(self.second_course.id),
unicode(self._get_lib_key(self.first_lib))
)
def test_given_id_list_indexes_courses(self):
""" Test that reindexes courses when given single course key or a list of course keys """
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
call_command('reindex_course', unicode(self.first_course.id))
self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_course))
patched_index.reset_mock()
call_command('reindex_course', unicode(self.second_course.id))
self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_course))
patched_index.reset_mock()
call_command(
'reindex_course',
unicode(self.first_course.id),
unicode(self.second_course.id)
)
expected_calls = self._build_calls(self.first_course, self.second_course)
self.assertEqual(patched_index.mock_calls, expected_calls)
def test_given_all_key_prompts_and_reindexes_all_courses(self):
""" Test that reindexes all courses when --all key is given and confirmed """
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
patched_yes_no.return_value = True
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
call_command('reindex_course', all=True)
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
expected_calls = self._build_calls(self.first_course, self.second_course)
self.assertItemsEqual(patched_index.mock_calls, expected_calls)
def test_given_all_key_prompts_and_reindexes_all_courses_cancelled(self):
""" Test that does not reindex anything when --all key is given and cancelled """
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
patched_yes_no.return_value = False
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
call_command('reindex_course', all=True)
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
patched_index.assert_not_called()
def test_fail_fast_if_reindex_fails(self):
""" Test that fails on first reindexing exception """
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index:
patched_index.side_effect = SearchIndexingError("message", [])
with self.assertRaises(SearchIndexingError):
call_command('reindex_course', unicode(self.second_course.id))
......@@ -170,6 +170,9 @@ FEATURES = {
# Teams feature
'ENABLE_TEAMS': True,
# Teams search feature
'ENABLE_TEAMS_SEARCH': False,
# Show video bumper in Studio
'ENABLE_VIDEO_BUMPER': False,
......
......@@ -281,5 +281,8 @@ SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
# teams feature
FEATURES['ENABLE_TEAMS'] = True
# teams search
FEATURES['ENABLE_TEAMS_SEARCH'] = True
# Dummy secret key for dev/test
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
......@@ -79,7 +79,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
};
beforeEach(function() {
TemplateHelpers.installTemplates(['certificate-details', 'signatory-details', 'signatory-editor'], true);
TemplateHelpers.installTemplates(['certificate-details', 'signatory-details', 'signatory-editor', 'signatory-actions'], true);
this.newModelOptions = {add: true};
this.model = new CertificateModel({
......
......@@ -40,6 +40,7 @@ function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, Sign
eventAgg: this.eventAgg
});
this.template = this.loadTemplate('signatory-details');
this.signatory_action_template = this.loadTemplate('signatory-actions');
},
loadTemplate: function(name) {
......@@ -51,6 +52,7 @@ function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, Sign
// Retrieve the edit view for this model
if (event && event.preventDefault) { event.preventDefault(); }
this.$el.html(this.edit_view.render());
$(this.signatory_action_template()).appendTo(this.el);
this.edit_view.delegateEvents();
this.delegateEvents();
},
......
......@@ -552,7 +552,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
var clickNewComponent;
clickNewComponent = function (index) {
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
containerPage.$(".new-component .new-component-type button.single-template")[index].click();
};
it('Attaches a handler to new component button', function() {
......@@ -598,7 +598,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
var showTemplatePicker, verifyCreateHtmlComponent;
showTemplatePicker = function () {
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
containerPage.$('.new-component .new-component-type button.multiple-templates')[0].click();
};
verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) {
......@@ -606,7 +606,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
renderContainerPage(test, mockContainerXBlockHtml);
showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
containerPage.$('.new-component-html a')[templateIndex].click();
containerPage.$('.new-component-html button')[templateIndex].click();
EditHelpers.verifyXBlockRequest(requests, expectedRequest);
AjaxHelpers.respondWithJson(requests, {"locator": "new_item"});
respondWithHtml(mockXBlockHtml);
......
......@@ -89,24 +89,24 @@ function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) {
it("displays an error when the user has already been added", function () {
var requests = AjaxHelpers.requests(this);
var promptSpy = ViewHelpers.createPromptSpy();
$('.create-user-button').click();
$('.user-email-input').val('honor@example.com');
var warningPromptSelector = '.wrapper-prompt.is-shown .prompt.warning';
expect($(warningPromptSelector).length).toEqual(0);
$('.form-create.create-user .action-primary').click();
expect($(warningPromptSelector).length).toEqual(1);
expect($(warningPromptSelector)).toContainText('Already a library team member');
ViewHelpers.verifyPromptShowing(promptSpy, 'Already a library team member');
expect(requests.length).toEqual(0);
});
it("can remove a user's permission to access the library", function () {
var requests = AjaxHelpers.requests(this);
var promptSpy = ViewHelpers.createPromptSpy();
var reloadSpy = spyOn(ViewUtils, 'reload');
var email = "honor@example.com";
$('.user-item[data-email="'+email+'"] .action-delete .delete').click();
expect($('.wrapper-prompt.is-shown .prompt.warning').length).toEqual(1);
$('.wrapper-prompt.is-shown .action-primary').click();
ViewHelpers.verifyPromptShowing(promptSpy, 'Are you sure?');
ViewHelpers.confirmPrompt(promptSpy);
ViewHelpers.verifyPromptHidden(promptSpy);
AjaxHelpers.expectJsonRequest(requests, 'DELETE', getUrl(email), {role: null});
AjaxHelpers.respondWithJson(requests, {'result': 'ok'});
expect(reloadSpy).toHaveBeenCalled();
......
......@@ -6,10 +6,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({
events: {
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type a.single-template': 'createNewComponent',
'click .new-component .new-component-type button.multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type button.single-template': 'createNewComponent',
'click .new-component .cancel-button': 'closeNewComponent',
'click .new-component-templates .new-component-template a': 'createNewComponent',
'click .new-component-templates .new-component-template .button-component': 'createNewComponent',
'click .new-component-templates .cancel-button': 'closeNewComponent'
},
......@@ -43,13 +43,17 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
type = $(event.currentTarget).data('type');
this.$('.new-component').slideUp(250);
this.$('.new-component-' + type).slideDown(250);
this.$('.new-component-' + type + ' div').focus();
},
closeNewComponent: function(event) {
event.preventDefault();
event.stopPropagation();
type = $(event.currentTarget).data('type');
this.$('.new-component').slideDown(250);
this.$('.new-component-templates').slideUp(250);
this.$('ul.new-component-type li button[data-type=' + type + ']').focus();
},
createNewComponent: function(event) {
......
......@@ -13,40 +13,35 @@
.large-advanced-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: ($baseline/4);
width: ($baseline*3);
height: ($baseline*3);
background: url(../images/large-advanced-icon.png) center no-repeat;
}
.large-discussion-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: ($baseline/4);
width: ($baseline*3);
height: ($baseline*3);
background: url(../images/large-discussion-icon.png) center no-repeat;
}
.large-html-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: ($baseline/4);
width: ($baseline*3);
height: ($baseline*3);
background: url(../images/large-html-icon.png) center no-repeat;
}
.large-problem-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: ($baseline/4);
width: ($baseline*3);
height: ($baseline*3);
background: url(../images/large-problem-icon.png) center no-repeat;
}
.large-video-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: ($baseline/4);
width: ($baseline*3);
height: ($baseline*3);
background: url(../images/large-video-icon.png) center no-repeat;
}
......@@ -130,9 +130,10 @@
width: ($baseline*5);
height: ($baseline*5);
margin-bottom: ($baseline/2);
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, .4) inset;
border: 1px solid $green-d2;
border-radius: ($baseline/4);
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, .4) inset;
padding: 0;
background-color: $green-l1;
text-align: center;
color: $white;
......@@ -195,16 +196,17 @@
}
}
a {
.button-component {
@include clearfix();
@include transition(none);
@extend %t-demi-strong;
display: block;
width: 100%;
border: 0px;
padding: 7px $baseline;
background: $white;
color: $gray-d3;
text-align: left;
&:hover {
@include transition(background-color $tmg-f2 linear 0s);
......
......@@ -380,6 +380,12 @@
color: $gray-l3;
}
}
&.custom-signatory-action {
position: relative;
top: 0;
left: 0;
opacity: 1.0;
}
}
.copy {
......@@ -522,6 +528,10 @@
.signatory-panel-body {
padding: $baseline;
.signatory-image {
margin-top: 20px;
}
}
.signatory-panel-body label {
......
......@@ -11,7 +11,7 @@ from django.utils.translation import ugettext as _
<%block name="bodyclass">is-signedin course view-certificates</%block>
<%block name="header_extras">
% for template_name in ["certificate-details", "certificate-editor", "signatory-editor", "signatory-details", "basic-modal", "modal-button", "list", "upload-dialog", "certificate-web-preview"]:
% for template_name in ["certificate-details", "certificate-editor", "signatory-editor", "signatory-details", "basic-modal", "modal-button", "list", "upload-dialog", "certificate-web-preview", "signatory-actions"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
......
<% if (type === 'advanced' || templates.length > 1) { %>
<a href="#" class="multiple-templates add-xblock-component-button" data-type="<%= type %>">
<button type="button" class="multiple-templates add-xblock-component-button" data-type="<%= type %>">
<% } else { %>
<a href="#" class="single-template add-xblock-component-button" data-type="<%= type %>" data-category="<%= templates[0].category %>">
<button type="button" class="single-template add-xblock-component-button" data-type="<%= type %>" data-category="<%= templates[0].category %>">
<% } %>
<span class="large-template-icon large-<%= type %>-icon"></span>
<span class="sr"> <%= gettext("Add Component:") %></span>
<span class="name"><%= display_name %></span>
</a>
</button>
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<div class="tab-group tabs" tabindex="-1" role="dialog" aria-label="<%-
interpolate(
gettext('%(type)s Component Template Menu'),
{type: type},
true
)
%>">
<ul class="problem-type-tabs nav-tabs" tabindex='-1'>
<li class="current">
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li>
......@@ -13,16 +19,16 @@
<% if (templates[i].tab == "common") { %>
<% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>">
<button type="button" class="button-component" data-category="<%= templates[i].category %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</button>
</li>
<% } else { %>
<li class="editor-md">
<a href="#" data-category="<%= templates[i].category %>"
<button type="button" class="button-component" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</button>
</li>
<% } %>
<% } %>
......@@ -34,14 +40,14 @@
<% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].tab == "advanced") { %>
<li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>"
<button type="button" class="button-component" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</button>
</li>
<% } %>
<% } %>
</ul>
</div>
<button class="cancel-button" data-type="<%= type %>"><%= gettext("Cancel") %></button>
</div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% if (type === 'advanced' || templates.length > 1) { %>
<div class="tab current" id="tab1">
<div class="tab current" id="tab1" tabindex="-1" role="dialog" aria-label="<%-
interpolate(
gettext('%(type)s Component Template Menu'),
{type: type},
true
)
%>">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>">
<button type="button" class="button-component" data-category="<%= templates[i].category %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</button>
</li>
<% } else { %>
<li class="editor-md">
<a href="#" data-category="<%= templates[i].category %>"
<button type="button" class="button-component" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</button>
</li>
<% } %>
<% } %>
</ul>
<button class="cancel-button" data-type="<%= type %>"><%= gettext("Cancel") %></button>
</div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% } %>
......@@ -17,12 +17,12 @@
<span class="title"><%= gettext("Certificate Details") %></span>
</header>
<div class="actual-course-title">
<span class="certificate-label"><%= gettext('Course Title') %>: </span>
<span class="certificate-label"><b><%= gettext('Course Title') %>: </b> </span>
<span class="certificate-value"><%= course.get('name') %></span>
</div>
<% if (course_title) { %>
<div class="course-title-override">
<span class="certificate-label"><%= gettext('Course Title Override') %>: </span>
<span class="certificate-label"><b><%= gettext('Course Title Override') %>: </b></span>
<span class="certificate-value"><%= course_title %></span>
</div>
<% } %>
......
......@@ -19,6 +19,10 @@
<textarea id="certificate-description-<%= uniqueId %>" class="certificate-description-input text input-text" name="certificate-description" placeholder="<%= gettext("Description of the certificate") %>" aria-describedby="certificate-description-<%=uniqueId %>-tip"><%= description %></textarea>
<span id="certificate-description-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Description of the certificate") %></span>
</div>
<div class="field text actual-course-title">
<label class="actual-course-title"><b><%= gettext("Course Title") %></b></label>
<span class="actual-title"><%= course.get('name') %></span>
</div>
<div class="input-wrap field text add-certificate-course-title">
<label for="certificate-course-title-<%= uniqueId %>"><%= gettext("Course Title Override") %></label>
<input id="certificate-course-title-<%= uniqueId %>" class="certificate-course-title-input input-text" name="certificate-course-title" type="text" placeholder="<%= gettext("Course title") %>" value="<%= course_title %>" aria-describedby="certificate-course-title-<%=uniqueId %>-tip" />
......
<div class="collection-edit">
<div class="actions custom-signatory-action">
<button class="signatory-panel-save action action-primary" type="submit"><%= gettext("Save") %></button>
<button class="signatory-panel-close action action-secondary action-cancel"><%= gettext("Cancel") %></button>
</div>
</div>
......@@ -9,16 +9,25 @@
<div class="signatory-panel-header">Signatory <%= signatory_number %>&nbsp;</div>
<div class="signatory-panel-body">
<div>
<span class="signatory-name-label"><%= gettext("Name") %>:&nbsp;</span>
<span class="signatory-name-value"><%= name %></span>
<div>
<span class="signatory-name-label"><b><%= gettext("Name") %>:</b>&nbsp;</span>
<span class="signatory-name-value"><%= name %></span>
</div>
<div>
<span class="signatory-title-label"><b><%= gettext("Title") %>:</b>&nbsp;</span>
<span class="signatory-title-value"><%= title %></span>
</div>
<div>
<span class="signatory-organization-label"><b><%= gettext("Organization") %>:</b>&nbsp;</span>
<span class="signatory-organization-value"><%= organization %></span>
</div>
</div>
<div>
<span class="signatory-title-label"><%= gettext("Title") %>:&nbsp;</span>
<span class="signatory-title-value"><%= title %></span>
</div>
<div>
<span class="signatory-organization-label"><%= gettext("Organization") %>:&nbsp;</span>
<span class="signatory-organization-value"><%= organization %></span>
<div class="signatory-image">
<% if (signature_image_path != "") { %>
<div class="wrapper-signature-image">
<img class="signature-image" src="<%= signature_image_path %>" alt="Signature Image">
</div>
<% } %>
</div>
</div>
</div>
<div class="signatory-panel-default">
<% if (!is_editing_all_collections) { %>
<a class="signatory-panel-close" href="javascript:void(0);" data-tooltip="Close">
<i class="icon fa fa-close" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Close") %></span>
</a>
<a class="signatory-panel-save" href="javascript:void(0);" data-tooltip="Save">
<i class="icon fa fa-save" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Save") %></span>
</a>
<% } else if (signatories_count > 1 && (total_saved_signatories > 1 || isNew) ) { %>
<% if (is_editing_all_collections && signatories_count > 1 && (total_saved_signatories > 1 || isNew) ) { %>
<a class="signatory-panel-delete" href="#" data-tooltip="Delete">
<i class="icon fa fa-trash-o" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Delete") %></span>
......
......@@ -189,7 +189,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None):
return {
provider.provider_id: third_party_auth.pipeline.get_login_url(
provider.provider_id, auth_entry, redirect_url=redirect_url
) for provider in third_party_auth.provider.Registry.enabled()
) for provider in third_party_auth.provider.Registry.accepting_logins()
}
......
......@@ -1046,9 +1046,10 @@ class CourseEnrollment(models.Model):
`course_key` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
`mode` is a string specifying what kind of enrollment this is. The
default is "honor", meaning honor certificate. Future options
may include "audit", "verified_id", etc. Please don't use it
until we have these mapped out.
default is 'honor', meaning honor certificate. Other options
include 'professional', 'verified', 'audit',
'no-id-professional' and 'credit'.
See CourseMode in common/djangoapps/course_modes/models.py.
`check_access`: if True, we check that an accessible course actually
exists for the given course_key before we enroll the student.
......
......@@ -447,10 +447,11 @@ def register_user(request, extra_context=None):
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_from_pipeline(running_pipeline)
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
overrides['running_pipeline'] = running_pipeline
overrides['selected_provider'] = current_provider.name
context.update(overrides)
if current_provider is not None:
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
overrides['running_pipeline'] = running_pipeline
overrides['selected_provider'] = current_provider.name
context.update(overrides)
return render_to_response('register.html', context)
......@@ -1769,6 +1770,10 @@ def auto_auth(request):
full_name = request.GET.get('full_name', username)
is_staff = request.GET.get('staff', None)
course_id = request.GET.get('course_id', None)
# mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit'
enrollment_mode = request.GET.get('enrollment_mode', 'honor')
course_key = None
if course_id:
course_key = CourseLocator.from_string(course_id)
......@@ -1816,7 +1821,7 @@ def auto_auth(request):
# Enroll the user in a course
if course_key is not None:
CourseEnrollment.enroll(user, course_key)
CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
# Apply the roles
for role_name in role_names:
......
......@@ -6,7 +6,7 @@ Admin site configuration for third party authentication
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData
from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData, LTIProviderConfig
from .tasks import fetch_saml_metadata
......@@ -88,3 +88,26 @@ class SAMLProviderDataAdmin(admin.ModelAdmin):
return self.readonly_fields
admin.site.register(SAMLProviderData, SAMLProviderDataAdmin)
class LTIProviderConfigAdmin(KeyedConfigurationModelAdmin):
""" Django Admin class for LTIProviderConfig """
exclude = (
'icon_class',
'secondary',
)
def get_list_display(self, request):
""" Don't show every single field in the admin change list """
return (
'name',
'enabled',
'lti_consumer_key',
'lti_max_timestamp_age',
'change_date',
'changed_by',
'edit_link',
)
admin.site.register(LTIProviderConfig, LTIProviderConfigAdmin)
"""
Third-party-auth module for Learning Tools Interoperability
"""
import logging
import calendar
import time
from django.contrib.auth import REDIRECT_FIELD_NAME
from oauthlib.common import Request
from oauthlib.oauth1.rfc5849.signature import (
normalize_base_string_uri,
normalize_parameters,
collect_parameters,
construct_base_string,
sign_hmac_sha1,
)
from social.backends.base import BaseAuth
from social.exceptions import AuthFailed
from social.utils import sanitize_redirect
log = logging.getLogger(__name__)
LTI_PARAMS_KEY = 'tpa-lti-params'
class LTIAuthBackend(BaseAuth):
"""
Third-party-auth module for Learning Tools Interoperability
"""
name = 'lti'
def start(self):
"""
Prepare to handle a login request.
This method replaces social.actions.do_auth and must be kept in sync
with any upstream changes in that method. In the current version of
the upstream, this means replacing the logic to populate the session
from request parameters, and not calling backend.start() to avoid
an unwanted redirect to the non-existent login page.
"""
# Clean any partial pipeline data
self.strategy.clean_partial_pipeline()
# Save validated LTI parameters (or None if invalid or not submitted)
validated_lti_params = self.get_validated_lti_params(self.strategy)
# Set a auth_entry here so we don't have to receive that as a custom parameter
self.strategy.session_setdefault('auth_entry', 'login')
if not validated_lti_params:
self.strategy.session_set(LTI_PARAMS_KEY, None)
raise AuthFailed(self, "LTI parameters could not be validated.")
else:
self.strategy.session_set(LTI_PARAMS_KEY, validated_lti_params)
# Save extra data into session.
# While Basic LTI 1.0 specifies that the message is to be signed using OAuth, implying
# that any GET parameters should be stripped from the base URL and included as signed
# parameters, typical LTI Tool Consumer implementations do not support this behaviour. As
# a workaround, we accept TPA parameters from LTI custom parameters prefixed with "tpa_".
for field_name in self.setting('FIELDS_STORED_IN_SESSION', []):
if 'custom_tpa_' + field_name in validated_lti_params:
self.strategy.session_set(field_name, validated_lti_params['custom_tpa_' + field_name])
if 'custom_tpa_' + REDIRECT_FIELD_NAME in validated_lti_params:
# Check and sanitize a user-defined GET/POST next field value
redirect_uri = validated_lti_params['custom_tpa_' + REDIRECT_FIELD_NAME]
if self.setting('SANITIZE_REDIRECTS', True):
redirect_uri = sanitize_redirect(self.strategy.request_host(), redirect_uri)
self.strategy.session_set(REDIRECT_FIELD_NAME, redirect_uri or self.setting('LOGIN_REDIRECT_URL'))
def auth_html(self):
"""
Not used
"""
raise NotImplementedError("Not used")
def auth_url(self):
"""
Not used
"""
raise NotImplementedError("Not used")
def auth_complete(self, *args, **kwargs):
"""
Completes third-part-auth authentication
"""
lti_params = self.strategy.session_get(LTI_PARAMS_KEY)
kwargs.update({'response': {LTI_PARAMS_KEY: lti_params}, 'backend': self})
return self.strategy.authenticate(*args, **kwargs)
def get_user_id(self, details, response):
"""
Computes social auth username from LTI parameters
"""
lti_params = response[LTI_PARAMS_KEY]
return lti_params['oauth_consumer_key'] + ":" + lti_params['user_id']
def get_user_details(self, response):
"""
Retrieves user details from LTI parameters
"""
details = {}
lti_params = response[LTI_PARAMS_KEY]
def add_if_exists(lti_key, details_key):
"""
Adds LTI parameter to user details dict if it exists
"""
if lti_key in lti_params and lti_params[lti_key]:
details[details_key] = lti_params[lti_key]
add_if_exists('email', 'email')
add_if_exists('lis_person_name_full', 'fullname')
add_if_exists('lis_person_name_given', 'first_name')
add_if_exists('lis_person_name_family', 'last_name')
return details
@classmethod
def get_validated_lti_params(cls, strategy):
"""
Validates LTI signature and returns LTI parameters
"""
request = Request(
uri=strategy.request.build_absolute_uri(), http_method=strategy.request.method, body=strategy.request.body
)
lti_consumer_key = request.oauth_consumer_key
(lti_consumer_valid, lti_consumer_secret, lti_max_timestamp_age) = cls.load_lti_consumer(lti_consumer_key)
current_time = calendar.timegm(time.gmtime())
return cls._get_validated_lti_params_from_values(
request=request, current_time=current_time,
lti_consumer_valid=lti_consumer_valid,
lti_consumer_secret=lti_consumer_secret,
lti_max_timestamp_age=lti_max_timestamp_age
)
@classmethod
def _get_validated_lti_params_from_values(cls, request, current_time,
lti_consumer_valid, lti_consumer_secret, lti_max_timestamp_age):
"""
Validates LTI signature and returns LTI parameters
"""
# Taking a cue from oauthlib, to avoid leaking information through a timing attack,
# we proceed through the entire validation before rejecting any request for any reason.
# However, as noted there, the value of doing this is dubious.
base_uri = normalize_base_string_uri(request.uri)
parameters = collect_parameters(uri_query=request.uri_query, body=request.body)
parameters_string = normalize_parameters(parameters)
base_string = construct_base_string(request.http_method, base_uri, parameters_string)
computed_signature = sign_hmac_sha1(base_string, unicode(lti_consumer_secret), '')
submitted_signature = request.oauth_signature
data = {parameter_value_pair[0]: parameter_value_pair[1] for parameter_value_pair in parameters}
def safe_int(value):
"""
Interprets parameter as an int or returns 0 if not possible
"""
try:
return int(value)
except (ValueError, TypeError):
return 0
oauth_timestamp = safe_int(request.oauth_timestamp)
# As this must take constant time, do not use shortcutting operators such as 'and'.
# Instead, use constant time operators such as '&', which is the bitwise and.
valid = (lti_consumer_valid)
valid = valid & (submitted_signature == computed_signature)
valid = valid & (request.oauth_version == '1.0')
valid = valid & (request.oauth_signature_method == 'HMAC-SHA1')
valid = valid & ('user_id' in data) # Not required by LTI but can't log in without one
valid = valid & (oauth_timestamp >= current_time - lti_max_timestamp_age)
valid = valid & (oauth_timestamp <= current_time)
if valid:
return data
else:
return None
@classmethod
def load_lti_consumer(cls, lti_consumer_key):
"""
Retrieves LTI consumer details from database
"""
from .models import LTIProviderConfig
provider_config = LTIProviderConfig.current(lti_consumer_key)
if provider_config and provider_config.enabled:
return (
provider_config.enabled,
provider_config.get_lti_consumer_secret(),
provider_config.lti_max_timestamp_age,
)
else:
return False, '', -1
......@@ -3,6 +3,8 @@
Models used to implement SAML SSO support in third_party_auth
(inlcuding Shibboleth support)
"""
from __future__ import absolute_import
from config_models.models import ConfigurationModel, cache
from django.conf import settings
from django.core.exceptions import ValidationError
......@@ -11,9 +13,11 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
import json
import logging
from provider.utils import long_token
from social.backends.base import BaseAuth
from social.backends.oauth import OAuthAuth
from social.backends.saml import SAMLAuth, SAMLIdentityProvider
from .lti import LTIAuthBackend, LTI_PARAMS_KEY
from social.exceptions import SocialAuthBaseException
from social.utils import module_member
......@@ -32,6 +36,7 @@ def _load_backend_classes(base_class=BaseAuth):
_PSA_BACKENDS = {backend_class.name: backend_class for backend_class in _load_backend_classes()}
_PSA_OAUTH2_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(OAuthAuth)]
_PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)]
_LTI_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(LTIAuthBackend)]
def clean_json(value, of_type):
......@@ -95,6 +100,7 @@ class ProviderConfig(ConfigurationModel):
)
prefix = None # used for provider_id. Set to a string value in subclass
backend_name = None # Set to a field or fixed value in subclass
accepts_logins = True # Whether to display a sign-in button when the provider is enabled
# "enabled" field is inherited from ConfigurationModel
......@@ -454,3 +460,70 @@ class SAMLProviderData(models.Model):
cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout)
return current
class LTIProviderConfig(ProviderConfig):
"""
Configuration required for this edX instance to act as a LTI
Tool Provider and allow users to authenticate and be enrolled in a
course via third party LTI Tool Consumers.
"""
prefix = 'lti'
backend_name = 'lti'
icon_class = None # This provider is not visible to users
secondary = False # This provider is not visible to users
accepts_logins = False # LTI login cannot be initiated by the tool provider
KEY_FIELDS = ('lti_consumer_key', )
lti_consumer_key = models.CharField(
max_length=255,
help_text=(
'The name that the LTI Tool Consumer will use to identify itself'
)
)
lti_consumer_secret = models.CharField(
default=long_token,
max_length=255,
help_text=(
'The shared secret that the LTI Tool Consumer will use to '
'authenticate requests. Only this edX instance and this '
'tool consumer instance should know this value. '
'For increased security, you can avoid storing this in '
'your database by leaving this field blank and setting '
'SOCIAL_AUTH_LTI_CONSUMER_SECRETS = {"consumer key": "secret", ...} '
'in your instance\'s Django setttigs (or lms.auth.json)'
),
blank=True,
)
lti_max_timestamp_age = models.IntegerField(
default=10,
help_text=(
'The maximum age of oauth_timestamp values, in seconds.'
)
)
def match_social_auth(self, social_auth):
""" Is this provider being used for this UserSocialAuth entry? """
prefix = self.lti_consumer_key + ":"
return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix)
def is_active_for_pipeline(self, pipeline):
""" Is this provider being used for the specified pipeline? """
try:
return (
self.backend_name == pipeline['backend'] and
self.lti_consumer_key == pipeline['kwargs']['response'][LTI_PARAMS_KEY]['oauth_consumer_key']
)
except KeyError:
return False
def get_lti_consumer_secret(self):
""" If the LTI consumer secret is not stored in the database, check Django settings instead """
if self.lti_consumer_secret:
return self.lti_consumer_secret
return getattr(settings, 'SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}).get(self.lti_consumer_key, '')
class Meta(object): # pylint: disable=missing-docstring
verbose_name = "Provider Configuration (LTI)"
verbose_name_plural = verbose_name
......@@ -99,13 +99,6 @@ AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_REGISTER = 'register'
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings'
# This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369)
# We need to keep both the old and new entry points
# until every session from before the test ended has expired.
AUTH_ENTRY_LOGIN_2 = 'account_login'
AUTH_ENTRY_REGISTER_2 = 'account_register'
# Entry modes into the authentication process by a remote API call (as opposed to a browser session).
AUTH_ENTRY_LOGIN_API = 'login_api'
AUTH_ENTRY_REGISTER_API = 'register_api'
......@@ -126,28 +119,12 @@ AUTH_DISPATCH_URLS = {
AUTH_ENTRY_LOGIN: '/login',
AUTH_ENTRY_REGISTER: '/register',
AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings',
# This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369)
# We need to keep both the old and new entry points
# until every session from before the test ended has expired.
AUTH_ENTRY_LOGIN_2: '/account/login/',
AUTH_ENTRY_REGISTER_2: '/account/register/',
}
_AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_LOGIN,
AUTH_ENTRY_REGISTER,
AUTH_ENTRY_ACCOUNT_SETTINGS,
# This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369)
# We need to keep both the old and new entry points
# until every session from before the test ended has expired.
AUTH_ENTRY_LOGIN_2,
AUTH_ENTRY_REGISTER_2,
AUTH_ENTRY_LOGIN_API,
AUTH_ENTRY_REGISTER_API,
])
......@@ -395,9 +372,10 @@ def get_provider_user_states(user):
if enabled_provider.match_social_auth(auth):
association_id = auth.id
break
states.append(
ProviderUserState(enabled_provider, user, association_id)
)
if enabled_provider.accepts_logins or association_id:
states.append(
ProviderUserState(enabled_provider, user, association_id)
)
return states
......@@ -508,13 +486,13 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
if not user:
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest()
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
elif auth_entry == AUTH_ENTRY_LOGIN:
# User has authenticated with the third party provider but we don't know which edX
# account corresponds to them yet, if any.
if should_force_account_creation():
return dispatch_to_register()
return dispatch_to_login()
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
elif auth_entry == AUTH_ENTRY_REGISTER:
# User has authenticated with the third party provider and now wants to finish
# creating their edX account.
return dispatch_to_register()
......@@ -603,7 +581,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
""" Sends login info to Segment.io """
event_name = None
if auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
if auth_entry == AUTH_ENTRY_LOGIN:
event_name = 'edx.bi.user.account.authenticated'
elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]:
event_name = 'edx.bi.user.account.linked'
......
......@@ -2,8 +2,8 @@
Third-party auth provider configuration API.
"""
from .models import (
OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig,
_PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS
OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, LTIProviderConfig,
_PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS, _LTI_BACKENDS,
)
......@@ -26,6 +26,10 @@ class Registry(object):
provider = SAMLProviderConfig.current(idp_slug)
if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS:
yield provider
for consumer_key in LTIProviderConfig.key_values('lti_consumer_key', flat=True):
provider = LTIProviderConfig.current(consumer_key)
if provider.enabled and provider.backend_name in _LTI_BACKENDS:
yield provider
@classmethod
def enabled(cls):
......@@ -33,6 +37,11 @@ class Registry(object):
return sorted(cls._enabled_providers(), key=lambda provider: provider.name)
@classmethod
def accepting_logins(cls):
"""Returns list of providers that can be used to initiate logins currently"""
return [provider for provider in cls.enabled() if provider.accepts_logins]
@classmethod
def get(cls, provider_id):
"""Gets provider by provider_id string if enabled, else None."""
if '-' not in provider_id: # Check format - see models.py:ProviderConfig
......@@ -83,3 +92,8 @@ class Registry(object):
provider = SAMLProviderConfig.current(idp_name)
if provider.backend_name == backend_name and provider.enabled:
yield provider
elif backend_name in _LTI_BACKENDS:
for consumer_key in LTIProviderConfig.key_values('lti_consumer_key', flat=True):
provider = LTIProviderConfig.current(consumer_key)
if provider.backend_name == backend_name and provider.enabled:
yield provider
......@@ -20,6 +20,8 @@ class ConfigurationModelStrategy(DjangoStrategy):
OAuthAuth subclasses will call this method for every setting they want to look up.
SAMLAuthBackend subclasses will call this method only after first checking if the
setting 'name' is configured via SAMLProviderConfig.
LTIAuthBackend subclasses will call this method only after first checking if the
setting 'name' is configured via LTIProviderConfig.
"""
if isinstance(backend, OAuthAuth):
provider_config = OAuth2ProviderConfig.current(backend.name)
......@@ -29,6 +31,6 @@ class ConfigurationModelStrategy(DjangoStrategy):
return provider_config.get_setting(name)
except KeyError:
pass
# At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row.
# At this point, we know 'name' is not set in a [OAuth2|LTI|SAML]ProviderConfig row.
# It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION':
return super(ConfigurationModelStrategy, self).setting(name, default, backend)
lti_message_type=basic-lti-launch-request&lti_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank
\ No newline at end of file
some=garbage&values=provided
\ No newline at end of file
lti_message_type=basic-lti-launch-request&lti_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpXXXXX%3D&oauth_callback=about%3Ablank
\ No newline at end of file
lti_message_type=basic-lti-launch-request&lti_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank
\ No newline at end of file
lti_message_type=basic-lti-launch-request&lti_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank
\ No newline at end of file
lti_message_type=basic-lti-launch-request&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank
\ No newline at end of file
......@@ -381,12 +381,6 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self):
self.assert_exception_redirect_looks_correct('/register', auth_entry=pipeline.AUTH_ENTRY_REGISTER)
def test_canceling_authentication_redirects_to_login_when_auth_login_2(self):
self.assert_exception_redirect_looks_correct('/account/login/', auth_entry=pipeline.AUTH_ENTRY_LOGIN_2)
def test_canceling_authentication_redirects_to_login_when_auth_register_2(self):
self.assert_exception_redirect_looks_correct('/account/register/', auth_entry=pipeline.AUTH_ENTRY_REGISTER_2)
def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self):
self.assert_exception_redirect_looks_correct(
'/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS
......
"""
Integration tests for third_party_auth LTI auth providers
"""
import unittest
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from oauthlib.oauth1.rfc5849 import Client, SIGNATURE_TYPE_BODY
from third_party_auth.tests import testutil
FORM_ENCODED = 'application/x-www-form-urlencoded'
LTI_CONSUMER_KEY = 'consumer'
LTI_CONSUMER_SECRET = 'secret'
LTI_TPA_LOGIN_URL = 'http://testserver/auth/login/lti/'
LTI_TPA_COMPLETE_URL = 'http://testserver/auth/complete/lti/'
OTHER_LTI_CONSUMER_KEY = 'settings-consumer'
OTHER_LTI_CONSUMER_SECRET = 'secret2'
LTI_USER_ID = 'lti_user_id'
EDX_USER_ID = 'test_user'
EMAIL = 'lti_user@example.com'
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class IntegrationTestLTI(testutil.TestCase):
"""
Integration tests for third_party_auth LTI auth providers
"""
def setUp(self):
super(IntegrationTestLTI, self).setUp()
self.configure_lti_provider(
name='Other Tool Consumer 1', enabled=True,
lti_consumer_key='other1',
lti_consumer_secret='secret1',
lti_max_timestamp_age=10,
)
self.configure_lti_provider(
name='LTI Test Tool Consumer', enabled=True,
lti_consumer_key=LTI_CONSUMER_KEY,
lti_consumer_secret=LTI_CONSUMER_SECRET,
lti_max_timestamp_age=10,
)
self.configure_lti_provider(
name='Tool Consumer with Secret in Settings', enabled=True,
lti_consumer_key=OTHER_LTI_CONSUMER_KEY,
lti_consumer_secret='',
lti_max_timestamp_age=10,
)
self.lti = Client(
client_key=LTI_CONSUMER_KEY,
client_secret=LTI_CONSUMER_SECRET,
signature_type=SIGNATURE_TYPE_BODY,
)
def test_lti_login(self):
# The user initiates a login from an external site
(uri, _headers, body) = self.lti.sign(
uri=LTI_TPA_LOGIN_URL, http_method='POST',
headers={'Content-Type': FORM_ENCODED},
body={
'user_id': LTI_USER_ID,
'custom_tpa_next': '/account/finish_auth/?course_id=my_course_id&enrollment_action=enroll',
}
)
login_response = self.client.post(path=uri, content_type=FORM_ENCODED, data=body)
# The user should be redirected to the registration form
self.assertEqual(login_response.status_code, 302)
self.assertTrue(login_response['Location'].endswith(reverse('signin_user')))
register_response = self.client.get(login_response['Location'])
self.assertEqual(register_response.status_code, 200)
self.assertIn('currentProvider&#34;: &#34;LTI Test Tool Consumer&#34;', register_response.content)
self.assertIn('&#34;errorMessage&#34;: null', register_response.content)
# Now complete the form:
ajax_register_response = self.client.post(
reverse('user_api_registration'),
{
'email': EMAIL,
'name': 'Myself',
'username': EDX_USER_ID,
'honor_code': True,
}
)
self.assertEqual(ajax_register_response.status_code, 200)
continue_response = self.client.get(LTI_TPA_COMPLETE_URL)
# The user should be redirected to the finish_auth view which will enroll them.
# FinishAuthView.js reads the URL parameters directly from $.url
self.assertEqual(continue_response.status_code, 302)
self.assertEqual(
continue_response['Location'],
'http://testserver/account/finish_auth/?course_id=my_course_id&enrollment_action=enroll'
)
# Now check that we can login again
self.client.logout()
self.verify_user_email(EMAIL)
(uri, _headers, body) = self.lti.sign(
uri=LTI_TPA_LOGIN_URL, http_method='POST',
headers={'Content-Type': FORM_ENCODED},
body={'user_id': LTI_USER_ID}
)
login_2_response = self.client.post(path=uri, content_type=FORM_ENCODED, data=body)
# The user should be redirected to the dashboard
self.assertEqual(login_2_response.status_code, 302)
self.assertEqual(login_2_response['Location'], LTI_TPA_COMPLETE_URL)
continue_2_response = self.client.get(login_2_response['Location'])
self.assertEqual(continue_2_response.status_code, 302)
self.assertTrue(continue_2_response['Location'].endswith(reverse('dashboard')))
# Check that the user was created correctly
user = User.objects.get(email=EMAIL)
self.assertEqual(user.username, EDX_USER_ID)
def test_reject_initiating_login(self):
response = self.client.get(LTI_TPA_LOGIN_URL)
self.assertEqual(response.status_code, 405) # Not Allowed
def test_reject_bad_login(self):
login_response = self.client.post(
path=LTI_TPA_LOGIN_URL, content_type=FORM_ENCODED,
data="invalid=login"
)
# The user should be redirected to the login page with an error message
# (auth_entry defaults to login for this provider)
self.assertEqual(login_response.status_code, 302)
self.assertTrue(login_response['Location'].endswith(reverse('signin_user')))
error_response = self.client.get(login_response['Location'])
self.assertIn(
'Authentication failed: LTI parameters could not be validated.',
error_response.content
)
def test_can_load_consumer_secret_from_settings(self):
lti = Client(
client_key=OTHER_LTI_CONSUMER_KEY,
client_secret=OTHER_LTI_CONSUMER_SECRET,
signature_type=SIGNATURE_TYPE_BODY,
)
(uri, _headers, body) = lti.sign(
uri=LTI_TPA_LOGIN_URL, http_method='POST',
headers={'Content-Type': FORM_ENCODED},
body={
'user_id': LTI_USER_ID,
'custom_tpa_next': '/account/finish_auth/?course_id=my_course_id&enrollment_action=enroll',
}
)
with self.settings(SOCIAL_AUTH_LTI_CONSUMER_SECRETS={OTHER_LTI_CONSUMER_KEY: OTHER_LTI_CONSUMER_SECRET}):
login_response = self.client.post(path=uri, content_type=FORM_ENCODED, data=body)
# The user should be redirected to the registration form
self.assertEqual(login_response.status_code, 302)
self.assertTrue(login_response['Location'].endswith(reverse('signin_user')))
register_response = self.client.get(login_response['Location'])
self.assertEqual(register_response.status_code, 200)
self.assertIn(
'currentProvider&#34;: &#34;Tool Consumer with Secret in Settings&#34;',
register_response.content
)
self.assertIn('&#34;errorMessage&#34;: null', register_response.content)
"""
Third_party_auth integration tests using a mock version of the TestShib provider
"""
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
import httpretty
from mock import patch
......@@ -38,7 +37,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
def metadata_callback(_request, _uri, headers):
""" Return a cached copy of TestShib's metadata by reading it from disk """
return (200, headers, self._read_data_file('testshib_metadata.xml'))
return (200, headers, self.read_data_file('testshib_metadata.xml'))
httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback)
self.addCleanup(httpretty.disable)
self.addCleanup(httpretty.reset)
......@@ -106,7 +105,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
# Now check that we can login again:
self.client.logout()
self._verify_user_email('myself@testshib.org')
self.verify_user_email('myself@testshib.org')
self._test_return_login()
def test_login(self):
......@@ -220,11 +219,5 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
return self.client.post(
TPA_TESTSHIB_COMPLETE_URL,
content_type='application/x-www-form-urlencoded',
data=self._read_data_file('testshib_response.txt'),
data=self.read_data_file('testshib_response.txt'),
)
def _verify_user_email(self, email):
""" Mark the user with the given email as verified """
user = User.objects.get(email=email)
user.is_active = True
user.save()
"""
Unit tests for third_party_auth LTI auth providers
"""
import unittest
from oauthlib.common import Request
from third_party_auth.lti import LTIAuthBackend, LTI_PARAMS_KEY
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
class UnitTestLTI(unittest.TestCase, ThirdPartyAuthTestMixin):
"""
Unit tests for third_party_auth LTI auth providers
"""
def test_get_user_details_missing_keys(self):
lti = LTIAuthBackend()
details = lti.get_user_details({LTI_PARAMS_KEY: {
'lis_person_name_full': 'Full name'
}})
self.assertEquals(details, {
'fullname': 'Full name'
})
def test_get_user_details_extra_keys(self):
lti = LTIAuthBackend()
details = lti.get_user_details({LTI_PARAMS_KEY: {
'lis_person_name_full': 'Full name',
'lis_person_name_given': 'Given',
'lis_person_name_family': 'Family',
'email': 'user@example.com',
'other': 'something else'
}})
self.assertEquals(details, {
'fullname': 'Full name',
'first_name': 'Given',
'last_name': 'Family',
'email': 'user@example.com'
})
def test_get_user_id(self):
lti = LTIAuthBackend()
user_id = lti.get_user_id(None, {LTI_PARAMS_KEY: {
'oauth_consumer_key': 'consumer',
'user_id': 'user'
}})
self.assertEquals(user_id, 'consumer:user')
def test_validate_lti_valid_request(self):
request = Request(
uri='https://example.com/lti',
http_method='POST',
body=self.read_data_file('lti_valid_request.txt')
)
parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access
request=request, current_time=1436823554,
lti_consumer_valid=True, lti_consumer_secret='secret',
lti_max_timestamp_age=10
)
self.assertTrue(parameters)
self.assertDictContainsSubset({
'custom_extra': 'parameter',
'user_id': '292832126'
}, parameters)
def test_validate_lti_valid_request_with_get_params(self):
request = Request(
uri='https://example.com/lti?user_id=292832126&lti_version=LTI-1p0',
http_method='POST',
body=self.read_data_file('lti_valid_request_with_get_params.txt')
)
parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access
request=request, current_time=1436823554,
lti_consumer_valid=True, lti_consumer_secret='secret',
lti_max_timestamp_age=10
)
self.assertTrue(parameters)
self.assertDictContainsSubset({
'custom_extra': 'parameter',
'user_id': '292832126'
}, parameters)
def test_validate_lti_old_timestamp(self):
request = Request(
uri='https://example.com/lti',
http_method='POST',
body=self.read_data_file('lti_old_timestamp.txt')
)
parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access
request=request, current_time=1436900000,
lti_consumer_valid=True, lti_consumer_secret='secret',
lti_max_timestamp_age=10
)
self.assertFalse(parameters)
def test_validate_lti_invalid_signature(self):
request = Request(
uri='https://example.com/lti',
http_method='POST',
body=self.read_data_file('lti_invalid_signature.txt')
)
parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access
request=request, current_time=1436823554,
lti_consumer_valid=True, lti_consumer_secret='secret',
lti_max_timestamp_age=10
)
self.assertFalse(parameters)
def test_validate_lti_cannot_add_get_params(self):
request = Request(
uri='https://example.com/lti?custom_another=parameter',
http_method='POST',
body=self.read_data_file('lti_cannot_add_get_params.txt')
)
parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access
request=request, current_time=1436823554,
lti_consumer_valid=True, lti_consumer_secret='secret',
lti_max_timestamp_age=10
)
self.assertFalse(parameters)
def test_validate_lti_garbage(self):
request = Request(
uri='https://example.com/lti',
http_method='POST',
body=self.read_data_file('lti_garbage.txt')
)
parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access
request=request, current_time=1436823554,
lti_consumer_valid=True, lti_consumer_secret='secret',
lti_max_timestamp_age=10
)
self.assertFalse(parameters)
......@@ -6,11 +6,18 @@ Used by Django and non-Django tests; must not have Django deps.
from contextlib import contextmanager
from django.conf import settings
from django.contrib.auth.models import User
import django.test
import mock
import os.path
from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache
from third_party_auth.models import (
OAuth2ProviderConfig,
SAMLProviderConfig,
SAMLConfiguration,
LTIProviderConfig,
cache as config_cache,
)
AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
......@@ -52,6 +59,13 @@ class ThirdPartyAuthTestMixin(object):
obj.save()
return obj
@staticmethod
def configure_lti_provider(**kwargs):
""" Update the settings for a LTI Tool Consumer third party auth provider """
obj = LTIProviderConfig(**kwargs)
obj.save()
return obj
@classmethod
def configure_google_provider(cls, **kwargs):
""" Update the settings for the Google third party auth provider/backend """
......@@ -92,6 +106,19 @@ class ThirdPartyAuthTestMixin(object):
kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def verify_user_email(cls, email):
""" Mark the user with the given email as verified """
user = User.objects.get(email=email)
user.is_active = True
user.save()
@staticmethod
def read_data_file(filename):
""" Read the contents of a file in the data folder """
with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f:
return f.read()
class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase):
"""Base class for auth test cases."""
......@@ -111,18 +138,12 @@ class SAMLTestCase(TestCase):
@classmethod
def _get_public_key(cls, key_name='saml_key'):
""" Get a public key for use in the test. """
return cls._read_data_file('{}.pub'.format(key_name))
return cls.read_data_file('{}.pub'.format(key_name))
@classmethod
def _get_private_key(cls, key_name='saml_key'):
""" Get a private key for use in the test. """
return cls._read_data_file('{}.key'.format(key_name))
@staticmethod
def _read_data_file(filename):
""" Read the contents of a file in the data folder """
with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f:
return f.read()
return cls.read_data_file('{}.key'.format(key_name))
def enable_saml(self, **kwargs):
""" Enable SAML support (via SAMLConfiguration, not for any particular provider) """
......
......@@ -2,11 +2,12 @@
from django.conf.urls import include, patterns, url
from .views import inactive_user_view, saml_metadata_view
from .views import inactive_user_view, saml_metadata_view, lti_login_and_complete_view
urlpatterns = patterns(
'',
url(r'^auth/inactive', inactive_user_view),
url(r'^auth/saml/metadata.xml', saml_metadata_view),
url(r'^auth/login/(?P<backend>lti)/$', lti_login_and_complete_view),
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
)
......@@ -3,11 +3,17 @@ Extra views required for SSO
"""
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseServerError, Http404
from django.http import HttpResponse, HttpResponseServerError, Http404, HttpResponseNotAllowed
from django.shortcuts import redirect
from django.views.decorators.csrf import csrf_exempt
import social
from social.apps.django_app.views import complete
from social.apps.django_app.utils import load_strategy, load_backend
from social.utils import setting_name
from .models import SAMLConfiguration
URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social'
def inactive_user_view(request):
"""
......@@ -36,3 +42,15 @@ def saml_metadata_view(request):
if not errors:
return HttpResponse(content=metadata, content_type='text/xml')
return HttpResponseServerError(content=', '.join(errors))
@csrf_exempt
@social.apps.django_app.utils.psa('{0}:complete'.format(URL_NAMESPACE))
def lti_login_and_complete_view(request, backend, *args, **kwargs):
"""This is a combination login/complete due to LTI being a one step login"""
if request.method != 'POST':
return HttpResponseNotAllowed('POST')
request.backend.start()
return complete(request, backend, *args, **kwargs)
......@@ -60,6 +60,7 @@ lib_paths:
- public/js/split_test_staff.js
- common_static/js/src/accessibility_tools.js
- common_static/js/vendor/moment.min.js
- spec/main_requirejs.js
# Paths to spec (test) JavaScript files
spec_paths:
......
(function(requirejs) {
requirejs.config({
paths: {
"moment": "xmodule/include/common_static/js/vendor/moment.min"
},
"moment": {
exports: "moment"
}
});
}).call(this, RequireJS.requirejs);
......@@ -14,10 +14,9 @@
define(
'video/01_initialize.js',
['video/03_video_player.js', 'video/00_i18n.js'],
function (VideoPlayer, i18n) {
var moment = window.moment;
['video/03_video_player.js', 'video/00_i18n.js', 'moment'],
function (VideoPlayer, i18n, moment) {
var moment = moment || window.moment;
/**
* @function
*
......
......@@ -281,21 +281,6 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return courses.values()
@strip_key
def get_courses_keys(self, **kwargs):
'''
Returns a list containing the top level XModuleDescriptors keys of the courses in this modulestore.
'''
courses = {}
for store in self.modulestores:
# filter out ones which were fetched from earlier stores but locations may not be ==
for course in store.get_courses(**kwargs):
course_id = self._clean_locator_for_mapping(course.id)
if course_id not in courses:
# course is indeed unique. save it in result
courses[course_id] = course
return courses.keys()
@strip_key
def get_libraries(self, **kwargs):
"""
Returns a list containing the top level XBlock of the libraries (LibraryRoot) in this modulestore.
......
......@@ -30,6 +30,8 @@
isZeroIndexed: false,
perPage: 10,
isStale: false,
sortField: '',
sortDirection: 'descending',
sortableFields: {},
......@@ -37,6 +39,8 @@
filterField: '',
filterableFields: {},
searchString: null,
paginator_core: {
type: 'GET',
dataType: 'json',
......@@ -51,9 +55,10 @@
},
server_api: {
'page': function () { return this.currentPage; },
'page_size': function () { return this.perPage; },
'sort_order': function () { return this.sortField; }
page: function () { return this.currentPage; },
page_size: function () { return this.perPage; },
text_search: function () { return this.searchString ? this.searchString : ''; },
sort_order: function () { return this.sortField; }
},
parse: function (response) {
......@@ -61,7 +66,11 @@
this.currentPage = response.current_page;
this.totalPages = response.num_pages;
this.start = response.start;
this.sortField = response.sort_order;
// Note: sort_order is not returned when performing a search
if (response.sort_order) {
this.sortField = response.sort_order;
}
return response.results;
},
......@@ -84,6 +93,7 @@
self = this;
return this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
function () {
self.isStale = false;
self.trigger('page_changed');
},
function () {
......@@ -92,6 +102,24 @@
);
},
/**
* Refreshes the collection if it has been marked as stale.
* @returns {promise} Returns a promise representing the refresh.
*/
refresh: function() {
var deferred = $.Deferred();
if (this.isStale) {
this.setPage(1)
.done(function() {
deferred.resolve();
});
} else {
deferred.resolve();
}
return deferred.promise();
},
/**
* Returns true if the collection has a next page, false otherwise.
*/
......@@ -183,7 +211,7 @@
}
}
this.sortField = fieldName;
this.setPage(1);
this.isStale = true;
},
/**
......@@ -193,7 +221,7 @@
*/
setSortDirection: function (direction) {
this.sortDirection = direction;
this.setPage(1);
this.isStale = true;
},
/**
......@@ -203,7 +231,19 @@
*/
setFilterField: function (fieldName) {
this.filterField = fieldName;
this.setPage(1);
this.isStale = true;
},
/**
* Sets the string to use for a text search. If no string is specified then
* the search is cleared.
* @param searchString A string to search on, or null if no search is to be applied.
*/
setSearchString: function(searchString) {
if (searchString !== this.searchString) {
this.searchString = searchString;
this.isStale = true;
}
}
}, {
SortDirection: {
......
......@@ -43,10 +43,16 @@
return this;
},
/**
* Updates the collection's sort order, and fetches an updated set of
* results.
* @returns {*} A promise for the collection being updated
*/
sortCollection: function () {
var selected = this.$('#paging-header-select option:selected');
this.sortOrder = selected.attr('value');
this.collection.setSortField(this.sortOrder);
return this.collection.refresh();
}
});
return PagingHeader;
......
/**
* A search field that works in concert with a paginated collection. When the user
* performs a search, the collection's search string will be updated and then the
* collection will be refreshed to show the first page of results.
*/
;(function (define) {
'use strict';
define(['backbone', 'jquery', 'underscore', 'text!common/templates/components/search-field.underscore'],
function (Backbone, $, _, searchFieldTemplate) {
return Backbone.View.extend({
events: {
'submit .search-form': 'performSearch',
'blur .search-form': 'onFocusOut',
'keyup .search-field': 'refreshState',
'click .action-clear': 'clearSearch'
},
initialize: function(options) {
this.type = options.type;
this.label = options.label;
},
refreshState: function() {
var searchField = this.$('.search-field'),
clearButton = this.$('.action-clear'),
searchString = $.trim(searchField.val());
if (searchString) {
clearButton.removeClass('is-hidden');
} else {
clearButton.addClass('is-hidden');
}
},
render: function() {
this.$el.html(_.template(searchFieldTemplate, {
type: this.type,
searchString: this.collection.searchString,
searchLabel: this.label
}));
this.refreshState();
return this;
},
onFocusOut: function(event) {
// If the focus is going anywhere but the clear search
// button then treat it as a request to search.
if (!$(event.relatedTarget).hasClass('action-clear')) {
this.performSearch(event);
}
},
performSearch: function(event) {
var searchField = this.$('.search-field'),
searchString = $.trim(searchField.val());
event.preventDefault();
this.collection.setSearchString(searchString);
return this.collection.refresh();
},
clearSearch: function(event) {
event.preventDefault();
this.$('.search-field').val('');
this.collection.setSearchString('');
this.refreshState();
return this.collection.refresh();
}
});
});
}).call(this, define || RequireJS.define);
......@@ -10,11 +10,11 @@ define(['jquery',
'use strict';
describe('PagingCollection', function () {
var collection, requests, server, assertQueryParams;
server = {
var collection;
var server = {
isZeroIndexed: false,
count: 43,
respond: function () {
respond: function (requests) {
var params = (new URI(requests[requests.length - 1].url)).query(true),
page = parseInt(params['page'], 10),
page_size = parseInt(params['page_size'], 10),
......@@ -35,7 +35,7 @@ define(['jquery',
}
}
};
assertQueryParams = function (params) {
var assertQueryParams = function (requests, params) {
var urlParams = (new URI(requests[requests.length - 1].url)).query(true);
_.each(params, function (value, key) {
expect(urlParams[key]).toBe(value);
......@@ -45,7 +45,6 @@ define(['jquery',
beforeEach(function () {
collection = new PagingCollection();
collection.perPage = 10;
requests = AjaxHelpers.requests(this);
server.isZeroIndexed = false;
server.count = 43;
});
......@@ -69,10 +68,11 @@ define(['jquery',
});
it('can set the sort field', function () {
var requests = AjaxHelpers.requests(this);
collection.registerSortableField('test_field', 'Test Field');
collection.setSortField('test_field', false);
expect(requests.length).toBe(1);
assertQueryParams({'sort_order': 'test_field'});
collection.refresh();
assertQueryParams(requests, {'sort_order': 'test_field'});
expect(collection.sortField).toBe('test_field');
expect(collection.sortDisplayName()).toBe('Test Field');
});
......@@ -80,7 +80,7 @@ define(['jquery',
it('can set the filter field', function () {
collection.registerFilterableField('test_field', 'Test Field');
collection.setFilterField('test_field');
expect(requests.length).toBe(1);
collection.refresh();
// The default implementation does not send any query params for filtering
expect(collection.filterField).toBe('test_field');
expect(collection.filterDisplayName()).toBe('Test Field');
......@@ -88,11 +88,9 @@ define(['jquery',
it('can set the sort direction', function () {
collection.setSortDirection(PagingCollection.SortDirection.ASCENDING);
expect(requests.length).toBe(1);
// The default implementation does not send any query params for sort direction
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING);
collection.setSortDirection(PagingCollection.SortDirection.DESCENDING);
expect(requests.length).toBe(2);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
});
......@@ -113,11 +111,12 @@ define(['jquery',
'queries with page, page_size, and sort_order parameters when zero indexed': [true, 2],
'queries with page, page_size, and sort_order parameters when one indexed': [false, 3],
}, function (isZeroIndexed, page) {
var requests = AjaxHelpers.requests(this);
collection.isZeroIndexed = isZeroIndexed;
collection.perPage = 5;
collection.sortField = 'test_field';
collection.setPage(3);
assertQueryParams({'page': page.toString(), 'page_size': '5', 'sort_order': 'test_field'});
assertQueryParams(requests, {'page': page.toString(), 'page_size': '5', 'sort_order': 'test_field'});
});
SpecHelpers.withConfiguration({
......@@ -129,27 +128,30 @@ define(['jquery',
}, function () {
describe('setPage', function() {
it('triggers a reset event when the page changes successfully', function () {
var resetTriggered = false;
var requests = AjaxHelpers.requests(this),
resetTriggered = false;
collection.on('reset', function () { resetTriggered = true; });
collection.setPage(3);
server.respond();
server.respond(requests);
expect(resetTriggered).toBe(true);
});
it('triggers an error event when the requested page is out of range', function () {
var errorTriggered = false;
var requests = AjaxHelpers.requests(this),
errorTriggered = false;
collection.on('error', function () { errorTriggered = true; });
collection.setPage(17);
server.respond();
server.respond(requests);
expect(errorTriggered).toBe(true);
});
it('triggers an error event if the server responds with a 500', function () {
var errorTriggered = false;
var requests = AjaxHelpers.requests(this),
errorTriggered = false;
collection.on('error', function () { errorTriggered = true; });
collection.setPage(2);
expect(collection.getPage()).toBe(2);
server.respond();
server.respond(requests);
collection.setPage(3);
AjaxHelpers.respondWithError(requests, 500, {}, requests.length - 1);
expect(errorTriggered).toBe(true);
......@@ -159,11 +161,12 @@ define(['jquery',
describe('getPage', function () {
it('returns the correct page', function () {
var requests = AjaxHelpers.requests(this);
collection.setPage(1);
server.respond();
server.respond(requests);
expect(collection.getPage()).toBe(1);
collection.setPage(3);
server.respond();
server.respond(requests);
expect(collection.getPage()).toBe(3);
});
});
......@@ -177,9 +180,10 @@ define(['jquery',
'returns false on the last page': [5, 43, false]
},
function (page, count, result) {
var requests = AjaxHelpers.requests(this);
server.count = count;
collection.setPage(page);
server.respond();
server.respond(requests);
expect(collection.hasNextPage()).toBe(result);
}
);
......@@ -194,9 +198,10 @@ define(['jquery',
'returns false on the first page': [1, 43, false]
},
function (page, count, result) {
var requests = AjaxHelpers.requests(this);
server.count = count;
collection.setPage(page);
server.respond();
server.respond(requests);
expect(collection.hasPreviousPage()).toBe(result);
}
);
......@@ -209,13 +214,14 @@ define(['jquery',
'silently fails on the last page': [5, 43, 5]
},
function (page, count, newPage) {
var requests = AjaxHelpers.requests(this);
server.count = count;
collection.setPage(page);
server.respond();
server.respond(requests);
expect(collection.getPage()).toBe(page);
collection.nextPage();
if (requests.length > 1) {
server.respond();
server.respond(requests);
}
expect(collection.getPage()).toBe(newPage);
}
......@@ -229,13 +235,14 @@ define(['jquery',
'silently fails on the first page': [1, 43, 1]
},
function (page, count, newPage) {
var requests = AjaxHelpers.requests(this);
server.count = count;
collection.setPage(page);
server.respond();
server.respond(requests);
expect(collection.getPage()).toBe(page);
collection.previousPage();
if (requests.length > 1) {
server.respond();
server.respond(requests);
}
expect(collection.getPage()).toBe(newPage);
}
......
define([
'underscore',
'common/js/components/views/search_field',
'common/js/components/collections/paging_collection',
'common/js/spec_helpers/ajax_helpers'
], function (_, SearchFieldView, PagingCollection, AjaxHelpers) {
'use strict';
describe('SearchFieldView', function () {
var searchFieldView,
mockUrl = '/api/mock_collection';
var newCollection = function (size, perPage) {
var pageSize = 5,
results = _.map(_.range(size), function (i) { return {foo: i}; });
var collection = new PagingCollection(
[],
{
url: mockUrl,
count: results.length,
num_pages: results.length / pageSize,
current_page: 1,
start: 0,
results: _.first(results, perPage)
},
{parse: true}
);
collection.start = 0;
collection.totalCount = results.length;
return collection;
};
var createSearchFieldView = function (options) {
options = _.extend(
{
type: 'test',
collection: newCollection(5, 4),
el: $('.test-search')
},
options || {}
);
return new SearchFieldView(options);
};
beforeEach(function() {
setFixtures('<section class="test-search"></section>');
});
it('correctly displays itself', function () {
searchFieldView = createSearchFieldView().render();
expect(searchFieldView.$('.search-field').val(), '');
expect(searchFieldView.$('.action-clear')).toHaveClass('is-hidden');
});
it('can display with an initial search string', function () {
searchFieldView = createSearchFieldView({
searchString: 'foo'
}).render();
expect(searchFieldView.$('.search-field').val(), 'foo');
});
it('refreshes the collection when performing a search', function () {
var requests = AjaxHelpers.requests(this);
searchFieldView = createSearchFieldView().render();
searchFieldView.$('.search-field').val('foo');
searchFieldView.$('.action-search').click();
AjaxHelpers.expectRequestURL(requests, mockUrl, {
page: '1',
page_size: '10',
sort_order: '',
text_search: 'foo'
});
AjaxHelpers.respondWithJson(requests, {
count: 10,
current_page: 1,
num_pages: 1,
start: 0,
results: []
});
expect(searchFieldView.$('.search-field').val(), 'foo');
});
it('can clear the search', function () {
var requests = AjaxHelpers.requests(this);
searchFieldView = createSearchFieldView({
searchString: 'foo'
}).render();
searchFieldView.$('.action-clear').click();
AjaxHelpers.expectRequestURL(requests, mockUrl, {
page: '1',
page_size: '10',
sort_order: '',
text_search: ''
});
AjaxHelpers.respondWithJson(requests, {
count: 10,
current_page: 1,
num_pages: 1,
start: 0,
results: []
});
expect(searchFieldView.$('.search-field').val(), '');
expect(searchFieldView.$('.action-clear')).toHaveClass('is-hidden');
});
});
});
define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
'use strict';
var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest, expectJsonRequestURL,
var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest, expectRequestURL,
respondWithJson, respondWithError, respondWithTextError, respondWithNoContent;
/* These utility methods are used by Jasmine tests to create a mock server or
......@@ -77,7 +77,7 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
* @param expectedParameters An object representing the URL parameters
* @param requestIndex An optional index for the request (by default, the last request is used)
*/
expectJsonRequestURL = function(requests, expectedUrl, expectedParameters, requestIndex) {
expectRequestURL = function(requests, expectedUrl, expectedParameters, requestIndex) {
var request, parameters;
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
......@@ -153,15 +153,15 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
};
return {
'server': fakeServer,
'requests': fakeRequests,
'expectRequest': expectRequest,
'expectJsonRequest': expectJsonRequest,
'expectJsonRequestURL': expectJsonRequestURL,
'expectPostRequest': expectPostRequest,
'respondWithJson': respondWithJson,
'respondWithError': respondWithError,
'respondWithTextError': respondWithTextError,
'respondWithNoContent': respondWithNoContent,
server: fakeServer,
requests: fakeRequests,
expectRequest: expectRequest,
expectJsonRequest: expectJsonRequest,
expectPostRequest: expectPostRequest,
expectRequestURL: expectRequestURL,
respondWithJson: respondWithJson,
respondWithError: respondWithError,
respondWithTextError: respondWithTextError,
respondWithNoContent: respondWithNoContent
};
});
<div class="page-header-search wrapper-search-<%= type %>">
<form class="search-form">
<div class="wrapper-search-input">
<label for="search-<%= type %>" class="search-label">><%- searchLabel %></label>
<input id="search-<%= type %>" class="search-field" type="text" value="<%- searchString %>" placeholder="<%- searchLabel %>" />
<button type="button" class="action action-clear <%= searchLabel ? '' : 'is-hidden' %>" aria-label="<%- gettext('Clear search') %>">
<i class="icon fa fa-times-circle" aria-hidden="true"></i><span class="sr"><%- gettext('Search') %></span>
</button>
</div>
<button type="submit" class="action action-search"><span class="icon fa-search" aria-hidden="true"></span><span class="sr"><%- gettext('Search') %></span></button>
</form>
</div>
......@@ -15,8 +15,8 @@
<% } %>
<div class="copy">
<h2 class="title title-3" id="<%= type %>-<%= intent %>-title"><%= title %></h2>
<% if(obj.message) { %><p class="message" id="<%= type %>-<%= intent %>-description"><%= message %></p><% } %>
<h2 class="title title-3" id="<%= type %>-<%= intent %>-title"><%- title %></h2>
<% if(obj.message) { %><p class="message" id="<%= type %>-<%= intent %>-description"><%- message %></p><% } %>
</div>
<% if(obj.actions) { %>
......@@ -24,13 +24,13 @@
<ul>
<% if(actions.primary) { %>
<li class="nav-item">
<button class="action-primary <%= actions.primary.class %>"><%= actions.primary.text %></button>
<button class="action-primary <%= actions.primary.class %>"><%- actions.primary.text %></button>
</li>
<% } %>
<% if(actions.secondary) {
_.each(actions.secondary, function(secondary) { %>
<li class="nav-item">
<button class="action-secondary <%= secondary.class %>"><%= secondary.text %></button>
<button class="action-secondary <%= secondary.class %>"><%- secondary.text %></button>
</li>
<% });
} %>
......
......@@ -289,7 +289,7 @@ define(['js/capa/drag_and_drop/draggable_events', 'js/capa/drag_and_drop/draggab
draggableObj.iconEl.appendTo(draggableObj.containerEl);
draggableObj.iconWidth = draggableObj.iconEl.width();
draggableObj.iconWidth = draggableObj.iconEl.width() + 1;
draggableObj.iconHeight = draggableObj.iconEl.height();
draggableObj.iconWidthSmall = draggableObj.iconWidth;
draggableObj.iconHeightSmall = draggableObj.iconHeight;
......
......@@ -155,13 +155,14 @@
define([
// Run the common tests that use RequireJS.
'common-requirejs/include/common/js/spec/components/feedback_spec.js',
'common-requirejs/include/common/js/spec/components/list_spec.js',
'common-requirejs/include/common/js/spec/components/paginated_view_spec.js',
'common-requirejs/include/common/js/spec/components/paging_collection_spec.js',
'common-requirejs/include/common/js/spec/components/paging_header_spec.js',
'common-requirejs/include/common/js/spec/components/paging_footer_spec.js',
'common-requirejs/include/common/js/spec/components/view_utils_spec.js',
'common-requirejs/include/common/js/spec/components/feedback_spec.js'
'common-requirejs/include/common/js/spec/components/search_field_spec.js',
'common-requirejs/include/common/js/spec/components/view_utils_spec.js'
]);
}).call(this, requirejs, define);
......@@ -17,7 +17,8 @@ class AutoAuthPage(PageObject):
CONTENT_REGEX = r'.+? user (?P<username>\S+) \((?P<email>.+?)\) with password \S+ and user_id (?P<user_id>\d+)$'
def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None):
def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None,
enrollment_mode=None, roles=None):
"""
Auto-auth is an end-point for HTTP GET requests.
By default, it will create accounts with random user credentials,
......@@ -52,6 +53,8 @@ class AutoAuthPage(PageObject):
if course_id is not None:
self._params['course_id'] = course_id
if enrollment_mode:
self._params['enrollment_mode'] = enrollment_mode
if roles is not None:
self._params['roles'] = roles
......
......@@ -127,7 +127,7 @@ class CombinedLoginAndRegisterPage(PageObject):
@property
def url(self):
"""Return the URL for the combined login/registration page. """
url = "{base}/account/{login_or_register}".format(
url = "{base}/{login_or_register}".format(
base=BASE_URL,
login_or_register=self._start_page
)
......
......@@ -20,6 +20,25 @@ TEAMS_HEADER_CSS = '.teams-header'
CREATE_TEAM_LINK_CSS = '.create-team'
class TeamCardsMixin(object):
"""Provides common operations on the team card component."""
@property
def team_cards(self):
"""Get all the team cards on the page."""
return self.q(css='.team-card')
@property
def team_names(self):
"""Return the names of each team on the page."""
return self.q(css='h3.card-title').map(lambda e: e.text).results
@property
def team_descriptions(self):
"""Return the names of each team on the page."""
return self.q(css='p.card-description').map(lambda e: e.text).results
class TeamsPage(CoursePage):
"""
Teams page/tab.
......@@ -84,7 +103,7 @@ class TeamsPage(CoursePage):
self.q(css='a.nav-item').filter(text=topic)[0].click()
class MyTeamsPage(CoursePage, PaginatedUIMixin):
class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
"""
The 'My Teams' tab of the Teams page.
"""
......@@ -98,11 +117,6 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin):
return False
return 'is-active' in button_classes[0]
@property
def team_cards(self):
"""Get all the team cards on the page."""
return self.q(css='.team-card')
class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
"""
......@@ -128,6 +142,11 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
"""Return a list of the topic names present on the page."""
return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results
@property
def topic_descriptions(self):
"""Return a list of the topic descriptions present on the page."""
return self.q(css='p.card-description').map(lambda e: e.text).results
def browse_teams_for_topic(self, topic_name):
"""
Show the teams list for `topic_name`.
......@@ -145,43 +164,43 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
self.wait_for_ajax()
class BrowseTeamsPage(CoursePage, PaginatedUIMixin):
class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
"""
The paginated UI for browsing teams within a Topic on the Teams
page.
"""
def __init__(self, browser, course_id, topic):
"""
Set up `self.url_path` on instantiation, since it dynamically
reflects the current topic. Note that `topic` is a dict
representation of a topic following the same convention as a
course module's topic.
Note that `topic` is a dict representation of a topic following
the same convention as a course module's topic.
"""
super(BrowseTeamsPage, self).__init__(browser, course_id)
super(BaseTeamsPage, self).__init__(browser, course_id)
self.topic = topic
self.url_path = "teams/#topics/{topic_id}".format(topic_id=self.topic['id'])
def is_browser_on_page(self):
"""Check if we're on the teams list page for a particular topic."""
self.wait_for_element_presence('.team-actions', 'Wait for the bottom links to be present')
"""Check if we're on a teams list page for a particular topic."""
has_correct_url = self.url.endswith(self.url_path)
teams_list_view_present = self.q(css='.teams-main').present
return has_correct_url and teams_list_view_present
@property
def header_topic_name(self):
def header_name(self):
"""Get the topic name displayed by the page header"""
return self.q(css=TEAMS_HEADER_CSS + ' .page-title')[0].text
@property
def header_topic_description(self):
def header_description(self):
"""Get the topic description displayed by the page header"""
return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text
@property
def team_cards(self):
"""Get all the team cards on the page."""
return self.q(css='.team-card')
def sort_order(self):
"""Return the current sort order on the page."""
return self.q(
css='#paging-header-select option'
).filter(
lambda e: e.is_selected()
).results[0].text.strip()
def click_create_team_link(self):
""" Click on create team link."""
......@@ -204,6 +223,55 @@ class BrowseTeamsPage(CoursePage, PaginatedUIMixin):
query.first.click()
self.wait_for_ajax()
def sort_teams_by(self, sort_order):
"""Sort the list of teams by the given `sort_order`."""
self.q(
css='#paging-header-select option[value={sort_order}]'.format(sort_order=sort_order)
).click()
self.wait_for_ajax()
@property
def _showing_search_results(self):
"""
Returns true if showing search results.
"""
return self.header_description.startswith(u"Showing results for")
def search(self, string):
"""
Searches for the specified string, and returns a SearchTeamsPage
representing the search results page.
"""
self.q(css='.search-field').first.fill(string)
self.q(css='.action-search').first.click()
self.wait_for(
lambda: self._showing_search_results,
description="Showing search results"
)
page = SearchTeamsPage(self.browser, self.course_id, self.topic)
page.wait_for_page()
return page
class BrowseTeamsPage(BaseTeamsPage):
"""
The paginated UI for browsing teams within a Topic on the Teams
page.
"""
def __init__(self, browser, course_id, topic):
super(BrowseTeamsPage, self).__init__(browser, course_id, topic)
self.url_path = "teams/#topics/{topic_id}".format(topic_id=self.topic['id'])
class SearchTeamsPage(BaseTeamsPage):
"""
The paginated UI for showing team search results.
page.
"""
def __init__(self, browser, course_id, topic):
super(SearchTeamsPage, self).__init__(browser, course_id, topic)
self.url_path = "teams/#topics/{topic_id}/search".format(topic_id=self.topic['id'])
class CreateOrEditTeamPage(CoursePage, FieldsMixin):
"""
......
......@@ -305,7 +305,7 @@ class ContainerPage(PageObject):
Returns:
list
"""
css = '#tab{tab_index} a[data-category={category_type}] span'.format(
css = '#tab{tab_index} button[data-category={category_type}] span'.format(
tab_index=tab_index,
category_type=category_type
)
......
......@@ -27,6 +27,12 @@ class CertificatesPage(CoursePage):
# Helpers
################
def refresh(self):
"""
Refresh the certificate page
"""
self.browser.refresh()
def is_browser_on_page(self):
"""
Verify that the browser is on the page and it is not still loading.
......@@ -434,11 +440,8 @@ class Signatory(object):
"""
Save signatory.
"""
# Move focus from input to save button and then click it
self.certificate.page.browser.execute_script(
"$('{} .signatory-panel-save').focus()".format(self.get_selector())
)
self.find_css('.signatory-panel-save').first.click()
# Click on the save button.
self.certificate.page.q(css='button.signatory-panel-save').click()
self.mode = 'details'
self.certificate.page.wait_for_ajax()
self.wait_for_signatory_detail_view()
......@@ -447,7 +450,7 @@ class Signatory(object):
"""
Cancel signatory editing.
"""
self.find_css('.signatory-panel-close').first.click()
self.certificate.page.q(css='button.signatory-panel-close').click()
self.mode = 'details'
self.wait_for_signatory_detail_view()
......
......@@ -33,9 +33,9 @@ class UsersPageMixin(PageObject):
def is_browser_on_page(self):
"""
Returns True iff the browser has loaded the page.
Returns True if the browser has loaded the page.
"""
return self.q(css='body.view-team').present
return self.q(css='body.view-team').present and not self.q(css='.ui-loading').present
@property
def users(self):
......
......@@ -72,7 +72,7 @@ def add_discussion(page, menu_index=0):
placement within the page).
"""
page.wait_for_component_menu()
click_css(page, 'a>span.large-discussion-icon', menu_index)
click_css(page, 'button>span.large-discussion-icon', menu_index)
def add_advanced_component(page, menu_index, name):
......@@ -84,7 +84,7 @@ def add_advanced_component(page, menu_index, name):
"""
# Click on the Advanced icon.
page.wait_for_component_menu()
click_css(page, 'a>span.large-advanced-icon', menu_index, require_notification=False)
click_css(page, 'button>span.large-advanced-icon', menu_index, require_notification=False)
# This does an animation to hide the first level of buttons
# and instead show the Advanced buttons that are available.
......@@ -95,7 +95,7 @@ def add_advanced_component(page, menu_index, name):
page.wait_for_element_visibility('.new-component-advanced', 'Advanced component menu is visible')
# Now click on the component to add it.
component_css = 'a[data-category={}]'.format(name)
component_css = 'button[data-category={}]'.format(name)
page.wait_for_element_visibility(component_css, 'Advanced component {} is visible'.format(name))
# Adding some components, e.g. the Discussion component, will make an ajax call
......@@ -123,7 +123,7 @@ def add_component(page, item_type, specific_type):
'Wait for the add component menu to disappear'
)
all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type))
all_options = page.q(css='.new-component-{} ul.new-component-template li button span'.format(item_type))
chosen_option = all_options.filter(lambda el: el.text == specific_type).first
chosen_option.click()
wait_for_notification(page)
......@@ -139,13 +139,13 @@ def add_html_component(page, menu_index, boilerplate=None):
"""
# Click on the HTML icon.
page.wait_for_component_menu()
click_css(page, 'a>span.large-html-icon', menu_index, require_notification=False)
click_css(page, 'button>span.large-html-icon', menu_index, require_notification=False)
# Make sure that the menu of HTML components is visible before clicking
page.wait_for_element_visibility('.new-component-html', 'HTML component menu is visible')
# Now click on the component to add it.
component_css = 'a[data-category=html]'
component_css = 'button[data-category=html]'
if boilerplate:
component_css += '[data-boilerplate={}]'.format(boilerplate)
else:
......
......@@ -30,7 +30,7 @@ CLASS_SELECTORS = {
}
BUTTON_SELECTORS = {
'create_video': 'a[data-category="video"]',
'create_video': 'button[data-category="video"]',
'handout_download': '.video-handout.video-download-button a',
'handout_download_editor': '.wrapper-comp-setting.file-uploader .download-action',
'upload_asset': '.upload-action',
......
......@@ -109,7 +109,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
self.login_page.visit().toggle_form()
self.assertEqual(self.login_page.current_form, "register")
@flaky # TODO fix this, see ECOM-1165
@flaky # ECOM-1165
def test_password_reset_success(self):
# Create a user account
email, password = self._create_unique_user() # pylint: disable=unused-variable
......
......@@ -168,32 +168,12 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# Auto-auth register for the course.
self._auto_auth(self.USERNAME, self.EMAIL, False)
def _auto_auth(self, username, email, staff):
def _auto_auth(self, username, email, staff, enrollment_mode="honor"):
"""
Logout and login with given credentials.
"""
AutoAuthPage(self.browser, username=username, email=email,
course_id=self.course_id, staff=staff).visit()
def _login_as_a_verified_user(self):
"""
login as a verififed user
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
# the track selection page cannot be visited. see the other tests to see if any prereq is there.
# Navigate to the track selection page
self.track_selection_page.visit()
# Enter the payment and verification flow by choosing to enroll as verified
self.track_selection_page.enroll('verified')
# Proceed to the fake payment page
self.payment_and_verification_flow.proceed_to_payment()
# Submit payment
self.fake_payment_page.submit_payment()
course_id=self.course_id, staff=staff, enrollment_mode=enrollment_mode).visit()
def _create_a_proctored_exam_and_attempt(self):
"""
......@@ -212,7 +192,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# login as a verified student and visit the courseware.
LogoutPage(self.browser).visit()
self._login_as_a_verified_user()
self._auto_auth(self.USERNAME, self.EMAIL, False, enrollment_mode="verified")
self.courseware_page.visit()
# Start the proctored exam.
......@@ -235,7 +215,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# login as a verified student and visit the courseware.
LogoutPage(self.browser).visit()
self._login_as_a_verified_user()
self._auto_auth(self.USERNAME, self.EMAIL, False, enrollment_mode="verified")
self.courseware_page.visit()
# Start the proctored exam.
......
......@@ -5,6 +5,7 @@ Bok choy acceptance tests for problems in the LMS
See also old lettuce tests in lms/djangoapps/courseware/features/problems.feature
"""
from textwrap import dedent
from flaky import flaky
from ..helpers import UniqueCourseTest
from ...pages.studio.auto_auth import AutoAuthPage
......@@ -191,6 +192,7 @@ class ProblemHintWithHtmlTest(ProblemsTest, EventsTestMixin):
""")
return XBlockFixtureDesc('problem', 'PROBLEM HTML HINT TEST', data=xml)
@flaky # TODO fix this, see TNL-3183
def test_check_hint(self):
"""
Test clicking Check shows the extended hint in the problem message.
......
......@@ -2,6 +2,8 @@
Acceptance tests for Studio related to the asset index page.
"""
from flaky import flaky
from ...pages.studio.asset_index import AssetIndexPage
from .base_studio_test import StudioCourseTest
......@@ -35,6 +37,7 @@ class AssetIndexTest(StudioCourseTest):
"""
self.asset_page.visit()
@flaky # TODO fix this, see SOL-1160
def test_type_filter_exists(self):
"""
Make sure type filter is on the page.
......
......@@ -522,9 +522,7 @@ class LibraryUsersPageTest(StudioLibraryTest):
"""
self.page = LibraryUsersPage(self.browser, self.library_key)
self.page.visit()
self.page.wait_until_no_loading_indicator()
@flaky # TODO fix this; see TNL-2647
def test_user_management(self):
"""
Scenario: Ensure that we can edit the permissions of users.
......
......@@ -170,6 +170,8 @@ class CertificatesTest(StudioCourseTest):
self.assertEqual(len(self.certificates_page.certificates), 1)
#Refreshing the page, So page have the updated certificate object.
self.certificates_page.refresh()
signatory = self.certificates_page.certificates[0].signatories[0]
self.assertIn("Updated signatory name", signatory.name)
self.assertIn("Update signatory title", signatory.title)
......
......@@ -3,6 +3,7 @@
import datetime
import json
import ddt
import unittest
from ..helpers import EventsTestMixin
from .test_video_module import VideoBaseTest
......@@ -60,6 +61,7 @@ class VideoEventsTestMixin(EventsTestMixin, VideoBaseTest):
class VideoEventsTest(VideoEventsTestMixin):
""" Test video player event emission """
@unittest.skip('AN-5867')
def test_video_control_events(self):
"""
Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources
......
......@@ -61,7 +61,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-21 14:18+0000\n"
"POT-Creation-Date: 2015-09-04 14:07+0000\n"
"PO-Revision-Date: 2015-05-28 20:00+0000\n"
"Last-Translator: Nadav Stark <nadav@yeda.org.il>\n"
"Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n"
......@@ -1216,10 +1216,6 @@ msgid "incorrect"
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "partially correct"
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "incomplete"
msgstr ""
......@@ -1242,10 +1238,6 @@ msgid "This is incorrect."
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "This is partially correct."
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "This is unanswered."
msgstr ""
......@@ -4550,7 +4542,14 @@ msgid "{month} {day}, {year}"
msgstr ""
#: lms/djangoapps/certificates/views/webview.py
msgid "a course of study offered by {partner_name}, through {platform_name}."
msgid ""
"a course of study offered by {partner_short_name}, an online learning "
"initiative of {partner_long_name} through {platform_name}."
msgstr ""
#: lms/djangoapps/certificates/views/webview.py
msgid ""
"a course of study offered by {partner_short_name}, through {platform_name}."
msgstr ""
#. Translators: Accomplishments describe the awards/certifications obtained by
......@@ -4650,13 +4649,13 @@ msgstr ""
#: lms/djangoapps/certificates/views/webview.py
msgid ""
"This is a valid {platform_name} certificate for {user_name}, who "
"participated in {partner_name} {course_number}"
"participated in {partner_short_name} {course_number}"
msgstr ""
#. Translators: This text is bound to the HTML 'title' element of the page
#. and appears in the browser title bar
#: lms/djangoapps/certificates/views/webview.py
msgid "{partner_name} {course_number} Certificate | {platform_name}"
msgid "{partner_short_name} {course_number} Certificate | {platform_name}"
msgstr ""
#. Translators: This text fragment appears after the student's name
......@@ -4813,6 +4812,14 @@ msgid ""
"{payment_support_link}."
msgstr ""
#: lms/djangoapps/commerce/api/v1/serializers.py
msgid "{course_id} is not a valid course key."
msgstr ""
#: lms/djangoapps/commerce/api/v1/serializers.py
msgid "Course {course_id} does not exist."
msgstr ""
#: lms/djangoapps/course_wiki/tab.py lms/djangoapps/course_wiki/views.py
#: lms/templates/wiki/base.html
msgid "Wiki"
......@@ -5334,6 +5341,23 @@ msgid "File is not attached."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Could not find problem with this location."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"The problem responses report is being created. To view the status of the "
"report, see Pending Tasks below."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"A problem responses report generation task is already in progress. Check the"
" 'Pending Tasks' table for the status of the task. When completed, the "
"report will be available for download in the table below."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Invoice number '{num}' does not exist."
msgstr ""
......@@ -5720,6 +5744,10 @@ msgid "CourseMode price updated successfully"
msgstr ""
#: lms/djangoapps/instructor/views/instructor_dashboard.py
msgid "No end date set"
msgstr ""
#: lms/djangoapps/instructor/views/instructor_dashboard.py
msgid "Enrollment data is now available in {dashboard_link}."
msgstr ""
......@@ -5817,18 +5845,6 @@ msgid "Grades for assignment \"{name}\""
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Found {num} records to dump."
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Couldn't find module with that urlname."
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Student state for problem {problem}"
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Grades from {course_id}"
msgstr ""
......@@ -5994,6 +6010,12 @@ msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "generated"
msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "graded"
msgstr ""
......@@ -6006,12 +6028,6 @@ msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "generated"
msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "generating_enrollment_report"
msgstr ""
......@@ -7392,6 +7408,7 @@ msgid "Optional language the team uses as ISO 639-1 code."
msgstr ""
#: lms/djangoapps/teams/plugins.py
#: lms/djangoapps/teams/templates/teams/teams.html
msgid "Teams"
msgstr ""
......@@ -7404,11 +7421,11 @@ msgid "course_id must be provided"
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "The supplied topic id {topic_id} is not valid"
msgid "text_search and order_by cannot be provided together"
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "text_search is not yet supported."
msgid "The supplied topic id {topic_id} is not valid"
msgstr ""
#. Translators: 'ordering' is a string describing a way
......@@ -9112,6 +9129,10 @@ msgstr ""
msgid "Sign Out"
msgstr ""
#: common/lib/capa/capa/templates/codeinput.html
msgid "{programming_language} editor"
msgstr ""
#: common/templates/license.html
msgid "All Rights Reserved"
msgstr ""
......@@ -11605,7 +11626,9 @@ msgid "Section:"
msgstr ""
#: lms/templates/courseware/legacy_instructor_dashboard.html
msgid "Problem urlname:"
msgid ""
"To download a CSV listing student responses to a given problem, visit the "
"Data Download section of the Instructor Dashboard."
msgstr ""
#: lms/templates/courseware/legacy_instructor_dashboard.html
......@@ -13379,6 +13402,20 @@ msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid ""
"To generate a CSV file that lists all student answers to a given problem, "
"enter the location of the problem (from its Staff Debug Info)."
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid "Problem location: "
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid "Download a CSV of problem responses"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid ""
"For smaller courses, click to list profile information for enrolled students"
" directly on this page:"
msgstr ""
......@@ -15674,41 +15711,50 @@ msgid "This module is not enabled."
msgstr ""
#: cms/templates/certificates.html
msgid "Working with Certificates"
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Upon successful completion of your course, learners receive a certificate to"
" acknowledge their accomplishment. If you are a course team member with the "
"Admin role in Studio, you can configure your course certificate."
"Specify a course title to use on the certificate if the course's official "
"title is too long to be displayed well."
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Click {em_start}Add your first certificate{em_end} to add a certificate "
"configuration. Upload the organization logo to be used on the certificate, "
"and specify at least one signatory. You can include up to four signatories "
"for a certificate. You can also upload a signature image file for each "
"signatory. {em_start}Note:{em_end} Signature images are used only for "
"verified certificates. Optionally, specify a different course title to use "
"on your course certificate. You might want to use a different title if, for "
"example, the official course name is too long to display well on a "
"certificate."
"For verified certificates, specify between one and four signatories and "
"upload the associated images."
msgstr ""
#: cms/templates/certificates.html
msgid ""
"To edit or delete a certificate before it is activated, hover over the top "
"right corner of the form and select {em_start}Edit{em_end} or the delete "
"icon."
msgstr ""
#: cms/templates/certificates.html
msgid ""
"To view a sample certificate, choose a course mode and select "
"{em_start}Preview Certificate{em_end}."
msgstr ""
#: cms/templates/certificates.html
msgid "Issuing Certificates to Learners"
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Select a course mode and click {em_start}Preview Certificate{em_end} to "
"preview the certificate that a learner in the selected enrollment track "
"would receive. When the certificate is ready for issuing, click "
"{em_start}Activate.{em_end} To stop issuing an active certificate, click "
"{em_start}Deactivate{em_end}."
"To begin issuing certificates, a course team member with the Admin role "
"selects {em_start}Activate{em_end}. Course team members without the Admin "
"role cannot edit or delete an activated certificate."
msgstr ""
#: cms/templates/certificates.html
msgid ""
" To edit the certificate configuration, hover over the top right corner of "
"the form and click {em_start}Edit{em_end}. To delete a certificate, hover "
"over the top right corner of the form and click the delete icon. In general,"
" do not delete certificates after a course has started, because some "
"certificates might already have been issued to learners."
"{em_start}Do not{em_end} delete certificates after a course has started; "
"learners who have already earned certificates will no longer be able to "
"access them."
msgstr ""
#: cms/templates/certificates.html
......
......@@ -72,7 +72,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-21 14:18+0000\n"
"POT-Creation-Date: 2015-09-04 14:07+0000\n"
"PO-Revision-Date: 2015-06-28 20:21+0000\n"
"Last-Translator: ria1234 <contactpayal@yahoo.com.au>\n"
"Language-Team: Hindi (http://www.transifex.com/open-edx/edx-platform/language/hi/)\n"
......@@ -1228,10 +1228,6 @@ msgid "incorrect"
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "partially correct"
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "incomplete"
msgstr ""
......@@ -1254,10 +1250,6 @@ msgid "This is incorrect."
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "This is partially correct."
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "This is unanswered."
msgstr ""
......@@ -4587,7 +4579,14 @@ msgid "{month} {day}, {year}"
msgstr ""
#: lms/djangoapps/certificates/views/webview.py
msgid "a course of study offered by {partner_name}, through {platform_name}."
msgid ""
"a course of study offered by {partner_short_name}, an online learning "
"initiative of {partner_long_name} through {platform_name}."
msgstr ""
#: lms/djangoapps/certificates/views/webview.py
msgid ""
"a course of study offered by {partner_short_name}, through {platform_name}."
msgstr ""
#. Translators: Accomplishments describe the awards/certifications obtained by
......@@ -4687,13 +4686,13 @@ msgstr ""
#: lms/djangoapps/certificates/views/webview.py
msgid ""
"This is a valid {platform_name} certificate for {user_name}, who "
"participated in {partner_name} {course_number}"
"participated in {partner_short_name} {course_number}"
msgstr ""
#. Translators: This text is bound to the HTML 'title' element of the page
#. and appears in the browser title bar
#: lms/djangoapps/certificates/views/webview.py
msgid "{partner_name} {course_number} Certificate | {platform_name}"
msgid "{partner_short_name} {course_number} Certificate | {platform_name}"
msgstr ""
#. Translators: This text fragment appears after the student's name
......@@ -4849,6 +4848,14 @@ msgid ""
"{payment_support_link}."
msgstr ""
#: lms/djangoapps/commerce/api/v1/serializers.py
msgid "{course_id} is not a valid course key."
msgstr ""
#: lms/djangoapps/commerce/api/v1/serializers.py
msgid "Course {course_id} does not exist."
msgstr ""
#: lms/djangoapps/course_wiki/tab.py lms/djangoapps/course_wiki/views.py
#: lms/templates/wiki/base.html
msgid "Wiki"
......@@ -5385,6 +5392,23 @@ msgid "File is not attached."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Could not find problem with this location."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"The problem responses report is being created. To view the status of the "
"report, see Pending Tasks below."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"A problem responses report generation task is already in progress. Check the"
" 'Pending Tasks' table for the status of the task. When completed, the "
"report will be available for download in the table below."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Invoice number '{num}' does not exist."
msgstr ""
......@@ -5771,6 +5795,10 @@ msgid "CourseMode price updated successfully"
msgstr ""
#: lms/djangoapps/instructor/views/instructor_dashboard.py
msgid "No end date set"
msgstr ""
#: lms/djangoapps/instructor/views/instructor_dashboard.py
msgid "Enrollment data is now available in {dashboard_link}."
msgstr ""
......@@ -5866,18 +5894,6 @@ msgid "Grades for assignment \"{name}\""
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Found {num} records to dump."
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Couldn't find module with that urlname."
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Student state for problem {problem}"
msgstr ""
#: lms/djangoapps/instructor/views/legacy.py
msgid "Grades from {course_id}"
msgstr ""
......@@ -6043,6 +6059,12 @@ msgstr "ईमेल कर दी गई"
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "generated"
msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "graded"
msgstr "श्रेणी दी जा चुकी है"
......@@ -6055,12 +6077,6 @@ msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "generated"
msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py
msgid "generating_enrollment_report"
msgstr ""
......@@ -7517,6 +7533,7 @@ msgid "Optional language the team uses as ISO 639-1 code."
msgstr ""
#: lms/djangoapps/teams/plugins.py
#: lms/djangoapps/teams/templates/teams/teams.html
msgid "Teams"
msgstr ""
......@@ -7529,11 +7546,11 @@ msgid "course_id must be provided"
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "The supplied topic id {topic_id} is not valid"
msgid "text_search and order_by cannot be provided together"
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "text_search is not yet supported."
msgid "The supplied topic id {topic_id} is not valid"
msgstr ""
#. Translators: 'ordering' is a string describing a way
......@@ -9269,6 +9286,10 @@ msgstr "सहायता"
msgid "Sign Out"
msgstr ""
#: common/lib/capa/capa/templates/codeinput.html
msgid "{programming_language} editor"
msgstr ""
#: common/templates/license.html
msgid "All Rights Reserved"
msgstr ""
......@@ -11836,8 +11857,10 @@ msgid "Section:"
msgstr "धारा:"
#: lms/templates/courseware/legacy_instructor_dashboard.html
msgid "Problem urlname:"
msgstr "समस्या का यू आर एल दें:"
msgid ""
"To download a CSV listing student responses to a given problem, visit the "
"Data Download section of the Instructor Dashboard."
msgstr ""
#: lms/templates/courseware/legacy_instructor_dashboard.html
msgid ""
......@@ -13652,6 +13675,20 @@ msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid ""
"To generate a CSV file that lists all student answers to a given problem, "
"enter the location of the problem (from its Staff Debug Info)."
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid "Problem location: "
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid "Download a CSV of problem responses"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid ""
"For smaller courses, click to list profile information for enrolled students"
" directly on this page:"
msgstr ""
......@@ -16013,41 +16050,50 @@ msgid "This module is not enabled."
msgstr ""
#: cms/templates/certificates.html
msgid "Working with Certificates"
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Specify a course title to use on the certificate if the course's official "
"title is too long to be displayed well."
msgstr ""
#: cms/templates/certificates.html
msgid ""
"For verified certificates, specify between one and four signatories and "
"upload the associated images."
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Upon successful completion of your course, learners receive a certificate to"
" acknowledge their accomplishment. If you are a course team member with the "
"Admin role in Studio, you can configure your course certificate."
"To edit or delete a certificate before it is activated, hover over the top "
"right corner of the form and select {em_start}Edit{em_end} or the delete "
"icon."
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Click {em_start}Add your first certificate{em_end} to add a certificate "
"configuration. Upload the organization logo to be used on the certificate, "
"and specify at least one signatory. You can include up to four signatories "
"for a certificate. You can also upload a signature image file for each "
"signatory. {em_start}Note:{em_end} Signature images are used only for "
"verified certificates. Optionally, specify a different course title to use "
"on your course certificate. You might want to use a different title if, for "
"example, the official course name is too long to display well on a "
"certificate."
"To view a sample certificate, choose a course mode and select "
"{em_start}Preview Certificate{em_end}."
msgstr ""
#: cms/templates/certificates.html
msgid "Issuing Certificates to Learners"
msgstr ""
#: cms/templates/certificates.html
msgid ""
"Select a course mode and click {em_start}Preview Certificate{em_end} to "
"preview the certificate that a learner in the selected enrollment track "
"would receive. When the certificate is ready for issuing, click "
"{em_start}Activate.{em_end} To stop issuing an active certificate, click "
"{em_start}Deactivate{em_end}."
"To begin issuing certificates, a course team member with the Admin role "
"selects {em_start}Activate{em_end}. Course team members without the Admin "
"role cannot edit or delete an activated certificate."
msgstr ""
#: cms/templates/certificates.html
msgid ""
" To edit the certificate configuration, hover over the top right corner of "
"the form and click {em_start}Edit{em_end}. To delete a certificate, hover "
"over the top right corner of the form and click the delete icon. In general,"
" do not delete certificates after a course has started, because some "
"certificates might already have been issued to learners."
"{em_start}Do not{em_end} delete certificates after a course has started; "
"learners who have already earned certificates will no longer be able to "
"access them."
msgstr ""
#: cms/templates/certificates.html
......
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