Commit 020e1649 by Clinton Blackburn

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

Release 2015-09-09
parents 7b492f1d 6c09ceae
...@@ -234,3 +234,5 @@ Awais Qureshi <awais.qureshi@arbisoft.com> ...@@ -234,3 +234,5 @@ Awais Qureshi <awais.qureshi@arbisoft.com>
Eric Fischer <efischer@edx.org> Eric Fischer <efischer@edx.org>
Brian Beggs <macdiesel@gmail.com> Brian Beggs <macdiesel@gmail.com>
Bill DeRusha <bill@edx.org> Bill DeRusha <bill@edx.org>
Kevin Falcone <kevin@edx.org>
Mirjam Škarica <mirjamskarica@gmail.com>
...@@ -67,20 +67,20 @@ def _click_advanced(): ...@@ -67,20 +67,20 @@ def _click_advanced():
world.wait_for_visible(tab2_css) 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 # The tab shows buttons for the given category
links = world.css_find('div.new-component-{} a'.format(category)) buttons = world.css_find('div.new-component-{} button'.format(category))
# Find the link whose text matches what you're looking for # Find the button whose text matches what you're looking for
matched_links = [link for link in links if link.text == component_type] matched_buttons = [btn for btn in buttons if btn.text == component_type]
# There should be one and only one # There should be one and only one
assert_equal(len(matched_links), 1) assert_equal(len(matched_buttons), 1)
return matched_links[0] return matched_buttons[0]
def click_component_from_menu(category, component_type, is_advanced): def click_component_from_menu(category, component_type, is_advanced):
...@@ -100,7 +100,7 @@ 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. # Retry this in case the list is empty because you tried too fast.
link = world.retry_on_exception( link = world.retry_on_exception(
lambda: _find_matching_link(category, component_type), lambda: _find_matching_button(category, component_type),
ignored_exceptions=AssertionError ignored_exceptions=AssertionError
) )
......
...@@ -18,18 +18,6 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -18,18 +18,6 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore 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): class Command(BaseCommand):
""" """
Delete a MongoDB backed course Delete a MongoDB backed course
...@@ -58,8 +46,6 @@ class Command(BaseCommand): ...@@ -58,8 +46,6 @@ class Command(BaseCommand):
elif len(args) > 2: elif len(args) > 2:
raise CommandError("Too many arguments! Expected <course_key> <commit>") raise CommandError("Too many arguments! Expected <course_key> <commit>")
print_out_all_courses()
if not modulestore().get_course(course_key): if not modulestore().get_course(course_key):
raise CommandError("Course with '%s' key not found." % args[0]) raise CommandError("Course with '%s' key not found." % args[0])
...@@ -67,4 +53,4 @@ class Command(BaseCommand): ...@@ -67,4 +53,4 @@ class Command(BaseCommand):
if query_yes_no("Deleting course {0}. Confirm?".format(course_key), default="no"): 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"): if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
delete_course_and_groups(course_key, ModuleStoreEnum.UserID.mgmt_command) 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 ...@@ -5,6 +5,7 @@ Unittests for deleting a course in an chosen modulestore
import unittest import unittest
import mock import mock
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.core.management import CommandError from django.core.management import CommandError
from contentstore.management.commands.delete_course import Command # pylint: disable=import-error from contentstore.management.commands.delete_course import Command # pylint: disable=import-error
from contentstore.tests.utils import CourseTestCase # pylint: disable=import-error from contentstore.tests.utils import CourseTestCase # pylint: disable=import-error
...@@ -94,27 +95,23 @@ class DeleteCourseTest(CourseTestCase): ...@@ -94,27 +95,23 @@ class DeleteCourseTest(CourseTestCase):
run=course_run 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): def test_course_key_not_found(self):
""" """
Test for when a non-existing course key is entered Test for when a non-existing course key is entered
""" """
errstring = "Course with 'TestX/TS01/2015_Q7' key not found." errstring = "Course with 'TestX/TS01/2015_Q7' key not found."
with self.assertRaisesRegexp(CommandError, errstring): 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): def test_course_deleted(self):
""" """
Testing if the entered course was deleted 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: with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
patched_yes_no.return_value = True patched_yes_no.return_value = True
self.command.handle("TestX/TS01/2015_Q1", "commit") self.command.handle('TestX/TS01/2015_Q1', "commit")
courses = [unicode(key) for key in modulestore().get_courses_keys()] self.assertIsNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
self.assertNotIn("TestX/TS01/2015_Q1", courses)
""" 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 = { ...@@ -170,6 +170,9 @@ FEATURES = {
# Teams feature # Teams feature
'ENABLE_TEAMS': True, 'ENABLE_TEAMS': True,
# Teams search feature
'ENABLE_TEAMS_SEARCH': False,
# Show video bumper in Studio # Show video bumper in Studio
'ENABLE_VIDEO_BUMPER': False, 'ENABLE_VIDEO_BUMPER': False,
......
...@@ -281,5 +281,8 @@ SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" ...@@ -281,5 +281,8 @@ SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
# teams feature # teams feature
FEATURES['ENABLE_TEAMS'] = True FEATURES['ENABLE_TEAMS'] = True
# teams search
FEATURES['ENABLE_TEAMS_SEARCH'] = True
# Dummy secret key for dev/test # Dummy secret key for dev/test
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
...@@ -79,7 +79,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails ...@@ -79,7 +79,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
}; };
beforeEach(function() { 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.newModelOptions = {add: true};
this.model = new CertificateModel({ this.model = new CertificateModel({
......
...@@ -40,6 +40,7 @@ function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, Sign ...@@ -40,6 +40,7 @@ function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, Sign
eventAgg: this.eventAgg eventAgg: this.eventAgg
}); });
this.template = this.loadTemplate('signatory-details'); this.template = this.loadTemplate('signatory-details');
this.signatory_action_template = this.loadTemplate('signatory-actions');
}, },
loadTemplate: function(name) { loadTemplate: function(name) {
...@@ -51,6 +52,7 @@ function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, Sign ...@@ -51,6 +52,7 @@ function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, Sign
// Retrieve the edit view for this model // Retrieve the edit view for this model
if (event && event.preventDefault) { event.preventDefault(); } if (event && event.preventDefault) { event.preventDefault(); }
this.$el.html(this.edit_view.render()); this.$el.html(this.edit_view.render());
$(this.signatory_action_template()).appendTo(this.el);
this.edit_view.delegateEvents(); this.edit_view.delegateEvents();
this.delegateEvents(); this.delegateEvents();
}, },
......
...@@ -552,7 +552,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja ...@@ -552,7 +552,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
var clickNewComponent; var clickNewComponent;
clickNewComponent = function (index) { 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() { it('Attaches a handler to new component button', function() {
...@@ -598,7 +598,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja ...@@ -598,7 +598,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
var showTemplatePicker, verifyCreateHtmlComponent; var showTemplatePicker, verifyCreateHtmlComponent;
showTemplatePicker = function () { 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) { verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) {
...@@ -606,7 +606,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja ...@@ -606,7 +606,7 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
renderContainerPage(test, mockContainerXBlockHtml); renderContainerPage(test, mockContainerXBlockHtml);
showTemplatePicker(); showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length; xblockCount = containerPage.$('.studio-xblock-wrapper').length;
containerPage.$('.new-component-html a')[templateIndex].click(); containerPage.$('.new-component-html button')[templateIndex].click();
EditHelpers.verifyXBlockRequest(requests, expectedRequest); EditHelpers.verifyXBlockRequest(requests, expectedRequest);
AjaxHelpers.respondWithJson(requests, {"locator": "new_item"}); AjaxHelpers.respondWithJson(requests, {"locator": "new_item"});
respondWithHtml(mockXBlockHtml); respondWithHtml(mockXBlockHtml);
......
...@@ -89,24 +89,24 @@ function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) { ...@@ -89,24 +89,24 @@ function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) {
it("displays an error when the user has already been added", function () { it("displays an error when the user has already been added", function () {
var requests = AjaxHelpers.requests(this); var requests = AjaxHelpers.requests(this);
var promptSpy = ViewHelpers.createPromptSpy();
$('.create-user-button').click(); $('.create-user-button').click();
$('.user-email-input').val('honor@example.com'); $('.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(); $('.form-create.create-user .action-primary').click();
expect($(warningPromptSelector).length).toEqual(1); ViewHelpers.verifyPromptShowing(promptSpy, 'Already a library team member');
expect($(warningPromptSelector)).toContainText('Already a library team member');
expect(requests.length).toEqual(0); expect(requests.length).toEqual(0);
}); });
it("can remove a user's permission to access the library", function () { it("can remove a user's permission to access the library", function () {
var requests = AjaxHelpers.requests(this); var requests = AjaxHelpers.requests(this);
var promptSpy = ViewHelpers.createPromptSpy();
var reloadSpy = spyOn(ViewUtils, 'reload'); var reloadSpy = spyOn(ViewUtils, 'reload');
var email = "honor@example.com"; var email = "honor@example.com";
$('.user-item[data-email="'+email+'"] .action-delete .delete').click(); $('.user-item[data-email="'+email+'"] .action-delete .delete').click();
expect($('.wrapper-prompt.is-shown .prompt.warning').length).toEqual(1); ViewHelpers.verifyPromptShowing(promptSpy, 'Are you sure?');
$('.wrapper-prompt.is-shown .action-primary').click(); ViewHelpers.confirmPrompt(promptSpy);
ViewHelpers.verifyPromptHidden(promptSpy);
AjaxHelpers.expectJsonRequest(requests, 'DELETE', getUrl(email), {role: null}); AjaxHelpers.expectJsonRequest(requests, 'DELETE', getUrl(email), {role: null});
AjaxHelpers.respondWithJson(requests, {'result': 'ok'}); AjaxHelpers.respondWithJson(requests, {'result': 'ok'});
expect(reloadSpy).toHaveBeenCalled(); expect(reloadSpy).toHaveBeenCalled();
......
...@@ -6,10 +6,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo ...@@ -6,10 +6,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) { function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({ var AddXBlockComponent = BaseView.extend({
events: { events: {
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates', 'click .new-component .new-component-type button.multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type a.single-template': 'createNewComponent', 'click .new-component .new-component-type button.single-template': 'createNewComponent',
'click .new-component .cancel-button': 'closeNewComponent', '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' 'click .new-component-templates .cancel-button': 'closeNewComponent'
}, },
...@@ -43,13 +43,17 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo ...@@ -43,13 +43,17 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
type = $(event.currentTarget).data('type'); type = $(event.currentTarget).data('type');
this.$('.new-component').slideUp(250); this.$('.new-component').slideUp(250);
this.$('.new-component-' + type).slideDown(250); this.$('.new-component-' + type).slideDown(250);
this.$('.new-component-' + type + ' div').focus();
}, },
closeNewComponent: function(event) { closeNewComponent: function(event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
type = $(event.currentTarget).data('type');
this.$('.new-component').slideDown(250); this.$('.new-component').slideDown(250);
this.$('.new-component-templates').slideUp(250); this.$('.new-component-templates').slideUp(250);
this.$('ul.new-component-type li button[data-type=' + type + ']').focus();
}, },
createNewComponent: function(event) { createNewComponent: function(event) {
......
...@@ -13,40 +13,35 @@ ...@@ -13,40 +13,35 @@
.large-advanced-icon { .large-advanced-icon {
display: inline-block; display: inline-block;
width: 100px; width: ($baseline*3);
height: 60px; height: ($baseline*3);
margin-right: ($baseline/4);
background: url(../images/large-advanced-icon.png) center no-repeat; background: url(../images/large-advanced-icon.png) center no-repeat;
} }
.large-discussion-icon { .large-discussion-icon {
display: inline-block; display: inline-block;
width: 100px; width: ($baseline*3);
height: 60px; height: ($baseline*3);
margin-right: ($baseline/4);
background: url(../images/large-discussion-icon.png) center no-repeat; background: url(../images/large-discussion-icon.png) center no-repeat;
} }
.large-html-icon { .large-html-icon {
display: inline-block; display: inline-block;
width: 100px; width: ($baseline*3);
height: 60px; height: ($baseline*3);
margin-right: ($baseline/4);
background: url(../images/large-html-icon.png) center no-repeat; background: url(../images/large-html-icon.png) center no-repeat;
} }
.large-problem-icon { .large-problem-icon {
display: inline-block; display: inline-block;
width: 100px; width: ($baseline*3);
height: 60px; height: ($baseline*3);
margin-right: ($baseline/4);
background: url(../images/large-problem-icon.png) center no-repeat; background: url(../images/large-problem-icon.png) center no-repeat;
} }
.large-video-icon { .large-video-icon {
display: inline-block; display: inline-block;
width: 100px; width: ($baseline*3);
height: 60px; height: ($baseline*3);
margin-right: ($baseline/4);
background: url(../images/large-video-icon.png) center no-repeat; background: url(../images/large-video-icon.png) center no-repeat;
} }
...@@ -130,9 +130,10 @@ ...@@ -130,9 +130,10 @@
width: ($baseline*5); width: ($baseline*5);
height: ($baseline*5); height: ($baseline*5);
margin-bottom: ($baseline/2); 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: 1px solid $green-d2;
border-radius: ($baseline/4); 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; background-color: $green-l1;
text-align: center; text-align: center;
color: $white; color: $white;
...@@ -195,16 +196,17 @@ ...@@ -195,16 +196,17 @@
} }
} }
a { .button-component {
@include clearfix(); @include clearfix();
@include transition(none); @include transition(none);
@extend %t-demi-strong; @extend %t-demi-strong;
display: block; display: block;
width: 100%;
border: 0px; border: 0px;
padding: 7px $baseline; padding: 7px $baseline;
background: $white; background: $white;
color: $gray-d3; color: $gray-d3;
text-align: left;
&:hover { &:hover {
@include transition(background-color $tmg-f2 linear 0s); @include transition(background-color $tmg-f2 linear 0s);
......
...@@ -380,6 +380,12 @@ ...@@ -380,6 +380,12 @@
color: $gray-l3; color: $gray-l3;
} }
} }
&.custom-signatory-action {
position: relative;
top: 0;
left: 0;
opacity: 1.0;
}
} }
.copy { .copy {
...@@ -522,6 +528,10 @@ ...@@ -522,6 +528,10 @@
.signatory-panel-body { .signatory-panel-body {
padding: $baseline; padding: $baseline;
.signatory-image {
margin-top: 20px;
}
} }
.signatory-panel-body label { .signatory-panel-body label {
......
...@@ -11,7 +11,7 @@ from django.utils.translation import ugettext as _ ...@@ -11,7 +11,7 @@ from django.utils.translation import ugettext as _
<%block name="bodyclass">is-signedin course view-certificates</%block> <%block name="bodyclass">is-signedin course view-certificates</%block>
<%block name="header_extras"> <%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"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
......
<% if (type === 'advanced' || templates.length > 1) { %> <% 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 { %> <% } 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="large-template-icon large-<%= type %>-icon"></span>
<span class="sr"> <%= gettext("Add Component:") %></span>
<span class="name"><%= display_name %></span> <span class="name"><%= display_name %></span>
</a> </button>
<div class="tab-group tabs"> <div class="tab-group tabs" tabindex="-1" role="dialog" aria-label="<%-
<ul class="problem-type-tabs nav-tabs"> interpolate(
gettext('%(type)s Component Template Menu'),
{type: type},
true
)
%>">
<ul class="problem-type-tabs nav-tabs" tabindex='-1'>
<li class="current"> <li class="current">
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a> <a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li> </li>
...@@ -13,16 +19,16 @@ ...@@ -13,16 +19,16 @@
<% if (templates[i].tab == "common") { %> <% if (templates[i].tab == "common") { %>
<% if (!templates[i].boilerplate_name) { %> <% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty"> <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> <span class="name"><%= templates[i].display_name %></span>
</a> </button>
</li> </li>
<% } else { %> <% } else { %>
<li class="editor-md"> <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 %>"> data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span> <span class="name"><%= templates[i].display_name %></span>
</a> </button>
</li> </li>
<% } %> <% } %>
<% } %> <% } %>
...@@ -34,14 +40,14 @@ ...@@ -34,14 +40,14 @@
<% for (var i = 0; i < templates.length; i++) { %> <% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].tab == "advanced") { %> <% if (templates[i].tab == "advanced") { %>
<li class="editor-manual"> <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 %>"> data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span> <span class="name"><%= templates[i].display_name %></span>
</a> </button>
</li> </li>
<% } %> <% } %>
<% } %> <% } %>
</ul> </ul>
</div> </div>
<button class="cancel-button" data-type="<%= type %>"><%= gettext("Cancel") %></button>
</div> </div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% if (type === 'advanced' || templates.length > 1) { %> <% 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"> <ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %> <% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].boilerplate_name) { %> <% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty"> <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> <span class="name"><%= templates[i].display_name %></span>
</a> </button>
</li> </li>
<% } else { %> <% } else { %>
<li class="editor-md"> <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 %>"> data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span> <span class="name"><%= templates[i].display_name %></span>
</a> </button>
</li> </li>
<% } %> <% } %>
<% } %> <% } %>
</ul> </ul>
<button class="cancel-button" data-type="<%= type %>"><%= gettext("Cancel") %></button>
</div> </div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% } %> <% } %>
...@@ -17,12 +17,12 @@ ...@@ -17,12 +17,12 @@
<span class="title"><%= gettext("Certificate Details") %></span> <span class="title"><%= gettext("Certificate Details") %></span>
</header> </header>
<div class="actual-course-title"> <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> <span class="certificate-value"><%= course.get('name') %></span>
</div> </div>
<% if (course_title) { %> <% if (course_title) { %>
<div class="course-title-override"> <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> <span class="certificate-value"><%= course_title %></span>
</div> </div>
<% } %> <% } %>
......
...@@ -19,6 +19,10 @@ ...@@ -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> <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> <span id="certificate-description-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Description of the certificate") %></span>
</div> </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"> <div class="input-wrap field text add-certificate-course-title">
<label for="certificate-course-title-<%= uniqueId %>"><%= gettext("Course Title Override") %></label> <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" /> <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 @@ ...@@ -9,16 +9,25 @@
<div class="signatory-panel-header">Signatory <%= signatory_number %>&nbsp;</div> <div class="signatory-panel-header">Signatory <%= signatory_number %>&nbsp;</div>
<div class="signatory-panel-body"> <div class="signatory-panel-body">
<div> <div>
<span class="signatory-name-label"><%= gettext("Name") %>:&nbsp;</span> <div>
<span class="signatory-name-label"><b><%= gettext("Name") %>:</b>&nbsp;</span>
<span class="signatory-name-value"><%= name %></span> <span class="signatory-name-value"><%= name %></span>
</div> </div>
<div> <div>
<span class="signatory-title-label"><%= gettext("Title") %>:&nbsp;</span> <span class="signatory-title-label"><b><%= gettext("Title") %>:</b>&nbsp;</span>
<span class="signatory-title-value"><%= title %></span> <span class="signatory-title-value"><%= title %></span>
</div> </div>
<div> <div>
<span class="signatory-organization-label"><%= gettext("Organization") %>:&nbsp;</span> <span class="signatory-organization-label"><b><%= gettext("Organization") %>:</b>&nbsp;</span>
<span class="signatory-organization-value"><%= organization %></span> <span class="signatory-organization-value"><%= organization %></span>
</div> </div>
</div> </div>
<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>
<div class="signatory-panel-default"> <div class="signatory-panel-default">
<% if (is_editing_all_collections && signatories_count > 1 && (total_saved_signatories > 1 || isNew) ) { %>
<% 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) ) { %>
<a class="signatory-panel-delete" href="#" data-tooltip="Delete"> <a class="signatory-panel-delete" href="#" data-tooltip="Delete">
<i class="icon fa fa-trash-o" aria-hidden="true"></i> <i class="icon fa fa-trash-o" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Delete") %></span> <span class="sr action-button-text"><%= gettext("Delete") %></span>
......
...@@ -189,7 +189,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None): ...@@ -189,7 +189,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None):
return { return {
provider.provider_id: third_party_auth.pipeline.get_login_url( provider.provider_id: third_party_auth.pipeline.get_login_url(
provider.provider_id, auth_entry, redirect_url=redirect_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): ...@@ -1046,9 +1046,10 @@ class CourseEnrollment(models.Model):
`course_key` is our usual course_id string (e.g. "edX/Test101/2013_Fall) `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 `mode` is a string specifying what kind of enrollment this is. The
default is "honor", meaning honor certificate. Future options default is 'honor', meaning honor certificate. Other options
may include "audit", "verified_id", etc. Please don't use it include 'professional', 'verified', 'audit',
until we have these mapped out. '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 `check_access`: if True, we check that an accessible course actually
exists for the given course_key before we enroll the student. exists for the given course_key before we enroll the student.
......
...@@ -447,6 +447,7 @@ def register_user(request, extra_context=None): ...@@ -447,6 +447,7 @@ def register_user(request, extra_context=None):
if third_party_auth.is_enabled() and pipeline.running(request): if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request) running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_from_pipeline(running_pipeline) current_provider = provider.Registry.get_from_pipeline(running_pipeline)
if current_provider is not None:
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
overrides['running_pipeline'] = running_pipeline overrides['running_pipeline'] = running_pipeline
overrides['selected_provider'] = current_provider.name overrides['selected_provider'] = current_provider.name
...@@ -1769,6 +1770,10 @@ def auto_auth(request): ...@@ -1769,6 +1770,10 @@ def auto_auth(request):
full_name = request.GET.get('full_name', username) full_name = request.GET.get('full_name', username)
is_staff = request.GET.get('staff', None) is_staff = request.GET.get('staff', None)
course_id = request.GET.get('course_id', 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 course_key = None
if course_id: if course_id:
course_key = CourseLocator.from_string(course_id) course_key = CourseLocator.from_string(course_id)
...@@ -1816,7 +1821,7 @@ def auto_auth(request): ...@@ -1816,7 +1821,7 @@ def auto_auth(request):
# Enroll the user in a course # Enroll the user in a course
if course_key is not None: if course_key is not None:
CourseEnrollment.enroll(user, course_key) CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
# Apply the roles # Apply the roles
for role_name in role_names: for role_name in role_names:
......
...@@ -6,7 +6,7 @@ Admin site configuration for third party authentication ...@@ -6,7 +6,7 @@ Admin site configuration for third party authentication
from django.contrib import admin from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin 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 from .tasks import fetch_saml_metadata
...@@ -88,3 +88,26 @@ class SAMLProviderDataAdmin(admin.ModelAdmin): ...@@ -88,3 +88,26 @@ class SAMLProviderDataAdmin(admin.ModelAdmin):
return self.readonly_fields return self.readonly_fields
admin.site.register(SAMLProviderData, SAMLProviderDataAdmin) 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 @@ ...@@ -3,6 +3,8 @@
Models used to implement SAML SSO support in third_party_auth Models used to implement SAML SSO support in third_party_auth
(inlcuding Shibboleth support) (inlcuding Shibboleth support)
""" """
from __future__ import absolute_import
from config_models.models import ConfigurationModel, cache from config_models.models import ConfigurationModel, cache
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
...@@ -11,9 +13,11 @@ from django.utils import timezone ...@@ -11,9 +13,11 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
import json import json
import logging import logging
from provider.utils import long_token
from social.backends.base import BaseAuth from social.backends.base import BaseAuth
from social.backends.oauth import OAuthAuth from social.backends.oauth import OAuthAuth
from social.backends.saml import SAMLAuth, SAMLIdentityProvider from social.backends.saml import SAMLAuth, SAMLIdentityProvider
from .lti import LTIAuthBackend, LTI_PARAMS_KEY
from social.exceptions import SocialAuthBaseException from social.exceptions import SocialAuthBaseException
from social.utils import module_member from social.utils import module_member
...@@ -32,6 +36,7 @@ def _load_backend_classes(base_class=BaseAuth): ...@@ -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_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_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)] _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): def clean_json(value, of_type):
...@@ -95,6 +100,7 @@ class ProviderConfig(ConfigurationModel): ...@@ -95,6 +100,7 @@ class ProviderConfig(ConfigurationModel):
) )
prefix = None # used for provider_id. Set to a string value in subclass 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 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 # "enabled" field is inherited from ConfigurationModel
...@@ -454,3 +460,70 @@ class SAMLProviderData(models.Model): ...@@ -454,3 +460,70 @@ class SAMLProviderData(models.Model):
cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout) cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout)
return current 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' ...@@ -99,13 +99,6 @@ AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_REGISTER = 'register' AUTH_ENTRY_REGISTER = 'register'
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings' 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). # 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_LOGIN_API = 'login_api'
AUTH_ENTRY_REGISTER_API = 'register_api' AUTH_ENTRY_REGISTER_API = 'register_api'
...@@ -126,28 +119,12 @@ AUTH_DISPATCH_URLS = { ...@@ -126,28 +119,12 @@ AUTH_DISPATCH_URLS = {
AUTH_ENTRY_LOGIN: '/login', AUTH_ENTRY_LOGIN: '/login',
AUTH_ENTRY_REGISTER: '/register', AUTH_ENTRY_REGISTER: '/register',
AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings', 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_CHOICES = frozenset([
AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN,
AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER,
AUTH_ENTRY_ACCOUNT_SETTINGS, 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_LOGIN_API,
AUTH_ENTRY_REGISTER_API, AUTH_ENTRY_REGISTER_API,
]) ])
...@@ -395,6 +372,7 @@ def get_provider_user_states(user): ...@@ -395,6 +372,7 @@ def get_provider_user_states(user):
if enabled_provider.match_social_auth(auth): if enabled_provider.match_social_auth(auth):
association_id = auth.id association_id = auth.id
break break
if enabled_provider.accepts_logins or association_id:
states.append( states.append(
ProviderUserState(enabled_provider, user, association_id) ProviderUserState(enabled_provider, user, association_id)
) )
...@@ -508,13 +486,13 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia ...@@ -508,13 +486,13 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
if not user: if not user:
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]: if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest() 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 # User has authenticated with the third party provider but we don't know which edX
# account corresponds to them yet, if any. # account corresponds to them yet, if any.
if should_force_account_creation(): if should_force_account_creation():
return dispatch_to_register() return dispatch_to_register()
return dispatch_to_login() 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 # User has authenticated with the third party provider and now wants to finish
# creating their edX account. # creating their edX account.
return dispatch_to_register() return dispatch_to_register()
...@@ -603,7 +581,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs): ...@@ -603,7 +581,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
""" Sends login info to Segment.io """ """ Sends login info to Segment.io """
event_name = None 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' event_name = 'edx.bi.user.account.authenticated'
elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]: elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]:
event_name = 'edx.bi.user.account.linked' event_name = 'edx.bi.user.account.linked'
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
Third-party auth provider configuration API. Third-party auth provider configuration API.
""" """
from .models import ( from .models import (
OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, LTIProviderConfig,
_PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS, _LTI_BACKENDS,
) )
...@@ -26,6 +26,10 @@ class Registry(object): ...@@ -26,6 +26,10 @@ class Registry(object):
provider = SAMLProviderConfig.current(idp_slug) provider = SAMLProviderConfig.current(idp_slug)
if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS: if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS:
yield provider 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 @classmethod
def enabled(cls): def enabled(cls):
...@@ -33,6 +37,11 @@ class Registry(object): ...@@ -33,6 +37,11 @@ class Registry(object):
return sorted(cls._enabled_providers(), key=lambda provider: provider.name) return sorted(cls._enabled_providers(), key=lambda provider: provider.name)
@classmethod @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): def get(cls, provider_id):
"""Gets provider by provider_id string if enabled, else None.""" """Gets provider by provider_id string if enabled, else None."""
if '-' not in provider_id: # Check format - see models.py:ProviderConfig if '-' not in provider_id: # Check format - see models.py:ProviderConfig
...@@ -83,3 +92,8 @@ class Registry(object): ...@@ -83,3 +92,8 @@ class Registry(object):
provider = SAMLProviderConfig.current(idp_name) provider = SAMLProviderConfig.current(idp_name)
if provider.backend_name == backend_name and provider.enabled: if provider.backend_name == backend_name and provider.enabled:
yield provider 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): ...@@ -20,6 +20,8 @@ class ConfigurationModelStrategy(DjangoStrategy):
OAuthAuth subclasses will call this method for every setting they want to look up. 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 SAMLAuthBackend subclasses will call this method only after first checking if the
setting 'name' is configured via SAMLProviderConfig. 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): if isinstance(backend, OAuthAuth):
provider_config = OAuth2ProviderConfig.current(backend.name) provider_config = OAuth2ProviderConfig.current(backend.name)
...@@ -29,6 +31,6 @@ class ConfigurationModelStrategy(DjangoStrategy): ...@@ -29,6 +31,6 @@ class ConfigurationModelStrategy(DjangoStrategy):
return provider_config.get_setting(name) return provider_config.get_setting(name)
except KeyError: except KeyError:
pass 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': # It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION':
return super(ConfigurationModelStrategy, self).setting(name, default, backend) 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): ...@@ -381,12 +381,6 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self): 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) 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): def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self):
self.assert_exception_redirect_looks_correct( self.assert_exception_redirect_looks_correct(
'/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS '/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 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 from django.core.urlresolvers import reverse
import httpretty import httpretty
from mock import patch from mock import patch
...@@ -38,7 +37,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): ...@@ -38,7 +37,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
def metadata_callback(_request, _uri, headers): def metadata_callback(_request, _uri, headers):
""" Return a cached copy of TestShib's metadata by reading it from disk """ """ 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) httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback)
self.addCleanup(httpretty.disable) self.addCleanup(httpretty.disable)
self.addCleanup(httpretty.reset) self.addCleanup(httpretty.reset)
...@@ -106,7 +105,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): ...@@ -106,7 +105,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
# Now check that we can login again: # Now check that we can login again:
self.client.logout() self.client.logout()
self._verify_user_email('myself@testshib.org') self.verify_user_email('myself@testshib.org')
self._test_return_login() self._test_return_login()
def test_login(self): def test_login(self):
...@@ -220,11 +219,5 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): ...@@ -220,11 +219,5 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
return self.client.post( return self.client.post(
TPA_TESTSHIB_COMPLETE_URL, TPA_TESTSHIB_COMPLETE_URL,
content_type='application/x-www-form-urlencoded', 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. ...@@ -6,11 +6,18 @@ Used by Django and non-Django tests; must not have Django deps.
from contextlib import contextmanager from contextlib import contextmanager
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
import django.test import django.test
import mock import mock
import os.path 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' AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
...@@ -52,6 +59,13 @@ class ThirdPartyAuthTestMixin(object): ...@@ -52,6 +59,13 @@ class ThirdPartyAuthTestMixin(object):
obj.save() obj.save()
return obj 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 @classmethod
def configure_google_provider(cls, **kwargs): def configure_google_provider(cls, **kwargs):
""" Update the settings for the Google third party auth provider/backend """ """ Update the settings for the Google third party auth provider/backend """
...@@ -92,6 +106,19 @@ class ThirdPartyAuthTestMixin(object): ...@@ -92,6 +106,19 @@ class ThirdPartyAuthTestMixin(object):
kwargs.setdefault("secret", "test") kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs) 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): class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase):
"""Base class for auth test cases.""" """Base class for auth test cases."""
...@@ -111,18 +138,12 @@ class SAMLTestCase(TestCase): ...@@ -111,18 +138,12 @@ class SAMLTestCase(TestCase):
@classmethod @classmethod
def _get_public_key(cls, key_name='saml_key'): def _get_public_key(cls, key_name='saml_key'):
""" Get a public key for use in the test. """ """ 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 @classmethod
def _get_private_key(cls, key_name='saml_key'): def _get_private_key(cls, key_name='saml_key'):
""" Get a private key for use in the test. """ """ Get a private key for use in the test. """
return cls._read_data_file('{}.key'.format(key_name)) 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()
def enable_saml(self, **kwargs): def enable_saml(self, **kwargs):
""" Enable SAML support (via SAMLConfiguration, not for any particular provider) """ """ Enable SAML support (via SAMLConfiguration, not for any particular provider) """
......
...@@ -2,11 +2,12 @@ ...@@ -2,11 +2,12 @@
from django.conf.urls import include, patterns, url 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( urlpatterns = patterns(
'', '',
url(r'^auth/inactive', inactive_user_view), url(r'^auth/inactive', inactive_user_view),
url(r'^auth/saml/metadata.xml', saml_metadata_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')), url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
) )
...@@ -3,11 +3,17 @@ Extra views required for SSO ...@@ -3,11 +3,17 @@ Extra views required for SSO
""" """
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse 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.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.apps.django_app.utils import load_strategy, load_backend
from social.utils import setting_name
from .models import SAMLConfiguration from .models import SAMLConfiguration
URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social'
def inactive_user_view(request): def inactive_user_view(request):
""" """
...@@ -36,3 +42,15 @@ def saml_metadata_view(request): ...@@ -36,3 +42,15 @@ def saml_metadata_view(request):
if not errors: if not errors:
return HttpResponse(content=metadata, content_type='text/xml') return HttpResponse(content=metadata, content_type='text/xml')
return HttpResponseServerError(content=', '.join(errors)) 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: ...@@ -60,6 +60,7 @@ lib_paths:
- public/js/split_test_staff.js - public/js/split_test_staff.js
- common_static/js/src/accessibility_tools.js - common_static/js/src/accessibility_tools.js
- common_static/js/vendor/moment.min.js - common_static/js/vendor/moment.min.js
- spec/main_requirejs.js
# Paths to spec (test) JavaScript files # Paths to spec (test) JavaScript files
spec_paths: 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 @@ ...@@ -14,10 +14,9 @@
define( define(
'video/01_initialize.js', 'video/01_initialize.js',
['video/03_video_player.js', 'video/00_i18n.js'], ['video/03_video_player.js', 'video/00_i18n.js', 'moment'],
function (VideoPlayer, i18n) { function (VideoPlayer, i18n, moment) {
var moment = window.moment; var moment = moment || window.moment;
/** /**
* @function * @function
* *
......
...@@ -281,21 +281,6 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -281,21 +281,6 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return courses.values() return courses.values()
@strip_key @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): def get_libraries(self, **kwargs):
""" """
Returns a list containing the top level XBlock of the libraries (LibraryRoot) in this modulestore. Returns a list containing the top level XBlock of the libraries (LibraryRoot) in this modulestore.
......
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
isZeroIndexed: false, isZeroIndexed: false,
perPage: 10, perPage: 10,
isStale: false,
sortField: '', sortField: '',
sortDirection: 'descending', sortDirection: 'descending',
sortableFields: {}, sortableFields: {},
...@@ -37,6 +39,8 @@ ...@@ -37,6 +39,8 @@
filterField: '', filterField: '',
filterableFields: {}, filterableFields: {},
searchString: null,
paginator_core: { paginator_core: {
type: 'GET', type: 'GET',
dataType: 'json', dataType: 'json',
...@@ -51,9 +55,10 @@ ...@@ -51,9 +55,10 @@
}, },
server_api: { server_api: {
'page': function () { return this.currentPage; }, page: function () { return this.currentPage; },
'page_size': function () { return this.perPage; }, page_size: function () { return this.perPage; },
'sort_order': function () { return this.sortField; } text_search: function () { return this.searchString ? this.searchString : ''; },
sort_order: function () { return this.sortField; }
}, },
parse: function (response) { parse: function (response) {
...@@ -61,7 +66,11 @@ ...@@ -61,7 +66,11 @@
this.currentPage = response.current_page; this.currentPage = response.current_page;
this.totalPages = response.num_pages; this.totalPages = response.num_pages;
this.start = response.start; this.start = response.start;
// Note: sort_order is not returned when performing a search
if (response.sort_order) {
this.sortField = response.sort_order; this.sortField = response.sort_order;
}
return response.results; return response.results;
}, },
...@@ -84,6 +93,7 @@ ...@@ -84,6 +93,7 @@
self = this; self = this;
return this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then( return this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
function () { function () {
self.isStale = false;
self.trigger('page_changed'); self.trigger('page_changed');
}, },
function () { function () {
...@@ -92,6 +102,24 @@ ...@@ -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. * Returns true if the collection has a next page, false otherwise.
*/ */
...@@ -183,7 +211,7 @@ ...@@ -183,7 +211,7 @@
} }
} }
this.sortField = fieldName; this.sortField = fieldName;
this.setPage(1); this.isStale = true;
}, },
/** /**
...@@ -193,7 +221,7 @@ ...@@ -193,7 +221,7 @@
*/ */
setSortDirection: function (direction) { setSortDirection: function (direction) {
this.sortDirection = direction; this.sortDirection = direction;
this.setPage(1); this.isStale = true;
}, },
/** /**
...@@ -203,7 +231,19 @@ ...@@ -203,7 +231,19 @@
*/ */
setFilterField: function (fieldName) { setFilterField: function (fieldName) {
this.filterField = 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: { SortDirection: {
......
...@@ -43,10 +43,16 @@ ...@@ -43,10 +43,16 @@
return this; 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 () { sortCollection: function () {
var selected = this.$('#paging-header-select option:selected'); var selected = this.$('#paging-header-select option:selected');
this.sortOrder = selected.attr('value'); this.sortOrder = selected.attr('value');
this.collection.setSortField(this.sortOrder); this.collection.setSortField(this.sortOrder);
return this.collection.refresh();
} }
}); });
return PagingHeader; 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', ...@@ -10,11 +10,11 @@ define(['jquery',
'use strict'; 'use strict';
describe('PagingCollection', function () { describe('PagingCollection', function () {
var collection, requests, server, assertQueryParams; var collection;
server = { var server = {
isZeroIndexed: false, isZeroIndexed: false,
count: 43, count: 43,
respond: function () { respond: function (requests) {
var params = (new URI(requests[requests.length - 1].url)).query(true), var params = (new URI(requests[requests.length - 1].url)).query(true),
page = parseInt(params['page'], 10), page = parseInt(params['page'], 10),
page_size = parseInt(params['page_size'], 10), page_size = parseInt(params['page_size'], 10),
...@@ -35,7 +35,7 @@ define(['jquery', ...@@ -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); var urlParams = (new URI(requests[requests.length - 1].url)).query(true);
_.each(params, function (value, key) { _.each(params, function (value, key) {
expect(urlParams[key]).toBe(value); expect(urlParams[key]).toBe(value);
...@@ -45,7 +45,6 @@ define(['jquery', ...@@ -45,7 +45,6 @@ define(['jquery',
beforeEach(function () { beforeEach(function () {
collection = new PagingCollection(); collection = new PagingCollection();
collection.perPage = 10; collection.perPage = 10;
requests = AjaxHelpers.requests(this);
server.isZeroIndexed = false; server.isZeroIndexed = false;
server.count = 43; server.count = 43;
}); });
...@@ -69,10 +68,11 @@ define(['jquery', ...@@ -69,10 +68,11 @@ define(['jquery',
}); });
it('can set the sort field', function () { it('can set the sort field', function () {
var requests = AjaxHelpers.requests(this);
collection.registerSortableField('test_field', 'Test Field'); collection.registerSortableField('test_field', 'Test Field');
collection.setSortField('test_field', false); collection.setSortField('test_field', false);
expect(requests.length).toBe(1); collection.refresh();
assertQueryParams({'sort_order': 'test_field'}); assertQueryParams(requests, {'sort_order': 'test_field'});
expect(collection.sortField).toBe('test_field'); expect(collection.sortField).toBe('test_field');
expect(collection.sortDisplayName()).toBe('Test Field'); expect(collection.sortDisplayName()).toBe('Test Field');
}); });
...@@ -80,7 +80,7 @@ define(['jquery', ...@@ -80,7 +80,7 @@ define(['jquery',
it('can set the filter field', function () { it('can set the filter field', function () {
collection.registerFilterableField('test_field', 'Test Field'); collection.registerFilterableField('test_field', 'Test Field');
collection.setFilterField('test_field'); collection.setFilterField('test_field');
expect(requests.length).toBe(1); collection.refresh();
// The default implementation does not send any query params for filtering // The default implementation does not send any query params for filtering
expect(collection.filterField).toBe('test_field'); expect(collection.filterField).toBe('test_field');
expect(collection.filterDisplayName()).toBe('Test Field'); expect(collection.filterDisplayName()).toBe('Test Field');
...@@ -88,11 +88,9 @@ define(['jquery', ...@@ -88,11 +88,9 @@ define(['jquery',
it('can set the sort direction', function () { it('can set the sort direction', function () {
collection.setSortDirection(PagingCollection.SortDirection.ASCENDING); collection.setSortDirection(PagingCollection.SortDirection.ASCENDING);
expect(requests.length).toBe(1);
// The default implementation does not send any query params for sort direction // The default implementation does not send any query params for sort direction
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING); expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING);
collection.setSortDirection(PagingCollection.SortDirection.DESCENDING); collection.setSortDirection(PagingCollection.SortDirection.DESCENDING);
expect(requests.length).toBe(2);
expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING); expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
}); });
...@@ -113,11 +111,12 @@ define(['jquery', ...@@ -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 zero indexed': [true, 2],
'queries with page, page_size, and sort_order parameters when one indexed': [false, 3], 'queries with page, page_size, and sort_order parameters when one indexed': [false, 3],
}, function (isZeroIndexed, page) { }, function (isZeroIndexed, page) {
var requests = AjaxHelpers.requests(this);
collection.isZeroIndexed = isZeroIndexed; collection.isZeroIndexed = isZeroIndexed;
collection.perPage = 5; collection.perPage = 5;
collection.sortField = 'test_field'; collection.sortField = 'test_field';
collection.setPage(3); 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({ SpecHelpers.withConfiguration({
...@@ -129,27 +128,30 @@ define(['jquery', ...@@ -129,27 +128,30 @@ define(['jquery',
}, function () { }, function () {
describe('setPage', function() { describe('setPage', function() {
it('triggers a reset event when the page changes successfully', 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.on('reset', function () { resetTriggered = true; });
collection.setPage(3); collection.setPage(3);
server.respond(); server.respond(requests);
expect(resetTriggered).toBe(true); expect(resetTriggered).toBe(true);
}); });
it('triggers an error event when the requested page is out of range', function () { 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.on('error', function () { errorTriggered = true; });
collection.setPage(17); collection.setPage(17);
server.respond(); server.respond(requests);
expect(errorTriggered).toBe(true); expect(errorTriggered).toBe(true);
}); });
it('triggers an error event if the server responds with a 500', function () { 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.on('error', function () { errorTriggered = true; });
collection.setPage(2); collection.setPage(2);
expect(collection.getPage()).toBe(2); expect(collection.getPage()).toBe(2);
server.respond(); server.respond(requests);
collection.setPage(3); collection.setPage(3);
AjaxHelpers.respondWithError(requests, 500, {}, requests.length - 1); AjaxHelpers.respondWithError(requests, 500, {}, requests.length - 1);
expect(errorTriggered).toBe(true); expect(errorTriggered).toBe(true);
...@@ -159,11 +161,12 @@ define(['jquery', ...@@ -159,11 +161,12 @@ define(['jquery',
describe('getPage', function () { describe('getPage', function () {
it('returns the correct page', function () { it('returns the correct page', function () {
var requests = AjaxHelpers.requests(this);
collection.setPage(1); collection.setPage(1);
server.respond(); server.respond(requests);
expect(collection.getPage()).toBe(1); expect(collection.getPage()).toBe(1);
collection.setPage(3); collection.setPage(3);
server.respond(); server.respond(requests);
expect(collection.getPage()).toBe(3); expect(collection.getPage()).toBe(3);
}); });
}); });
...@@ -177,9 +180,10 @@ define(['jquery', ...@@ -177,9 +180,10 @@ define(['jquery',
'returns false on the last page': [5, 43, false] 'returns false on the last page': [5, 43, false]
}, },
function (page, count, result) { function (page, count, result) {
var requests = AjaxHelpers.requests(this);
server.count = count; server.count = count;
collection.setPage(page); collection.setPage(page);
server.respond(); server.respond(requests);
expect(collection.hasNextPage()).toBe(result); expect(collection.hasNextPage()).toBe(result);
} }
); );
...@@ -194,9 +198,10 @@ define(['jquery', ...@@ -194,9 +198,10 @@ define(['jquery',
'returns false on the first page': [1, 43, false] 'returns false on the first page': [1, 43, false]
}, },
function (page, count, result) { function (page, count, result) {
var requests = AjaxHelpers.requests(this);
server.count = count; server.count = count;
collection.setPage(page); collection.setPage(page);
server.respond(); server.respond(requests);
expect(collection.hasPreviousPage()).toBe(result); expect(collection.hasPreviousPage()).toBe(result);
} }
); );
...@@ -209,13 +214,14 @@ define(['jquery', ...@@ -209,13 +214,14 @@ define(['jquery',
'silently fails on the last page': [5, 43, 5] 'silently fails on the last page': [5, 43, 5]
}, },
function (page, count, newPage) { function (page, count, newPage) {
var requests = AjaxHelpers.requests(this);
server.count = count; server.count = count;
collection.setPage(page); collection.setPage(page);
server.respond(); server.respond(requests);
expect(collection.getPage()).toBe(page); expect(collection.getPage()).toBe(page);
collection.nextPage(); collection.nextPage();
if (requests.length > 1) { if (requests.length > 1) {
server.respond(); server.respond(requests);
} }
expect(collection.getPage()).toBe(newPage); expect(collection.getPage()).toBe(newPage);
} }
...@@ -229,13 +235,14 @@ define(['jquery', ...@@ -229,13 +235,14 @@ define(['jquery',
'silently fails on the first page': [1, 43, 1] 'silently fails on the first page': [1, 43, 1]
}, },
function (page, count, newPage) { function (page, count, newPage) {
var requests = AjaxHelpers.requests(this);
server.count = count; server.count = count;
collection.setPage(page); collection.setPage(page);
server.respond(); server.respond(requests);
expect(collection.getPage()).toBe(page); expect(collection.getPage()).toBe(page);
collection.previousPage(); collection.previousPage();
if (requests.length > 1) { if (requests.length > 1) {
server.respond(); server.respond(requests);
} }
expect(collection.getPage()).toBe(newPage); 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) { define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
'use strict'; 'use strict';
var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest, expectJsonRequestURL, var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest, expectRequestURL,
respondWithJson, respondWithError, respondWithTextError, respondWithNoContent; respondWithJson, respondWithError, respondWithTextError, respondWithNoContent;
/* These utility methods are used by Jasmine tests to create a mock server or /* 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) { ...@@ -77,7 +77,7 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
* @param expectedParameters An object representing the URL parameters * @param expectedParameters An object representing the URL parameters
* @param requestIndex An optional index for the request (by default, the last request is used) * @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; var request, parameters;
if (_.isUndefined(requestIndex)) { if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1; requestIndex = requests.length - 1;
...@@ -153,15 +153,15 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { ...@@ -153,15 +153,15 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
}; };
return { return {
'server': fakeServer, server: fakeServer,
'requests': fakeRequests, requests: fakeRequests,
'expectRequest': expectRequest, expectRequest: expectRequest,
'expectJsonRequest': expectJsonRequest, expectJsonRequest: expectJsonRequest,
'expectJsonRequestURL': expectJsonRequestURL, expectPostRequest: expectPostRequest,
'expectPostRequest': expectPostRequest, expectRequestURL: expectRequestURL,
'respondWithJson': respondWithJson, respondWithJson: respondWithJson,
'respondWithError': respondWithError, respondWithError: respondWithError,
'respondWithTextError': respondWithTextError, respondWithTextError: respondWithTextError,
'respondWithNoContent': respondWithNoContent, 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 @@ ...@@ -15,8 +15,8 @@
<% } %> <% } %>
<div class="copy"> <div class="copy">
<h2 class="title title-3" id="<%= type %>-<%= intent %>-title"><%= title %></h2> <h2 class="title title-3" id="<%= type %>-<%= intent %>-title"><%- title %></h2>
<% if(obj.message) { %><p class="message" id="<%= type %>-<%= intent %>-description"><%= message %></p><% } %> <% if(obj.message) { %><p class="message" id="<%= type %>-<%= intent %>-description"><%- message %></p><% } %>
</div> </div>
<% if(obj.actions) { %> <% if(obj.actions) { %>
...@@ -24,13 +24,13 @@ ...@@ -24,13 +24,13 @@
<ul> <ul>
<% if(actions.primary) { %> <% if(actions.primary) { %>
<li class="nav-item"> <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> </li>
<% } %> <% } %>
<% if(actions.secondary) { <% if(actions.secondary) {
_.each(actions.secondary, function(secondary) { %> _.each(actions.secondary, function(secondary) { %>
<li class="nav-item"> <li class="nav-item">
<button class="action-secondary <%= secondary.class %>"><%= secondary.text %></button> <button class="action-secondary <%= secondary.class %>"><%- secondary.text %></button>
</li> </li>
<% }); <% });
} %> } %>
......
...@@ -289,7 +289,7 @@ define(['js/capa/drag_and_drop/draggable_events', 'js/capa/drag_and_drop/draggab ...@@ -289,7 +289,7 @@ define(['js/capa/drag_and_drop/draggable_events', 'js/capa/drag_and_drop/draggab
draggableObj.iconEl.appendTo(draggableObj.containerEl); draggableObj.iconEl.appendTo(draggableObj.containerEl);
draggableObj.iconWidth = draggableObj.iconEl.width(); draggableObj.iconWidth = draggableObj.iconEl.width() + 1;
draggableObj.iconHeight = draggableObj.iconEl.height(); draggableObj.iconHeight = draggableObj.iconEl.height();
draggableObj.iconWidthSmall = draggableObj.iconWidth; draggableObj.iconWidthSmall = draggableObj.iconWidth;
draggableObj.iconHeightSmall = draggableObj.iconHeight; draggableObj.iconHeightSmall = draggableObj.iconHeight;
......
...@@ -155,13 +155,14 @@ ...@@ -155,13 +155,14 @@
define([ define([
// Run the common tests that use RequireJS. // 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/list_spec.js',
'common-requirejs/include/common/js/spec/components/paginated_view_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_collection_spec.js',
'common-requirejs/include/common/js/spec/components/paging_header_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/paging_footer_spec.js',
'common-requirejs/include/common/js/spec/components/view_utils_spec.js', 'common-requirejs/include/common/js/spec/components/search_field_spec.js',
'common-requirejs/include/common/js/spec/components/feedback_spec.js' 'common-requirejs/include/common/js/spec/components/view_utils_spec.js'
]); ]);
}).call(this, requirejs, define); }).call(this, requirejs, define);
...@@ -17,7 +17,8 @@ class AutoAuthPage(PageObject): ...@@ -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+)$' 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. Auto-auth is an end-point for HTTP GET requests.
By default, it will create accounts with random user credentials, By default, it will create accounts with random user credentials,
...@@ -52,6 +53,8 @@ class AutoAuthPage(PageObject): ...@@ -52,6 +53,8 @@ class AutoAuthPage(PageObject):
if course_id is not None: if course_id is not None:
self._params['course_id'] = course_id self._params['course_id'] = course_id
if enrollment_mode:
self._params['enrollment_mode'] = enrollment_mode
if roles is not None: if roles is not None:
self._params['roles'] = roles self._params['roles'] = roles
......
...@@ -127,7 +127,7 @@ class CombinedLoginAndRegisterPage(PageObject): ...@@ -127,7 +127,7 @@ class CombinedLoginAndRegisterPage(PageObject):
@property @property
def url(self): def url(self):
"""Return the URL for the combined login/registration page. """ """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, base=BASE_URL,
login_or_register=self._start_page login_or_register=self._start_page
) )
......
...@@ -20,6 +20,25 @@ TEAMS_HEADER_CSS = '.teams-header' ...@@ -20,6 +20,25 @@ TEAMS_HEADER_CSS = '.teams-header'
CREATE_TEAM_LINK_CSS = '.create-team' 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): class TeamsPage(CoursePage):
""" """
Teams page/tab. Teams page/tab.
...@@ -84,7 +103,7 @@ class TeamsPage(CoursePage): ...@@ -84,7 +103,7 @@ class TeamsPage(CoursePage):
self.q(css='a.nav-item').filter(text=topic)[0].click() 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. The 'My Teams' tab of the Teams page.
""" """
...@@ -98,11 +117,6 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin): ...@@ -98,11 +117,6 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin):
return False return False
return 'is-active' in button_classes[0] 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): class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
""" """
...@@ -128,6 +142,11 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -128,6 +142,11 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
"""Return a list of the topic names present on the page.""" """Return a list of the topic names present on the page."""
return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results 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): def browse_teams_for_topic(self, topic_name):
""" """
Show the teams list for `topic_name`. Show the teams list for `topic_name`.
...@@ -145,43 +164,43 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -145,43 +164,43 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
self.wait_for_ajax() 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 The paginated UI for browsing teams within a Topic on the Teams
page. page.
""" """
def __init__(self, browser, course_id, topic): def __init__(self, browser, course_id, topic):
""" """
Set up `self.url_path` on instantiation, since it dynamically Note that `topic` is a dict representation of a topic following
reflects the current topic. Note that `topic` is a dict the same convention as a course module's topic.
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.topic = topic
self.url_path = "teams/#topics/{topic_id}".format(topic_id=self.topic['id'])
def is_browser_on_page(self): def is_browser_on_page(self):
"""Check if we're on the teams list page for a particular topic.""" """Check if we're on a teams list page for a particular topic."""
self.wait_for_element_presence('.team-actions', 'Wait for the bottom links to be present')
has_correct_url = self.url.endswith(self.url_path) has_correct_url = self.url.endswith(self.url_path)
teams_list_view_present = self.q(css='.teams-main').present teams_list_view_present = self.q(css='.teams-main').present
return has_correct_url and teams_list_view_present return has_correct_url and teams_list_view_present
@property @property
def header_topic_name(self): def header_name(self):
"""Get the topic name displayed by the page header""" """Get the topic name displayed by the page header"""
return self.q(css=TEAMS_HEADER_CSS + ' .page-title')[0].text return self.q(css=TEAMS_HEADER_CSS + ' .page-title')[0].text
@property @property
def header_topic_description(self): def header_description(self):
"""Get the topic description displayed by the page header""" """Get the topic description displayed by the page header"""
return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text
@property @property
def team_cards(self): def sort_order(self):
"""Get all the team cards on the page.""" """Return the current sort order on the page."""
return self.q(css='.team-card') return self.q(
css='#paging-header-select option'
).filter(
lambda e: e.is_selected()
).results[0].text.strip()
def click_create_team_link(self): def click_create_team_link(self):
""" Click on create team link.""" """ Click on create team link."""
...@@ -204,6 +223,55 @@ class BrowseTeamsPage(CoursePage, PaginatedUIMixin): ...@@ -204,6 +223,55 @@ class BrowseTeamsPage(CoursePage, PaginatedUIMixin):
query.first.click() query.first.click()
self.wait_for_ajax() 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): class CreateOrEditTeamPage(CoursePage, FieldsMixin):
""" """
......
...@@ -305,7 +305,7 @@ class ContainerPage(PageObject): ...@@ -305,7 +305,7 @@ class ContainerPage(PageObject):
Returns: Returns:
list 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, tab_index=tab_index,
category_type=category_type category_type=category_type
) )
......
...@@ -27,6 +27,12 @@ class CertificatesPage(CoursePage): ...@@ -27,6 +27,12 @@ class CertificatesPage(CoursePage):
# Helpers # Helpers
################ ################
def refresh(self):
"""
Refresh the certificate page
"""
self.browser.refresh()
def is_browser_on_page(self): def is_browser_on_page(self):
""" """
Verify that the browser is on the page and it is not still loading. Verify that the browser is on the page and it is not still loading.
...@@ -434,11 +440,8 @@ class Signatory(object): ...@@ -434,11 +440,8 @@ class Signatory(object):
""" """
Save signatory. Save signatory.
""" """
# Move focus from input to save button and then click it # Click on the save button.
self.certificate.page.browser.execute_script( self.certificate.page.q(css='button.signatory-panel-save').click()
"$('{} .signatory-panel-save').focus()".format(self.get_selector())
)
self.find_css('.signatory-panel-save').first.click()
self.mode = 'details' self.mode = 'details'
self.certificate.page.wait_for_ajax() self.certificate.page.wait_for_ajax()
self.wait_for_signatory_detail_view() self.wait_for_signatory_detail_view()
...@@ -447,7 +450,7 @@ class Signatory(object): ...@@ -447,7 +450,7 @@ class Signatory(object):
""" """
Cancel signatory editing. 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.mode = 'details'
self.wait_for_signatory_detail_view() self.wait_for_signatory_detail_view()
......
...@@ -33,9 +33,9 @@ class UsersPageMixin(PageObject): ...@@ -33,9 +33,9 @@ class UsersPageMixin(PageObject):
def is_browser_on_page(self): 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 @property
def users(self): def users(self):
......
...@@ -72,7 +72,7 @@ def add_discussion(page, menu_index=0): ...@@ -72,7 +72,7 @@ def add_discussion(page, menu_index=0):
placement within the page). placement within the page).
""" """
page.wait_for_component_menu() 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): def add_advanced_component(page, menu_index, name):
...@@ -84,7 +84,7 @@ 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. # Click on the Advanced icon.
page.wait_for_component_menu() 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 # This does an animation to hide the first level of buttons
# and instead show the Advanced buttons that are available. # and instead show the Advanced buttons that are available.
...@@ -95,7 +95,7 @@ def add_advanced_component(page, menu_index, name): ...@@ -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') page.wait_for_element_visibility('.new-component-advanced', 'Advanced component menu is visible')
# Now click on the component to add it. # 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)) 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 # 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): ...@@ -123,7 +123,7 @@ def add_component(page, item_type, specific_type):
'Wait for the add component menu to disappear' '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 = all_options.filter(lambda el: el.text == specific_type).first
chosen_option.click() chosen_option.click()
wait_for_notification(page) wait_for_notification(page)
...@@ -139,13 +139,13 @@ def add_html_component(page, menu_index, boilerplate=None): ...@@ -139,13 +139,13 @@ def add_html_component(page, menu_index, boilerplate=None):
""" """
# Click on the HTML icon. # Click on the HTML icon.
page.wait_for_component_menu() 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 # 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') page.wait_for_element_visibility('.new-component-html', 'HTML component menu is visible')
# Now click on the component to add it. # Now click on the component to add it.
component_css = 'a[data-category=html]' component_css = 'button[data-category=html]'
if boilerplate: if boilerplate:
component_css += '[data-boilerplate={}]'.format(boilerplate) component_css += '[data-boilerplate={}]'.format(boilerplate)
else: else:
......
...@@ -30,7 +30,7 @@ CLASS_SELECTORS = { ...@@ -30,7 +30,7 @@ CLASS_SELECTORS = {
} }
BUTTON_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': '.video-handout.video-download-button a',
'handout_download_editor': '.wrapper-comp-setting.file-uploader .download-action', 'handout_download_editor': '.wrapper-comp-setting.file-uploader .download-action',
'upload_asset': '.upload-action', 'upload_asset': '.upload-action',
......
...@@ -109,7 +109,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest): ...@@ -109,7 +109,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
self.login_page.visit().toggle_form() self.login_page.visit().toggle_form()
self.assertEqual(self.login_page.current_form, "register") self.assertEqual(self.login_page.current_form, "register")
@flaky # TODO fix this, see ECOM-1165 @flaky # ECOM-1165
def test_password_reset_success(self): def test_password_reset_success(self):
# Create a user account # Create a user account
email, password = self._create_unique_user() # pylint: disable=unused-variable email, password = self._create_unique_user() # pylint: disable=unused-variable
......
...@@ -168,32 +168,12 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -168,32 +168,12 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# Auto-auth register for the course. # Auto-auth register for the course.
self._auto_auth(self.USERNAME, self.EMAIL, False) 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. Logout and login with given credentials.
""" """
AutoAuthPage(self.browser, username=username, email=email, AutoAuthPage(self.browser, username=username, email=email,
course_id=self.course_id, staff=staff).visit() course_id=self.course_id, staff=staff, enrollment_mode=enrollment_mode).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()
def _create_a_proctored_exam_and_attempt(self): def _create_a_proctored_exam_and_attempt(self):
""" """
...@@ -212,7 +192,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -212,7 +192,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# login as a verified student and visit the courseware. # login as a verified student and visit the courseware.
LogoutPage(self.browser).visit() 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() self.courseware_page.visit()
# Start the proctored exam. # Start the proctored exam.
...@@ -235,7 +215,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -235,7 +215,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# login as a verified student and visit the courseware. # login as a verified student and visit the courseware.
LogoutPage(self.browser).visit() 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() self.courseware_page.visit()
# Start the proctored exam. # Start the proctored exam.
......
...@@ -5,6 +5,7 @@ Bok choy acceptance tests for problems in the LMS ...@@ -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 See also old lettuce tests in lms/djangoapps/courseware/features/problems.feature
""" """
from textwrap import dedent from textwrap import dedent
from flaky import flaky
from ..helpers import UniqueCourseTest from ..helpers import UniqueCourseTest
from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.auto_auth import AutoAuthPage
...@@ -191,6 +192,7 @@ class ProblemHintWithHtmlTest(ProblemsTest, EventsTestMixin): ...@@ -191,6 +192,7 @@ class ProblemHintWithHtmlTest(ProblemsTest, EventsTestMixin):
""") """)
return XBlockFixtureDesc('problem', 'PROBLEM HTML HINT TEST', data=xml) return XBlockFixtureDesc('problem', 'PROBLEM HTML HINT TEST', data=xml)
@flaky # TODO fix this, see TNL-3183
def test_check_hint(self): def test_check_hint(self):
""" """
Test clicking Check shows the extended hint in the problem message. Test clicking Check shows the extended hint in the problem message.
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
Acceptance tests for Studio related to the asset index page. Acceptance tests for Studio related to the asset index page.
""" """
from flaky import flaky
from ...pages.studio.asset_index import AssetIndexPage from ...pages.studio.asset_index import AssetIndexPage
from .base_studio_test import StudioCourseTest from .base_studio_test import StudioCourseTest
...@@ -35,6 +37,7 @@ class AssetIndexTest(StudioCourseTest): ...@@ -35,6 +37,7 @@ class AssetIndexTest(StudioCourseTest):
""" """
self.asset_page.visit() self.asset_page.visit()
@flaky # TODO fix this, see SOL-1160
def test_type_filter_exists(self): def test_type_filter_exists(self):
""" """
Make sure type filter is on the page. Make sure type filter is on the page.
......
...@@ -522,9 +522,7 @@ class LibraryUsersPageTest(StudioLibraryTest): ...@@ -522,9 +522,7 @@ class LibraryUsersPageTest(StudioLibraryTest):
""" """
self.page = LibraryUsersPage(self.browser, self.library_key) self.page = LibraryUsersPage(self.browser, self.library_key)
self.page.visit() self.page.visit()
self.page.wait_until_no_loading_indicator()
@flaky # TODO fix this; see TNL-2647
def test_user_management(self): def test_user_management(self):
""" """
Scenario: Ensure that we can edit the permissions of users. Scenario: Ensure that we can edit the permissions of users.
......
...@@ -170,6 +170,8 @@ class CertificatesTest(StudioCourseTest): ...@@ -170,6 +170,8 @@ class CertificatesTest(StudioCourseTest):
self.assertEqual(len(self.certificates_page.certificates), 1) 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] signatory = self.certificates_page.certificates[0].signatories[0]
self.assertIn("Updated signatory name", signatory.name) self.assertIn("Updated signatory name", signatory.name)
self.assertIn("Update signatory title", signatory.title) self.assertIn("Update signatory title", signatory.title)
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import datetime import datetime
import json import json
import ddt import ddt
import unittest
from ..helpers import EventsTestMixin from ..helpers import EventsTestMixin
from .test_video_module import VideoBaseTest from .test_video_module import VideoBaseTest
...@@ -60,6 +61,7 @@ class VideoEventsTestMixin(EventsTestMixin, VideoBaseTest): ...@@ -60,6 +61,7 @@ class VideoEventsTestMixin(EventsTestMixin, VideoBaseTest):
class VideoEventsTest(VideoEventsTestMixin): class VideoEventsTest(VideoEventsTestMixin):
""" Test video player event emission """ """ Test video player event emission """
@unittest.skip('AN-5867')
def test_video_control_events(self): def test_video_control_events(self):
""" """
Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources
......
...@@ -61,7 +61,7 @@ msgid "" ...@@ -61,7 +61,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\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" "PO-Revision-Date: 2015-05-28 20:00+0000\n"
"Last-Translator: Nadav Stark <nadav@yeda.org.il>\n" "Last-Translator: Nadav Stark <nadav@yeda.org.il>\n"
"Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n" "Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n"
...@@ -1216,10 +1216,6 @@ msgid "incorrect" ...@@ -1216,10 +1216,6 @@ msgid "incorrect"
msgstr "" msgstr ""
#: common/lib/capa/capa/inputtypes.py #: common/lib/capa/capa/inputtypes.py
msgid "partially correct"
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "incomplete" msgid "incomplete"
msgstr "" msgstr ""
...@@ -1242,10 +1238,6 @@ msgid "This is incorrect." ...@@ -1242,10 +1238,6 @@ msgid "This is incorrect."
msgstr "" msgstr ""
#: common/lib/capa/capa/inputtypes.py #: common/lib/capa/capa/inputtypes.py
msgid "This is partially correct."
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "This is unanswered." msgid "This is unanswered."
msgstr "" msgstr ""
...@@ -4550,7 +4542,14 @@ msgid "{month} {day}, {year}" ...@@ -4550,7 +4542,14 @@ msgid "{month} {day}, {year}"
msgstr "" msgstr ""
#: lms/djangoapps/certificates/views/webview.py #: 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 "" msgstr ""
#. Translators: Accomplishments describe the awards/certifications obtained by #. Translators: Accomplishments describe the awards/certifications obtained by
...@@ -4650,13 +4649,13 @@ msgstr "" ...@@ -4650,13 +4649,13 @@ msgstr ""
#: lms/djangoapps/certificates/views/webview.py #: lms/djangoapps/certificates/views/webview.py
msgid "" msgid ""
"This is a valid {platform_name} certificate for {user_name}, who " "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 "" msgstr ""
#. Translators: This text is bound to the HTML 'title' element of the page #. Translators: This text is bound to the HTML 'title' element of the page
#. and appears in the browser title bar #. and appears in the browser title bar
#: lms/djangoapps/certificates/views/webview.py #: lms/djangoapps/certificates/views/webview.py
msgid "{partner_name} {course_number} Certificate | {platform_name}" msgid "{partner_short_name} {course_number} Certificate | {platform_name}"
msgstr "" msgstr ""
#. Translators: This text fragment appears after the student's name #. Translators: This text fragment appears after the student's name
...@@ -4813,6 +4812,14 @@ msgid "" ...@@ -4813,6 +4812,14 @@ msgid ""
"{payment_support_link}." "{payment_support_link}."
msgstr "" 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/djangoapps/course_wiki/tab.py lms/djangoapps/course_wiki/views.py
#: lms/templates/wiki/base.html #: lms/templates/wiki/base.html
msgid "Wiki" msgid "Wiki"
...@@ -5334,6 +5341,23 @@ msgid "File is not attached." ...@@ -5334,6 +5341,23 @@ msgid "File is not attached."
msgstr "" msgstr ""
#: lms/djangoapps/instructor/views/api.py #: 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." msgid "Invoice number '{num}' does not exist."
msgstr "" msgstr ""
...@@ -5720,6 +5744,10 @@ msgid "CourseMode price updated successfully" ...@@ -5720,6 +5744,10 @@ msgid "CourseMode price updated successfully"
msgstr "" msgstr ""
#: lms/djangoapps/instructor/views/instructor_dashboard.py #: 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}." msgid "Enrollment data is now available in {dashboard_link}."
msgstr "" msgstr ""
...@@ -5817,18 +5845,6 @@ msgid "Grades for assignment \"{name}\"" ...@@ -5817,18 +5845,6 @@ msgid "Grades for assignment \"{name}\""
msgstr "" msgstr ""
#: lms/djangoapps/instructor/views/legacy.py #: 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}" msgid "Grades from {course_id}"
msgstr "" msgstr ""
...@@ -5994,6 +6010,12 @@ msgstr "" ...@@ -5994,6 +6010,12 @@ msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress #. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}. #. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py #: 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" msgid "graded"
msgstr "" msgstr ""
...@@ -6006,12 +6028,6 @@ msgstr "" ...@@ -6006,12 +6028,6 @@ msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress #. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}. #. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py #: 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" msgid "generating_enrollment_report"
msgstr "" msgstr ""
...@@ -7392,6 +7408,7 @@ msgid "Optional language the team uses as ISO 639-1 code." ...@@ -7392,6 +7408,7 @@ msgid "Optional language the team uses as ISO 639-1 code."
msgstr "" msgstr ""
#: lms/djangoapps/teams/plugins.py #: lms/djangoapps/teams/plugins.py
#: lms/djangoapps/teams/templates/teams/teams.html
msgid "Teams" msgid "Teams"
msgstr "" msgstr ""
...@@ -7404,11 +7421,11 @@ msgid "course_id must be provided" ...@@ -7404,11 +7421,11 @@ msgid "course_id must be provided"
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py #: 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 "" msgstr ""
#: lms/djangoapps/teams/views.py #: lms/djangoapps/teams/views.py
msgid "text_search is not yet supported." msgid "The supplied topic id {topic_id} is not valid"
msgstr "" msgstr ""
#. Translators: 'ordering' is a string describing a way #. Translators: 'ordering' is a string describing a way
...@@ -9112,6 +9129,10 @@ msgstr "" ...@@ -9112,6 +9129,10 @@ msgstr ""
msgid "Sign Out" msgid "Sign Out"
msgstr "" msgstr ""
#: common/lib/capa/capa/templates/codeinput.html
msgid "{programming_language} editor"
msgstr ""
#: common/templates/license.html #: common/templates/license.html
msgid "All Rights Reserved" msgid "All Rights Reserved"
msgstr "" msgstr ""
...@@ -11605,7 +11626,9 @@ msgid "Section:" ...@@ -11605,7 +11626,9 @@ msgid "Section:"
msgstr "" msgstr ""
#: lms/templates/courseware/legacy_instructor_dashboard.html #: 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 "" msgstr ""
#: lms/templates/courseware/legacy_instructor_dashboard.html #: lms/templates/courseware/legacy_instructor_dashboard.html
...@@ -13379,6 +13402,20 @@ msgstr "" ...@@ -13379,6 +13402,20 @@ msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html #: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid "" 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" "For smaller courses, click to list profile information for enrolled students"
" directly on this page:" " directly on this page:"
msgstr "" msgstr ""
...@@ -15674,41 +15711,50 @@ msgid "This module is not enabled." ...@@ -15674,41 +15711,50 @@ msgid "This module is not enabled."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "Working with Certificates"
msgstr ""
#: cms/templates/certificates.html
msgid "" msgid ""
"Upon successful completion of your course, learners receive a certificate to" "Specify a course title to use on the certificate if the course's official "
" acknowledge their accomplishment. If you are a course team member with the " "title is too long to be displayed well."
"Admin role in Studio, you can configure your course certificate."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "" msgid ""
"Click {em_start}Add your first certificate{em_end} to add a certificate " "For verified certificates, specify between one and four signatories and "
"configuration. Upload the organization logo to be used on the certificate, " "upload the associated images."
"and specify at least one signatory. You can include up to four signatories " msgstr ""
"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 " #: cms/templates/certificates.html
"verified certificates. Optionally, specify a different course title to use " msgid ""
"on your course certificate. You might want to use a different title if, for " "To edit or delete a certificate before it is activated, hover over the top "
"example, the official course name is too long to display well on a " "right corner of the form and select {em_start}Edit{em_end} or the delete "
"certificate." "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 "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "" msgid ""
"Select a course mode and click {em_start}Preview Certificate{em_end} to " "To begin issuing certificates, a course team member with the Admin role "
"preview the certificate that a learner in the selected enrollment track " "selects {em_start}Activate{em_end}. Course team members without the Admin "
"would receive. When the certificate is ready for issuing, click " "role cannot edit or delete an activated certificate."
"{em_start}Activate.{em_end} To stop issuing an active certificate, click "
"{em_start}Deactivate{em_end}."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "" msgid ""
" To edit the certificate configuration, hover over the top right corner of " "{em_start}Do not{em_end} delete certificates after a course has started; "
"the form and click {em_start}Edit{em_end}. To delete a certificate, hover " "learners who have already earned certificates will no longer be able to "
"over the top right corner of the form and click the delete icon. In general," "access them."
" do not delete certificates after a course has started, because some "
"certificates might already have been issued to learners."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
......
...@@ -72,7 +72,7 @@ msgid "" ...@@ -72,7 +72,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\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" "PO-Revision-Date: 2015-06-28 20:21+0000\n"
"Last-Translator: ria1234 <contactpayal@yahoo.com.au>\n" "Last-Translator: ria1234 <contactpayal@yahoo.com.au>\n"
"Language-Team: Hindi (http://www.transifex.com/open-edx/edx-platform/language/hi/)\n" "Language-Team: Hindi (http://www.transifex.com/open-edx/edx-platform/language/hi/)\n"
...@@ -1228,10 +1228,6 @@ msgid "incorrect" ...@@ -1228,10 +1228,6 @@ msgid "incorrect"
msgstr "" msgstr ""
#: common/lib/capa/capa/inputtypes.py #: common/lib/capa/capa/inputtypes.py
msgid "partially correct"
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "incomplete" msgid "incomplete"
msgstr "" msgstr ""
...@@ -1254,10 +1250,6 @@ msgid "This is incorrect." ...@@ -1254,10 +1250,6 @@ msgid "This is incorrect."
msgstr "" msgstr ""
#: common/lib/capa/capa/inputtypes.py #: common/lib/capa/capa/inputtypes.py
msgid "This is partially correct."
msgstr ""
#: common/lib/capa/capa/inputtypes.py
msgid "This is unanswered." msgid "This is unanswered."
msgstr "" msgstr ""
...@@ -4587,7 +4579,14 @@ msgid "{month} {day}, {year}" ...@@ -4587,7 +4579,14 @@ msgid "{month} {day}, {year}"
msgstr "" msgstr ""
#: lms/djangoapps/certificates/views/webview.py #: 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 "" msgstr ""
#. Translators: Accomplishments describe the awards/certifications obtained by #. Translators: Accomplishments describe the awards/certifications obtained by
...@@ -4687,13 +4686,13 @@ msgstr "" ...@@ -4687,13 +4686,13 @@ msgstr ""
#: lms/djangoapps/certificates/views/webview.py #: lms/djangoapps/certificates/views/webview.py
msgid "" msgid ""
"This is a valid {platform_name} certificate for {user_name}, who " "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 "" msgstr ""
#. Translators: This text is bound to the HTML 'title' element of the page #. Translators: This text is bound to the HTML 'title' element of the page
#. and appears in the browser title bar #. and appears in the browser title bar
#: lms/djangoapps/certificates/views/webview.py #: lms/djangoapps/certificates/views/webview.py
msgid "{partner_name} {course_number} Certificate | {platform_name}" msgid "{partner_short_name} {course_number} Certificate | {platform_name}"
msgstr "" msgstr ""
#. Translators: This text fragment appears after the student's name #. Translators: This text fragment appears after the student's name
...@@ -4849,6 +4848,14 @@ msgid "" ...@@ -4849,6 +4848,14 @@ msgid ""
"{payment_support_link}." "{payment_support_link}."
msgstr "" 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/djangoapps/course_wiki/tab.py lms/djangoapps/course_wiki/views.py
#: lms/templates/wiki/base.html #: lms/templates/wiki/base.html
msgid "Wiki" msgid "Wiki"
...@@ -5385,6 +5392,23 @@ msgid "File is not attached." ...@@ -5385,6 +5392,23 @@ msgid "File is not attached."
msgstr "" msgstr ""
#: lms/djangoapps/instructor/views/api.py #: 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." msgid "Invoice number '{num}' does not exist."
msgstr "" msgstr ""
...@@ -5771,6 +5795,10 @@ msgid "CourseMode price updated successfully" ...@@ -5771,6 +5795,10 @@ msgid "CourseMode price updated successfully"
msgstr "" msgstr ""
#: lms/djangoapps/instructor/views/instructor_dashboard.py #: 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}." msgid "Enrollment data is now available in {dashboard_link}."
msgstr "" msgstr ""
...@@ -5866,18 +5894,6 @@ msgid "Grades for assignment \"{name}\"" ...@@ -5866,18 +5894,6 @@ msgid "Grades for assignment \"{name}\""
msgstr "" msgstr ""
#: lms/djangoapps/instructor/views/legacy.py #: 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}" msgid "Grades from {course_id}"
msgstr "" msgstr ""
...@@ -6043,6 +6059,12 @@ msgstr "ईमेल कर दी गई" ...@@ -6043,6 +6059,12 @@ msgstr "ईमेल कर दी गई"
#. Translators: This is a past-tense verb that is inserted into task progress #. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}. #. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py #: 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" msgid "graded"
msgstr "श्रेणी दी जा चुकी है" msgstr "श्रेणी दी जा चुकी है"
...@@ -6055,12 +6077,6 @@ msgstr "" ...@@ -6055,12 +6077,6 @@ msgstr ""
#. Translators: This is a past-tense verb that is inserted into task progress #. Translators: This is a past-tense verb that is inserted into task progress
#. messages as {action}. #. messages as {action}.
#: lms/djangoapps/instructor_task/tasks.py #: 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" msgid "generating_enrollment_report"
msgstr "" msgstr ""
...@@ -7517,6 +7533,7 @@ msgid "Optional language the team uses as ISO 639-1 code." ...@@ -7517,6 +7533,7 @@ msgid "Optional language the team uses as ISO 639-1 code."
msgstr "" msgstr ""
#: lms/djangoapps/teams/plugins.py #: lms/djangoapps/teams/plugins.py
#: lms/djangoapps/teams/templates/teams/teams.html
msgid "Teams" msgid "Teams"
msgstr "" msgstr ""
...@@ -7529,11 +7546,11 @@ msgid "course_id must be provided" ...@@ -7529,11 +7546,11 @@ msgid "course_id must be provided"
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py #: 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 "" msgstr ""
#: lms/djangoapps/teams/views.py #: lms/djangoapps/teams/views.py
msgid "text_search is not yet supported." msgid "The supplied topic id {topic_id} is not valid"
msgstr "" msgstr ""
#. Translators: 'ordering' is a string describing a way #. Translators: 'ordering' is a string describing a way
...@@ -9269,6 +9286,10 @@ msgstr "सहायता" ...@@ -9269,6 +9286,10 @@ msgstr "सहायता"
msgid "Sign Out" msgid "Sign Out"
msgstr "" msgstr ""
#: common/lib/capa/capa/templates/codeinput.html
msgid "{programming_language} editor"
msgstr ""
#: common/templates/license.html #: common/templates/license.html
msgid "All Rights Reserved" msgid "All Rights Reserved"
msgstr "" msgstr ""
...@@ -11836,8 +11857,10 @@ msgid "Section:" ...@@ -11836,8 +11857,10 @@ msgid "Section:"
msgstr "धारा:" msgstr "धारा:"
#: lms/templates/courseware/legacy_instructor_dashboard.html #: lms/templates/courseware/legacy_instructor_dashboard.html
msgid "Problem urlname:" msgid ""
msgstr "समस्या का यू आर एल दें:" "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 #: lms/templates/courseware/legacy_instructor_dashboard.html
msgid "" msgid ""
...@@ -13652,6 +13675,20 @@ msgstr "" ...@@ -13652,6 +13675,20 @@ msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/data_download.html #: lms/templates/instructor/instructor_dashboard_2/data_download.html
msgid "" 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" "For smaller courses, click to list profile information for enrolled students"
" directly on this page:" " directly on this page:"
msgstr "" msgstr ""
...@@ -16013,41 +16050,50 @@ msgid "This module is not enabled." ...@@ -16013,41 +16050,50 @@ msgid "This module is not enabled."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: 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 "" msgid ""
"Upon successful completion of your course, learners receive a certificate to" "To edit or delete a certificate before it is activated, hover over the top "
" acknowledge their accomplishment. If you are a course team member with the " "right corner of the form and select {em_start}Edit{em_end} or the delete "
"Admin role in Studio, you can configure your course certificate." "icon."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "" msgid ""
"Click {em_start}Add your first certificate{em_end} to add a certificate " "To view a sample certificate, choose a course mode and select "
"configuration. Upload the organization logo to be used on the certificate, " "{em_start}Preview Certificate{em_end}."
"and specify at least one signatory. You can include up to four signatories " msgstr ""
"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 " #: cms/templates/certificates.html
"verified certificates. Optionally, specify a different course title to use " msgid "Issuing Certificates to Learners"
"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."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "" msgid ""
"Select a course mode and click {em_start}Preview Certificate{em_end} to " "To begin issuing certificates, a course team member with the Admin role "
"preview the certificate that a learner in the selected enrollment track " "selects {em_start}Activate{em_end}. Course team members without the Admin "
"would receive. When the certificate is ready for issuing, click " "role cannot edit or delete an activated certificate."
"{em_start}Activate.{em_end} To stop issuing an active certificate, click "
"{em_start}Deactivate{em_end}."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: cms/templates/certificates.html
msgid "" msgid ""
" To edit the certificate configuration, hover over the top right corner of " "{em_start}Do not{em_end} delete certificates after a course has started; "
"the form and click {em_start}Edit{em_end}. To delete a certificate, hover " "learners who have already earned certificates will no longer be able to "
"over the top right corner of the form and click the delete icon. In general," "access them."
" do not delete certificates after a course has started, because some "
"certificates might already have been issued to learners."
msgstr "" msgstr ""
#: cms/templates/certificates.html #: 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