From b5dfb5ed052efdffeb88cd0bc06dc8c12b9a4166 Mon Sep 17 00:00:00 2001 From: Dino Cikatic Date: Thu, 18 Jun 2015 22:45:54 +0200 Subject: [PATCH 01/97] Fix SEARCH_ENGINE set logic in settings files --- lms/envs/acceptance.py | 4 +++- lms/envs/aws.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 776ee30627..e932dbf34a 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -179,7 +179,9 @@ YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT) YOUTUBE['TEST_URL'] = "127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT) YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT) -if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or FEATURES.get('ENABLE_DASHBOARD_SEARCH'): +if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \ + FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \ + FEATURES.get('ENABLE_COURSE_DISCOVERY'): # Use MockSearchEngine as the search engine for test scenario SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d2f480ae26..3ab9ef5434 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -584,7 +584,9 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get( 'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM ) -if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or FEATURES.get('ENABLE_DASHBOARD_SEARCH'): +if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \ + FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \ + FEATURES.get('ENABLE_COURSE_DISCOVERY'): # Use ElasticSearch as the search engine herein SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" From f47ab2bb18e83b8bce6b86506d455d203521e33f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 18 Jun 2015 17:16:21 -0400 Subject: [PATCH 02/97] Enforce MAX_COMMENT_DEPTH in discussion_api --- lms/djangoapps/discussion_api/serializers.py | 8 ++++++-- .../discussion_api/tests/test_serializers.py | 13 +++++++++++++ .../django_comment_client/base/views.py | 11 +++++------ lms/djangoapps/django_comment_client/utils.py | 15 +++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/discussion_api/serializers.py b/lms/djangoapps/discussion_api/serializers.py index 290ec49b32..feafcc0e37 100644 --- a/lms/djangoapps/discussion_api/serializers.py +++ b/lms/djangoapps/discussion_api/serializers.py @@ -11,6 +11,7 @@ from django.core.urlresolvers import reverse from rest_framework import serializers from discussion_api.render import render_body +from django_comment_client.utils import is_comment_too_deep from django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, @@ -287,11 +288,12 @@ class CommentSerializer(_ContentSerializer): def validate(self, attrs): """ Ensure that parent_id identifies a comment that is actually in the - thread identified by thread_id. + thread identified by thread_id and does not violate the configured + maximum depth. """ + parent = None parent_id = attrs.get("parent_id") if parent_id: - parent = None try: parent = Comment(id=parent_id).retrieve() except CommentClientRequestError: @@ -300,6 +302,8 @@ class CommentSerializer(_ContentSerializer): raise ValidationError( "parent_id does not identify a comment in the thread identified by thread_id." ) + if is_comment_too_deep(parent): + raise ValidationError({"parent_id": ["Parent is too deep."]}) return attrs def restore_object(self, attrs, instance=None): diff --git a/lms/djangoapps/discussion_api/tests/test_serializers.py b/lms/djangoapps/discussion_api/tests/test_serializers.py index 813119d8f6..0829222d06 100644 --- a/lms/djangoapps/discussion_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion_api/tests/test_serializers.py @@ -644,6 +644,19 @@ class CommentSerializerDeserializationTest(CommentsServiceMockMixin, ModuleStore } ) + def test_create_parent_id_too_deep(self): + self.register_get_comment_response({ + "id": "test_parent", + "thread_id": "test_thread", + "depth": 2 + }) + data = self.minimal_data.copy() + data["parent_id"] = "test_parent" + context = get_context(self.course, self.request, make_minimal_cs_thread()) + serializer = CommentSerializer(data=data, context=context) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, {"parent_id": ["Parent is too deep."]}) + def test_create_missing_field(self): for field in self.minimal_data: data = self.minimal_data.copy() diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 10e98655e1..bfe0cc7715 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -22,6 +22,7 @@ from django_comment_client.utils import ( add_courseware_context, get_annotated_content_info, get_ability, + is_comment_too_deep, JsonError, JsonResponse, prepare_content, @@ -313,9 +314,8 @@ def create_comment(request, course_id, thread_id): given a course_id and thread_id, test for comment depth. if not too deep, call _create_comment to create the actual comment. """ - if cc_settings.MAX_COMMENT_DEPTH is not None: - if cc_settings.MAX_COMMENT_DEPTH < 0: - return JsonError(_("Comment level too deep")) + if is_comment_too_deep(parent=None): + return JsonError(_("Comment level too deep")) return _create_comment(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), thread_id=thread_id) @@ -397,9 +397,8 @@ def create_sub_comment(request, course_id, comment_id): given a course_id and comment_id, create a response to a comment after checking the max depth allowed, if allowed """ - if cc_settings.MAX_COMMENT_DEPTH is not None: - if cc_settings.MAX_COMMENT_DEPTH <= cc.Comment.find(comment_id).depth: - return JsonError(_("Comment level too deep")) + if is_comment_too_deep(parent=cc.Comment(comment_id)): + return JsonError(_("Comment level too deep")) return _create_comment(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), parent_id=comment_id) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 153a3b2b12..7d8e2cf079 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -16,6 +16,7 @@ from xmodule.modulestore.django import modulestore from django_comment_common.models import Role, FORUM_ROLE_STUDENT from django_comment_client.permissions import check_permissions_by_view, has_permission +from django_comment_client.settings import MAX_COMMENT_DEPTH from edxmako import lookup_template from courseware.access import has_access @@ -568,3 +569,17 @@ def get_group_id_for_comments_service(request, course_key, commentable_id=None): # Never pass a group_id to the comments service for a non-cohorted # commentable return None + + +def is_comment_too_deep(parent): + """ + Determine whether a comment with the given parent violates MAX_COMMENT_DEPTH + + parent can be None to determine whether root comments are allowed + """ + return ( + MAX_COMMENT_DEPTH is not None and ( + MAX_COMMENT_DEPTH < 0 or + (parent and parent["depth"] >= MAX_COMMENT_DEPTH) + ) + ) From 843c073f4fa1f1b3d685295b89daddf3179a6f42 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 16 Jun 2015 16:29:38 -0400 Subject: [PATCH 03/97] Re-nest for the signal-sending, then un-nest. Change publish signal test to reflect new behavior. --- common/lib/xmodule/xmodule/modulestore/__init__.py | 10 +++++++++- .../modulestore/tests/test_mixed_modulestore.py | 7 +++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index c167c25422..6949472011 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -272,12 +272,20 @@ class BulkOperationsMixin(object): dirty = self._end_outermost_bulk_operation(bulk_ops_record, structure_key) - self._clear_bulk_ops_record(structure_key) + # The bulk op has ended. However, the signal tasks below still need to use the + # built-up bulk op information (if the signals trigger tasks in the same thread). + # So re-nest until the signals are sent. + bulk_ops_record.nest() if emit_signals and dirty: self.send_bulk_published_signal(bulk_ops_record, structure_key) self.send_bulk_library_updated_signal(bulk_ops_record, structure_key) + # Signals are sent. Now unnest and clear the bulk op for good. + bulk_ops_record.unnest() + + self._clear_bulk_ops_record(structure_key) + def _is_in_bulk_operation(self, course_key, ignore_case=False): """ Return whether a bulk operation is active on `course_key`. diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index b41bf268d6..dfafc920e1 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -2015,8 +2015,11 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): course_key = course.id def _clear_bulk_ops_record(course_key): # pylint: disable=unused-argument - """ Check if the signal has been fired. """ - self.assertEqual(receiver.call_count, 0) + """ + Check if the signal has been fired. + The course_published signal fires before the _clear_bulk_ops_record. + """ + self.assertEqual(receiver.call_count, 1) with patch.object( self.store.thread_cache.default_store, '_clear_bulk_ops_record', wraps=_clear_bulk_ops_record From bd05dc0b71944f491eabed0b3b358094b0bf6617 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Wed, 17 Jun 2015 09:44:43 -0400 Subject: [PATCH 04/97] Wrap course structure generation task in bulk operation. --- .../content/course_structures/tasks.py | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/openedx/core/djangoapps/content/course_structures/tasks.py b/openedx/core/djangoapps/content/course_structures/tasks.py index f621cbe25f..5ed989be33 100644 --- a/openedx/core/djangoapps/content/course_structures/tasks.py +++ b/openedx/core/djangoapps/content/course_structures/tasks.py @@ -13,38 +13,40 @@ def _generate_course_structure(course_key): """ Generates a course structure dictionary for the specified course. """ - course = modulestore().get_course(course_key, depth=None) - blocks_stack = [course] - blocks_dict = {} - while blocks_stack: - curr_block = blocks_stack.pop() - children = curr_block.get_children() if curr_block.has_children else [] - key = unicode(curr_block.scope_ids.usage_id) - block = { - "usage_key": key, - "block_type": curr_block.category, - "display_name": curr_block.display_name, - "children": [unicode(child.scope_ids.usage_id) for child in children] + with modulestore().bulk_operations(course_key): + course = modulestore().get_course(course_key, depth=None) + blocks_stack = [course] + blocks_dict = {} + while blocks_stack: + curr_block = blocks_stack.pop() + children = curr_block.get_children() if curr_block.has_children else [] + key = unicode(curr_block.scope_ids.usage_id) + block = { + "usage_key": key, + "block_type": curr_block.category, + "display_name": curr_block.display_name, + "children": [unicode(child.scope_ids.usage_id) for child in children] + } + + # Retrieve these attributes separately so that we can fail gracefully + # if the block doesn't have the attribute. + attrs = (('graded', False), ('format', None)) + for attr, default in attrs: + if hasattr(curr_block, attr): + block[attr] = getattr(curr_block, attr, default) + else: + log.warning('Failed to retrieve %s attribute of block %s. Defaulting to %s.', attr, key, default) + block[attr] = default + + blocks_dict[key] = block + + # Add this blocks children to the stack so that we can traverse them as well. + blocks_stack.extend(children) + return { + "root": unicode(course.scope_ids.usage_id), + "blocks": blocks_dict } - # Retrieve these attributes separately so that we can fail gracefully if the block doesn't have the attribute. - attrs = (('graded', False), ('format', None)) - for attr, default in attrs: - if hasattr(curr_block, attr): - block[attr] = getattr(curr_block, attr, default) - else: - log.warning('Failed to retrieve %s attribute of block %s. Defaulting to %s.', attr, key, default) - block[attr] = default - - blocks_dict[key] = block - - # Add this blocks children to the stack so that we can traverse them as well. - blocks_stack.extend(children) - return { - "root": unicode(course.scope_ids.usage_id), - "blocks": blocks_dict - } - @task(name=u'openedx.core.djangoapps.content.course_structures.tasks.update_course_structure') def update_course_structure(course_key): From 7fcf4e3d8dfacc8f4f1adfa65812995d6bf8e29c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 19 Jun 2015 12:48:11 -0400 Subject: [PATCH 05/97] fixup! Enforce MAX_COMMENT_DEPTH in discussion_api Add more extensive testing --- lms/djangoapps/discussion_api/serializers.py | 2 +- .../discussion_api/tests/test_serializers.py | 42 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/lms/djangoapps/discussion_api/serializers.py b/lms/djangoapps/discussion_api/serializers.py index feafcc0e37..45f8d3968a 100644 --- a/lms/djangoapps/discussion_api/serializers.py +++ b/lms/djangoapps/discussion_api/serializers.py @@ -303,7 +303,7 @@ class CommentSerializer(_ContentSerializer): "parent_id does not identify a comment in the thread identified by thread_id." ) if is_comment_too_deep(parent): - raise ValidationError({"parent_id": ["Parent is too deep."]}) + raise ValidationError({"parent_id": ["Comment level is too deep."]}) return attrs def restore_object(self, attrs, instance=None): diff --git a/lms/djangoapps/discussion_api/tests/test_serializers.py b/lms/djangoapps/discussion_api/tests/test_serializers.py index 0829222d06..48a538ccb5 100644 --- a/lms/djangoapps/discussion_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion_api/tests/test_serializers.py @@ -644,18 +644,36 @@ class CommentSerializerDeserializationTest(CommentsServiceMockMixin, ModuleStore } ) - def test_create_parent_id_too_deep(self): - self.register_get_comment_response({ - "id": "test_parent", - "thread_id": "test_thread", - "depth": 2 - }) - data = self.minimal_data.copy() - data["parent_id"] = "test_parent" - context = get_context(self.course, self.request, make_minimal_cs_thread()) - serializer = CommentSerializer(data=data, context=context) - self.assertFalse(serializer.is_valid()) - self.assertEqual(serializer.errors, {"parent_id": ["Parent is too deep."]}) + @ddt.data(None, -1, 0, 2, 5) + def test_create_parent_id_too_deep(self, max_depth): + with mock.patch("django_comment_client.utils.MAX_COMMENT_DEPTH", max_depth): + data = self.minimal_data.copy() + context = get_context(self.course, self.request, make_minimal_cs_thread()) + if max_depth is None or max_depth >= 0: + if max_depth != 0: + self.register_get_comment_response({ + "id": "not_too_deep", + "thread_id": "test_thread", + "depth": max_depth - 1 if max_depth else 100 + }) + data["parent_id"] = "not_too_deep" + else: + data["parent_id"] = None + serializer = CommentSerializer(data=data, context=context) + self.assertTrue(serializer.is_valid(), serializer.errors) + if max_depth is not None: + if max_depth >= 0: + self.register_get_comment_response({ + "id": "too_deep", + "thread_id": "test_thread", + "depth": max_depth + }) + data["parent_id"] = "too_deep" + else: + data["parent_id"] = None + serializer = CommentSerializer(data=data, context=context) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, {"parent_id": ["Comment level is too deep."]}) def test_create_missing_field(self): for field in self.minimal_data: From 1281532be52f8dc7303bf101ddda3c90f6bdca5e Mon Sep 17 00:00:00 2001 From: Clinton Blackburn Date: Fri, 19 Jun 2015 01:19:36 -0400 Subject: [PATCH 06/97] Updated Payment Buttons The payment buttons now match the style of the verified upgrade buttons. XCOM-422 --- lms/static/js/verify_student/views/make_payment_step_view.js | 5 ++++- lms/static/sass/_developer.scss | 2 +- lms/static/sass/views/_verification.scss | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lms/static/js/verify_student/views/make_payment_step_view.js b/lms/static/js/verify_student/views/make_payment_step_view.js index 532534970b..68f92a11ce 100644 --- a/lms/static/js/verify_student/views/make_payment_step_view.js +++ b/lms/static/js/verify_student/views/make_payment_step_view.js @@ -68,6 +68,9 @@ var edx = edx || {}; templateContext.requirements, function( isVisible ) { return isVisible; } ), + // This a hack to appease /lms/static/js/spec/verify_student/pay_and_verify_view_spec.js, + // which does not load an actual template context. + processors = templateContext.processors || [], self = this; // Track a virtual pageview, for easy funnel reconstruction. @@ -100,7 +103,7 @@ var edx = edx || {}; ); // create a button for each payment processor - _.each(templateContext.processors, function(processorName) { + _.each(processors.reverse(), function(processorName) { $( 'div.payment-buttons' ).append( self._getPaymentButtonHtml(processorName) ); }); diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 0964b4dcc8..7b5448dacf 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -69,7 +69,7 @@ @include margin-left( ($baseline/2) ); &.is-selected { - background: $m-blue-d3 !important; + background: $m-green-s1 !important; } } } diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index b9a00db98c..14444798e3 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -165,7 +165,7 @@ // elements - controls .action-primary { - @extend %btn-primary-blue; + @extend %btn-verify-primary; // needed for override due to .register a:link styling border: 0 !important; color: $white !important; From d779466f09500a19f7c67fa409a10dfcd98418bd Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 19 Jun 2015 10:11:14 -0400 Subject: [PATCH 07/97] Make course tab refreshing safer. --- .../tests/test_course_settings.py | 51 ++++++++++++------- cms/djangoapps/contentstore/views/course.py | 26 ++++++++-- lms/djangoapps/course_wiki/tab.py | 1 + lms/djangoapps/courseware/tabs.py | 6 +++ lms/djangoapps/courseware/tests/test_tabs.py | 1 + .../django_comment_client/forum/views.py | 1 + 6 files changed, 65 insertions(+), 21 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2ecb83f802..31afe010d5 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -19,6 +19,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata from xmodule.fields import Date +from xmodule.tabs import InvalidTabsException from .utils import CourseTestCase from xmodule.modulestore.django import modulestore @@ -617,6 +618,7 @@ class CourseGradingTest(CourseTestCase): self.assertEqual(json.loads(response.content).get('graderType'), u'notgraded') +@ddt.ddt class CourseMetadataEditingTest(CourseTestCase): """ Tests for CourseMetadata. @@ -626,6 +628,7 @@ class CourseMetadataEditingTest(CourseTestCase): self.fullcourse = CourseFactory.create() self.course_setting_url = get_url(self.course.id, 'advanced_settings_handler') self.fullcourse_setting_url = get_url(self.fullcourse.id, 'advanced_settings_handler') + self.notes_tab = {"type": "notes", "name": "My Notes"} def test_fetch_initial_fields(self): test_model = CourseMetadata.fetch(self.course) @@ -930,12 +933,11 @@ class CourseMetadataEditingTest(CourseTestCase): """ open_ended_tab = {"type": "open_ended", "name": "Open Ended Panel"} peer_grading_tab = {"type": "peer_grading", "name": "Peer grading"} - notes_tab = {"type": "notes", "name": "My Notes"} # First ensure that none of the tabs are visible self.assertNotIn(open_ended_tab, self.course.tabs) self.assertNotIn(peer_grading_tab, self.course.tabs) - self.assertNotIn(notes_tab, self.course.tabs) + self.assertNotIn(self.notes_tab, self.course.tabs) # Now add the "combinedopenended" component and verify that the tab has been added self.client.ajax_post(self.course_setting_url, { @@ -944,7 +946,7 @@ class CourseMetadataEditingTest(CourseTestCase): course = modulestore().get_course(self.course.id) self.assertIn(open_ended_tab, course.tabs) self.assertIn(peer_grading_tab, course.tabs) - self.assertNotIn(notes_tab, course.tabs) + self.assertNotIn(self.notes_tab, course.tabs) # Now enable student notes and verify that the "My Notes" tab has also been added self.client.ajax_post(self.course_setting_url, { @@ -953,7 +955,7 @@ class CourseMetadataEditingTest(CourseTestCase): course = modulestore().get_course(self.course.id) self.assertIn(open_ended_tab, course.tabs) self.assertIn(peer_grading_tab, course.tabs) - self.assertIn(notes_tab, course.tabs) + self.assertIn(self.notes_tab, course.tabs) # Now remove the "combinedopenended" component and verify that the tab is gone self.client.ajax_post(self.course_setting_url, { @@ -962,7 +964,7 @@ class CourseMetadataEditingTest(CourseTestCase): course = modulestore().get_course(self.course.id) self.assertNotIn(open_ended_tab, course.tabs) self.assertNotIn(peer_grading_tab, course.tabs) - self.assertIn(notes_tab, course.tabs) + self.assertIn(self.notes_tab, course.tabs) # Finally disable student notes and verify that the "My Notes" tab is gone self.client.ajax_post(self.course_setting_url, { @@ -971,25 +973,40 @@ class CourseMetadataEditingTest(CourseTestCase): course = modulestore().get_course(self.course.id) self.assertNotIn(open_ended_tab, course.tabs) self.assertNotIn(peer_grading_tab, course.tabs) - self.assertNotIn(notes_tab, course.tabs) + self.assertNotIn(self.notes_tab, course.tabs) - def mark_wiki_as_hidden(self, tabs): - """ Mark the wiki tab as hidden. """ - for tab in tabs: - if tab.type == 'wiki': - tab['is_hidden'] = True - return tabs + def test_advanced_components_munge_tabs_validation_failure(self): + with patch('contentstore.views.course._refresh_course_tabs', side_effect=InvalidTabsException): + resp = self.client.ajax_post(self.course_setting_url, { + ADVANCED_COMPONENT_POLICY_KEY: {"value": ["notes"]} + }) + self.assertEqual(resp.status_code, 400) - def test_advanced_components_munge_tabs_hidden_tabs(self): - updated_tabs = self.mark_wiki_as_hidden(self.course.tabs) - self.course.tabs = updated_tabs + error_msg = [ + { + 'message': 'An error occurred while trying to save your tabs', + 'model': {'display_name': 'Tabs Exception'} + } + ] + self.assertEqual(json.loads(resp.content), error_msg) + + # verify that the course wasn't saved into the modulestore + course = modulestore().get_course(self.course.id) + self.assertNotIn("notes", course.advanced_modules) + + @ddt.data( + [{'type': 'courseware'}, {'type': 'course_info'}, {'type': 'wiki', 'is_hidden': True}], + [{'type': 'courseware', 'name': 'Courses'}, {'type': 'course_info', 'name': 'Info'}], + ) + def test_course_tab_configurations(self, tab_list): + self.course.tabs = tab_list modulestore().update_item(self.course, self.user.id) self.client.ajax_post(self.course_setting_url, { ADVANCED_COMPONENT_POLICY_KEY: {"value": ["notes"]} }) course = modulestore().get_course(self.course.id) - notes_tab = {"type": "notes", "name": "My Notes"} - self.assertIn(notes_tab, course.tabs) + tab_list.append(self.notes_tab) + self.assertEqual(tab_list, course.tabs) class CourseGraderUpdatesTest(CourseTestCase): diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 10ef8f2167..becfcfffdf 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -5,6 +5,7 @@ import copy from django.shortcuts import redirect import json import random +import logging import string # pylint: disable=deprecated-module from django.utils.translation import ugettext as _ import django.utils @@ -22,7 +23,7 @@ from xmodule.course_module import DEFAULT_START_DATE from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent -from xmodule.tabs import CourseTab +from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException from openedx.core.lib.course_tabs import CourseTabPluginManager from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements @@ -87,6 +88,8 @@ from util.milestones_helpers import ( is_valid_course_key ) +log = logging.getLogger(__name__) + __all__ = ['course_info_handler', 'course_handler', 'course_listing', 'course_info_update_handler', 'course_search_index_handler', 'course_rerun_handler', @@ -1024,6 +1027,9 @@ def grading_handler(request, course_key_string, grader_index=None): def _refresh_course_tabs(request, course_module): """ Automatically adds/removes tabs if changes to the course require them. + + Raises: + InvalidTabsException: raised if there's a problem with the new version of the tabs. """ def update_tab(tabs, tab_type, tab_enabled): @@ -1047,6 +1053,8 @@ def _refresh_course_tabs(request, course_module): tab_enabled = tab_type.is_enabled(course_module, user=request.user) update_tab(course_tabs, tab_type, tab_enabled) + CourseTabList.validate_tabs(course_tabs) + # Save the tabs into the course if they have been changed if course_tabs != course_module.tabs: course_module.tabs = course_tabs @@ -1090,8 +1098,18 @@ def advanced_settings_handler(request, course_key_string): ) if is_valid: - # update the course tabs if required by any setting changes - _refresh_course_tabs(request, course_module) + try: + # update the course tabs if required by any setting changes + _refresh_course_tabs(request, course_module) + except InvalidTabsException as err: + log.exception(err.message) + response_message = [ + { + 'message': _('An error occurred while trying to save your tabs'), + 'model': {'display_name': _('Tabs Exception')} + } + ] + return JsonResponseBadRequest(response_message) # now update mongo modulestore().update_item(course_module, request.user.id) @@ -1101,7 +1119,7 @@ def advanced_settings_handler(request, course_key_string): return JsonResponseBadRequest(errors) # Handle all errors that validation doesn't catch - except (TypeError, ValueError) as err: + except (TypeError, ValueError, InvalidTabsException) as err: return HttpResponseBadRequest( django.utils.html.escape(err.message), content_type="text/plain" diff --git a/lms/djangoapps/course_wiki/tab.py b/lms/djangoapps/course_wiki/tab.py index eecc082dcb..cf681eaa4a 100644 --- a/lms/djangoapps/course_wiki/tab.py +++ b/lms/djangoapps/course_wiki/tab.py @@ -18,6 +18,7 @@ class WikiTab(EnrolledTab): title = _('Wiki') view_name = "course_wiki" is_hideable = True + is_default = False @classmethod def is_enabled(cls, course, user=None): diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 94b4b25d88..cc2d6916d8 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -32,6 +32,7 @@ class CoursewareTab(EnrolledTab): priority = 10 view_name = 'courseware' is_movable = False + is_default = False class CourseInfoTab(CourseTab): @@ -44,6 +45,7 @@ class CourseInfoTab(CourseTab): view_name = 'info' tab_id = 'info' is_movable = False + is_default = False @classmethod def is_enabled(cls, course, user=None): @@ -59,6 +61,7 @@ class SyllabusTab(EnrolledTab): priority = 30 view_name = 'syllabus' allow_multiple = True + is_default = False @classmethod def is_enabled(cls, course, user=None): # pylint: disable=unused-argument @@ -76,6 +79,7 @@ class ProgressTab(EnrolledTab): priority = 40 view_name = 'progress' is_hideable = True + is_default = False @classmethod def is_enabled(cls, course, user=None): # pylint: disable=unused-argument @@ -91,6 +95,7 @@ class TextbookTabsBase(CourseTab): # Translators: 'Textbooks' refers to the tab in the course that leads to the course' textbooks title = _("Textbooks") is_collection = True + is_default = False @classmethod def is_enabled(cls, course, user=None): # pylint: disable=unused-argument @@ -222,6 +227,7 @@ class ExternalDiscussionCourseTab(LinkTab): # Translators: 'Discussion' refers to the tab in the courseware that leads to the discussion forums title = _('Discussion') priority = None + is_default = False @classmethod def validate(cls, tab_dict, raise_error=True): diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 9e52a73c72..b50d4d793a 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -480,6 +480,7 @@ class TabListTestCase(TabTestCase): [{'type': CoursewareTab.type}, {'type': 'discussion', 'name': 'fake_name'}], # incorrect order [{'type': CourseInfoTab.type, 'name': 'fake_name'}, {'type': CoursewareTab.type}], + [{'type': 'unknown_type'}] ] # tab types that should appear only once diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 9c9d362f37..8470cb0b36 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -59,6 +59,7 @@ class DiscussionTab(EnrolledTab): priority = None view_name = 'django_comment_client.forum.views.forum_form_discussion' is_hideable = settings.FEATURES.get('ALLOW_HIDING_DISCUSSION_TAB', False) + is_default = False @classmethod def is_enabled(cls, course, user=None): From 3a03a0e9408f531a38832025d66f50b63ef78244 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Fri, 19 Jun 2015 16:31:25 -0400 Subject: [PATCH 08/97] adjust margin-bottom on courseware with license --- lms/static/sass/base/_layouts.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/static/sass/base/_layouts.scss b/lms/static/sass/base/_layouts.scss index 6fa237f7b0..d4579becdc 100644 --- a/lms/static/sass/base/_layouts.scss +++ b/lms/static/sass/base/_layouts.scss @@ -57,7 +57,7 @@ body.view-incourse { .course-wrapper, .profile-wrapper { max-width: 1180px; - margin: 0 auto ($baseline*2) auto; + margin: 0 auto; padding: 0; } @@ -77,6 +77,7 @@ body.view-incourse { // site footer .wrapper-footer { + margin-top: $baseline; padding-right: 2%; padding-left: 2%; From 59e0d22fc335b51ce2306cce33c1e14a51487c92 Mon Sep 17 00:00:00 2001 From: utkjad Date: Fri, 29 May 2015 20:49:22 +0000 Subject: [PATCH 09/97] Adding call stack manager --- cms/envs/test.py | 2 +- lms/djangoapps/courseware/models.py | 11 +- lms/envs/test.py | 2 +- .../djangoapps/call_stack_manager/__init__.py | 5 + .../djangoapps/call_stack_manager/core.py | 144 ++++++++++++ .../djangoapps/call_stack_manager/models.py | 8 + .../djangoapps/call_stack_manager/tests.py | 215 ++++++++++++++++++ 7 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 openedx/core/djangoapps/call_stack_manager/__init__.py create mode 100644 openedx/core/djangoapps/call_stack_manager/core.py create mode 100644 openedx/core/djangoapps/call_stack_manager/models.py create mode 100644 openedx/core/djangoapps/call_stack_manager/tests.py diff --git a/cms/envs/test.py b/cms/envs/test.py index b637368c9f..08e5fe73ed 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -173,7 +173,7 @@ CACHES = { INSTALLED_APPS += ('external_auth', ) # Add milestones to Installed apps for testing -INSTALLED_APPS += ('milestones', ) +INSTALLED_APPS += ('milestones', 'openedx.core.djangoapps.call_stack_manager') # hide ratelimit warnings while running tests filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit') diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 3c2cd72026..ac7b72520b 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -26,6 +26,7 @@ from student.models import user_by_anonymous_id from submissions.models import score_set, score_reset from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField # pylint: disable=import-error +log = logging.getLogger(__name__) log = logging.getLogger("edx.courseware") @@ -72,7 +73,6 @@ class StudentModule(models.Model): Keeps student state for a particular module in a particular course. """ objects = ChunkingManager() - MODEL_TAGS = ['course_id', 'module_type'] # For a homework problem, contains a JSON @@ -96,10 +96,10 @@ class StudentModule(models.Model): class Meta(object): # pylint: disable=missing-docstring unique_together = (('student', 'module_state_key', 'course_id'),) - ## Internal state of the object + # Internal state of the object state = models.TextField(null=True, blank=True) - ## Grade, and are we done? + # Grade, and are we done? grade = models.FloatField(null=True, blank=True, db_index=True) max_grade = models.FloatField(null=True, blank=True) DONE_TYPES = ( @@ -146,7 +146,6 @@ class StudentModuleHistory(models.Model): """Keeps a complete history of state changes for a given XModule for a given Student. Right now, we restrict this to problems so that the table doesn't explode in size.""" - HISTORY_SAVING_TYPES = {'problem'} class Meta(object): # pylint: disable=missing-docstring @@ -211,7 +210,6 @@ class XModuleUserStateSummaryField(XBlockFieldBase): """ Stores data set in the Scope.user_state_summary scope by an xmodule field """ - class Meta(object): # pylint: disable=missing-docstring unique_together = (('usage_id', 'field_name'),) @@ -223,7 +221,6 @@ class XModuleStudentPrefsField(XBlockFieldBase): """ Stores data set in the Scope.preferences scope by an xmodule field """ - class Meta(object): # pylint: disable=missing-docstring unique_together = (('student', 'module_type', 'field_name'),) @@ -237,10 +234,8 @@ class XModuleStudentInfoField(XBlockFieldBase): """ Stores data set in the Scope.preferences scope by an xmodule field """ - class Meta(object): # pylint: disable=missing-docstring unique_together = (('student', 'field_name'),) - student = models.ForeignKey(User, db_index=True) diff --git a/lms/envs/test.py b/lms/envs/test.py index 5a81e3cca2..d62756b8c3 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -457,7 +457,7 @@ FEATURES['ENABLE_EDXNOTES'] = True FEATURES['ENABLE_TEAMS'] = True # Add milestones to Installed apps for testing -INSTALLED_APPS += ('milestones', ) +INSTALLED_APPS += ('milestones', 'openedx.core.djangoapps.call_stack_manager') # Enable courseware search for tests FEATURES['ENABLE_COURSEWARE_SEARCH'] = True diff --git a/openedx/core/djangoapps/call_stack_manager/__init__.py b/openedx/core/djangoapps/call_stack_manager/__init__.py new file mode 100644 index 0000000000..5682284fa2 --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/__init__.py @@ -0,0 +1,5 @@ +""" +Root Package for getting call stacks of various Model classes being used +""" +from __future__ import absolute_import +from .core import CallStackManager, CallStackMixin, donottrack diff --git a/openedx/core/djangoapps/call_stack_manager/core.py b/openedx/core/djangoapps/call_stack_manager/core.py new file mode 100644 index 0000000000..98a63e8dbd --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/core.py @@ -0,0 +1,144 @@ +""" +Get call stacks of Model Class +in three cases- +1. QuerySet API +2. save() +3. delete() + +classes: +CallStackManager - stores all stacks in global dictionary and logs +CallStackMixin - used for Model save(), and delete() method + +Functions: +capture_call_stack - global function used to store call stack + +Decorators: +donottrack - mainly for the places where we know the calls. This decorator will let us not to track in specified cases + +How to use- +1. Import following in the file where class to be tracked resides + from openedx.core.djangoapps.call_stack_manager import CallStackManager, CallStackMixin +2. Override objects of default manager by writing following in any model class which you want to track- + objects = CallStackManager() +3. For tracking Save and Delete events- + Use mixin called "CallStackMixin" + For ex. + class StudentModule(CallStackMixin, models.Model): +4. Decorator is a parameterized decorator with class name/s as argument + How to use - + 1. Import following + import from openedx.core.djangoapps.call_stack_manager import donottrack +""" + +import logging +import traceback +import re +import collections +from django.db.models import Manager + +log = logging.getLogger(__name__) + +# list of regular expressions acting as filters +REGULAR_EXPS = [re.compile(x) for x in ['^.*python2.7.*$', '^.*.*$', '^.*exec_code_object.*$', + '^.*edxapp/src.*$', '^.*call_stack_manager.*$']] +# Variable which decides whether to track calls in the function or not. Do it by default. +TRACK_FLAG = True + +# List keeping track of Model classes not be tracked for special cases +# usually cases where we know that the function is calling Model classes. +HALT_TRACKING = [] + +# Module Level variables +# dictionary which stores call stacks. +# { "ModelClasses" : [ListOfFrames]} +# Frames - ('FilePath','LineNumber','Context') +# ex. {"" : [[(file,line number,context),(---,---,---)], +# [(file,line number,context),(---,---,---)]]} +STACK_BOOK = collections.defaultdict(list) + + +def capture_call_stack(current_model): + """ logs customised call stacks in global dictionary `STACK_BOOK`, and logs it. + + Args: + current_model - Name of the model class + """ + # holds temporary callstack + # frame[0][6:-1] -> File name along with path + # frame[1][6:] -> Line Number + # frame[2][3:] -> Context + temp_call_stack = [(frame[0][6:-1], + frame[1][6:], + frame[2][3:]) + for frame in [stack.replace("\n", "").strip().split(',') for stack in traceback.format_stack()] + if not any(reg.match(frame[0]) for reg in REGULAR_EXPS)] + + # avoid duplication. + if temp_call_stack not in STACK_BOOK[current_model] and TRACK_FLAG \ + and not issubclass(current_model, tuple(HALT_TRACKING)): + STACK_BOOK[current_model].append(temp_call_stack) + log.info("logging new call stack for %s:\n %s", current_model, temp_call_stack) + + +class CallStackMixin(object): + """ A mixin class for getting call stacks when Save() and Delete() methods are called + """ + + def save(self, *args, **kwargs): + """ + Logs before save and overrides respective model API save() + """ + capture_call_stack(type(self)) + return super(CallStackMixin, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """ + Logs before delete and overrides respective model API delete() + """ + capture_call_stack(type(self)) + return super(CallStackMixin, self).delete(*args, **kwargs) + + +class CallStackManager(Manager): + """ A Manager class which overrides the default Manager class for getting call stacks + """ + def get_query_set(self): + """overriding the default queryset API method + """ + capture_call_stack(self.model) + return super(CallStackManager, self).get_query_set() + + +def donottrack(*classes_not_to_be_tracked): + """function decorator which deals with toggling call stack + Args: + classes_not_to_be_tracked: model classes where tracking is undesirable + Returns: + wrapped function + """ + + def real_donottrack(function): + """takes function to be decorated and returns wrapped function + + Args: + function - wrapped function i.e. real_donottrack + """ + def wrapper(*args, **kwargs): + """ wrapper function for decorated function + Returns: + wrapper function i.e. wrapper + """ + if len(classes_not_to_be_tracked) == 0: + global TRACK_FLAG # pylint: disable=W0603 + current_flag = TRACK_FLAG + TRACK_FLAG = False + function(*args, **kwargs) + TRACK_FLAG = current_flag + else: + global HALT_TRACKING # pylint: disable=W0603 + current_halt_track = HALT_TRACKING + HALT_TRACKING = classes_not_to_be_tracked + function(*args, **kwargs) + HALT_TRACKING = current_halt_track + return wrapper + return real_donottrack diff --git a/openedx/core/djangoapps/call_stack_manager/models.py b/openedx/core/djangoapps/call_stack_manager/models.py new file mode 100644 index 0000000000..4069b5477d --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/models.py @@ -0,0 +1,8 @@ +""" +Dummy models.py file + +Note - +django-nose loads models for tests, but only if the django app that the test is contained in has models itself. +This file is empty so that the unit tests can have models. +For call_stack_manager - models specific to tests are defined in tests.py +""" diff --git a/openedx/core/djangoapps/call_stack_manager/tests.py b/openedx/core/djangoapps/call_stack_manager/tests.py new file mode 100644 index 0000000000..7112e01ec5 --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/tests.py @@ -0,0 +1,215 @@ +""" +Test cases for Call Stack Manager +""" +from mock import patch +from django.db import models +from django.test import TestCase + +from openedx.core.djangoapps.call_stack_manager import donottrack, CallStackManager, CallStackMixin + + +class ModelMixinCallStckMngr(CallStackMixin, models.Model): + """ + Test Model class which uses both CallStackManager, and CallStackMixin + """ + # override Manager objects + objects = CallStackManager() + id_field = models.IntegerField() + + +class ModelMixin(CallStackMixin, models.Model): + """ + Test Model that uses CallStackMixin but does not use CallStackManager + """ + id_field = models.IntegerField() + + +class ModelNothingCallStckMngr(models.Model): + """ + Test Model class that neither uses CallStackMixin nor CallStackManager + """ + id_field = models.IntegerField() + + +class ModelAnotherCallStckMngr(models.Model): + """ + Test Model class that only uses overridden Manager CallStackManager + """ + objects = CallStackManager() + id_field = models.IntegerField() + + +class ModelWithCallStackMngr(models.Model): + """ + Test Model Class with overridden CallStackManager + """ + objects = CallStackManager() + id_field = models.IntegerField() + + +class ModelWithCallStckMngrChild(ModelWithCallStackMngr): + """child class of ModelWithCallStackMngr + """ + objects = CallStackManager() + child_id_field = models.IntegerField() + + +@donottrack(ModelWithCallStackMngr) +def donottrack_subclass(): + """ function in which subclass and superclass calls QuerySetAPI + """ + ModelWithCallStackMngr.objects.filter(id_field=1) + ModelWithCallStckMngrChild.objects.filter(child_id_field=1) + + +def track_without_donottrack(): + """ function calling QuerySetAPI, another function, again QuerySetAPI + """ + ModelAnotherCallStckMngr.objects.filter(id_field=1) + donottrack_child_func() + ModelAnotherCallStckMngr.objects.filter(id_field=1) + + +@donottrack(ModelAnotherCallStckMngr) +def donottrack_child_func(): + """ decorated child function + """ + # should not be tracked + ModelAnotherCallStckMngr.objects.filter(id_field=1) + + # should be tracked + ModelMixinCallStckMngr.objects.filter(id_field=1) + + +@donottrack(ModelMixinCallStckMngr) +def donottrack_parent_func(): + """ decorated parent function + """ + # should not be tracked + ModelMixinCallStckMngr.objects.filter(id_field=1) + # should be tracked + ModelAnotherCallStckMngr.objects.filter(id_field=1) + donottrack_child_func() + + +@donottrack() +def donottrack_func_parent(): + """ non-parameterized @donottrack decorated function calling child function + """ + ModelMixin.objects.all() + donottrack_func_child() + ModelMixin.objects.filter(id_field=1) + + +@donottrack() +def donottrack_func_child(): + """ child decorated non-parameterized function + """ + # Should not be tracked + ModelMixin.objects.all() + + +@patch('openedx.core.djangoapps.call_stack_manager.core.log.info') +@patch('openedx.core.djangoapps.call_stack_manager.core.REGULAR_EXPS', []) +class TestingCallStackManager(TestCase): + """Tests for call_stack_manager + 1. Tests CallStackManager QuerySetAPI functionality + 2. Tests @donottrack decorator + """ + def test_save(self, log_capt): + """ tests save() of CallStackMixin/ applies same for delete() + classes with CallStackMixin should participate in logging. + """ + ModelMixin(id_field=1).save() + self.assertEqual(ModelMixin, log_capt.call_args[0][1]) + + def test_withoutmixin_save(self, log_capt): + """tests save() of CallStackMixin/ applies same for delete() + classes without CallStackMixin should not participate in logging + """ + ModelAnotherCallStckMngr(id_field=1).save() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_queryset(self, log_capt): + """ Tests for Overriding QuerySet API + classes with CallStackManager should get logged. + """ + ModelAnotherCallStckMngr(id_field=1).save() + ModelAnotherCallStckMngr.objects.all() + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args[0][1]) + + def test_withoutqueryset(self, log_capt): + """ Tests for Overriding QuerySet API + classes without CallStackManager should not get logged + """ + # create and save objects of class not overriding queryset API + ModelNothingCallStckMngr(id_field=1).save() + # class not using Manager, should not get logged + ModelNothingCallStckMngr.objects.all() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_donottrack(self, log_capt): + """ Test for @donottrack + calls in decorated function should not get logged + """ + donottrack_func_parent() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_parameterized_donottrack(self, log_capt): + """ Test for parameterized @donottrack + classes specified in the decorator @donottrack should not get logged + """ + ModelAnotherCallStckMngr(id_field=1).save() + ModelMixinCallStckMngr(id_field=1).save() + donottrack_child_func() + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args[0][1]) + + def test_nested_parameterized_donottrack(self, log_capt): + """ Tests parameterized nested @donottrack + should not track call of classes specified in decorated with scope bounded to the respective class + """ + ModelAnotherCallStckMngr(id_field=1).save() + donottrack_parent_func() + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[0][0][1]) + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[1][0][1]) + + def test_nested_parameterized_donottrack_after(self, log_capt): + """ Tests parameterized nested @donottrack + QuerySetAPI calls after calling function with @donottrack should get logged + """ + donottrack_child_func() + # class with CallStackManager as Manager + ModelAnotherCallStckMngr(id_field=1).save() + # test is this- that this should get called. + ModelAnotherCallStckMngr.objects.filter(id_field=1) + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[0][0][1]) + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[1][0][1]) + + def test_donottrack_called_in_func(self, log_capt): + """ test for function which calls decorated function + functions without @donottrack decorator should log + """ + ModelAnotherCallStckMngr(id_field=1).save() + ModelMixinCallStckMngr(id_field=1).save() + track_without_donottrack() + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[0][0][1]) + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[1][0][1]) + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[2][0][1]) + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[3][0][1]) + + def test_donottrack_child_too(self, log_capt): + """ Test for inheritance + subclass should not be tracked when superclass is called in a @donottrack decorated function + """ + ModelWithCallStackMngr(id_field=1).save() + ModelWithCallStckMngrChild(id_field=1, child_id_field=1).save() + donottrack_subclass() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_duplication(self, log_capt): + """ Test for duplication of call stacks + should not log duplicated call stacks + """ + for __ in range(1, 5): + ModelMixinCallStckMngr(id_field=1).save() + self.assertEqual(len(log_capt.call_args_list), 1) From 2ee4b85ffc088fe60885ce3c97cb7cd32b4ce90a Mon Sep 17 00:00:00 2001 From: Ben Patterson Date: Fri, 19 Jun 2015 20:17:29 -0400 Subject: [PATCH 10/97] Fix flaky test. SOL-975 Remove flaky flag, improve linting. --- common/test/acceptance/pages/lms/discovery.py | 10 +++++++++- .../acceptance/tests/lms/test_lms_course_discovery.py | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/common/test/acceptance/pages/lms/discovery.py b/common/test/acceptance/pages/lms/discovery.py index 00547ccf9d..b8aea204cd 100644 --- a/common/test/acceptance/pages/lms/discovery.py +++ b/common/test/acceptance/pages/lms/discovery.py @@ -15,7 +15,15 @@ class CourseDiscoveryPage(PageObject): form = "#discovery-form" def is_browser_on_page(self): - return "Courses" in self.browser.title + """ + Loading indicator must be present, but not visible + """ + loading_css = "#loading-indicator" + courses_css = '.courses-listing' + + return self.q(css=courses_css).visible \ + and self.q(css=loading_css).present \ + and not self.q(css=loading_css).visible @property def result_items(self): diff --git a/common/test/acceptance/tests/lms/test_lms_course_discovery.py b/common/test/acceptance/tests/lms/test_lms_course_discovery.py index 7f9231dcbd..084a15b4e5 100644 --- a/common/test/acceptance/tests/lms/test_lms_course_discovery.py +++ b/common/test/acceptance/tests/lms/test_lms_course_discovery.py @@ -72,7 +72,6 @@ class CourseDiscoveryTest(WebAppTest): """ self.page.visit() - @flaky # TODO: fix this. See SOL-975 def test_search(self): """ Make sure you can search for courses. From d5b7d285d6895addc9dcc7fcb0c9daf49a8c86a3 Mon Sep 17 00:00:00 2001 From: Awais Date: Mon, 22 Jun 2015 14:55:12 +0500 Subject: [PATCH 11/97] ECOM-1772 Due date issue test case fixed. --- lms/djangoapps/verify_student/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 0027c312b7..833b8e4b95 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -1849,7 +1849,7 @@ class TestEmailMessageWithCustomICRVBlock(ModuleStoreTestCase): """ self.course_key = SlashSeparatedCourseKey("Robot", "999", "Test_Course") self.course = CourseFactory.create(org='Robot', number='999', display_name='Test Course') - self.due_date = datetime(2015, 6, 22, tzinfo=pytz.UTC) + self.due_date = datetime.now(pytz.UTC) + timedelta(days=20) self.allowed_attempts = 1 # Create the course modes From 0a696ae87ff341e74f8659cb489daa1b1f4bf101 Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Mon, 4 May 2015 16:01:59 +0500 Subject: [PATCH 12/97] Learner profile Location and Language fields Accessibility issues Following are done in this PR 1-The controls to edit the form fields were hyperlinks, added button and required css to make it look like hyperlink. 2-label added when swap to combobox 3-Non breaking spaces added with dashes. --- .../views/learner_profile_factory.js | 2 ++ lms/static/js/views/fields.js | 1 + lms/static/sass/shared/_fields.scss | 11 ++++++++++- lms/templates/fields/field_dropdown.underscore | 14 ++++++++++---- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js index 033472f09e..372f5e52ac 100644 --- a/lms/static/js/student_profile/views/learner_profile_factory.js +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -78,6 +78,7 @@ new FieldsView.DropdownFieldView({ model: accountSettingsModel, screenReaderTitle: gettext('Country'), + titleVisible: false, required: true, editable: editable, showMessages: false, @@ -90,6 +91,7 @@ new AccountSettingsFieldViews.LanguageProficienciesFieldView({ model: accountSettingsModel, screenReaderTitle: gettext('Preferred Language'), + titleVisible: false, required: false, editable: editable, showMessages: false, diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js index c6aa7639c8..35f11bd5db 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -325,6 +325,7 @@ mode: this.mode, title: this.options.title, screenReaderTitle: this.options.screenReaderTitle || this.options.title, + titleVisible: this.options.titleVisible || true, iconName: this.options.iconName, showBlankOption: (!this.options.required || !this.modelValueIsSet()), selectOptions: this.options.options, diff --git a/lms/static/sass/shared/_fields.scss b/lms/static/sass/shared/_fields.scss index 277278beee..b74ed55b28 100644 --- a/lms/static/sass/shared/_fields.scss +++ b/lms/static/sass/shared/_fields.scss @@ -127,7 +127,16 @@ display: none; } - &.mode-edit a.u-field-value-display { + button.u-field-value-display, button.u-field-value-display:active, button.u-field-value-display:focus, button.u-field-value-display:hover{ + border-color: transparent; + background: transparent; + padding: 0; + box-shadow: none; + font-size: inherit; + } + + + &.mode-edit button.u-field-value-display { display: none; } } diff --git a/lms/templates/fields/field_dropdown.underscore b/lms/templates/fields/field_dropdown.underscore index b42ab0cc36..6ec92412a3 100644 --- a/lms/templates/fields/field_dropdown.underscore +++ b/lms/templates/fields/field_dropdown.underscore @@ -4,6 +4,12 @@ <% } %> +<% if (!titleVisible) { %> + +<% } %> + <% if (iconName) { %> <% } %> @@ -17,11 +23,11 @@ <% }); %> - - <%- screenReaderTitle %> + From 6fdff766bc5b02ee73fec99b994c42b4c12d0b1f Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Wed, 17 Jun 2015 19:46:57 +0500 Subject: [PATCH 13/97] Credit eligibility requirements display on student progress page ECOM-1523 --- cms/envs/common.py | 6 ++- cms/static/js/views/settings/grading.js | 2 +- .../tests/test_field_override_performance.py | 48 +++++++++---------- lms/djangoapps/courseware/views.py | 20 +++++--- lms/envs/common.py | 4 ++ lms/static/js/courseware/credit_progress.js | 14 ++++-- lms/static/sass/course/_profile.scss | 6 +-- lms/templates/courseware/progress.html | 32 +++++++------ openedx/core/djangoapps/credit/api.py | 28 +++-------- 9 files changed, 84 insertions(+), 76 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 46a2dcc184..0057b86f22 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -42,6 +42,9 @@ from lms.envs.common import ( # technically accessible through the CMS via legacy URLs. PROFILE_IMAGE_BACKEND, PROFILE_IMAGE_DEFAULT_FILENAME, PROFILE_IMAGE_DEFAULT_FILE_EXTENSION, PROFILE_IMAGE_SECRET_KEY, PROFILE_IMAGE_MIN_BYTES, PROFILE_IMAGE_MAX_BYTES, + # The following setting is included as it is used to check whether to + # display credit eligibility table on the CMS or not. + ENABLE_CREDIT_ELIGIBILITY ) from path import path from warnings import simplefilter @@ -174,7 +177,7 @@ FEATURES = { 'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600, # Enable credit eligibility feature - 'ENABLE_CREDIT_ELIGIBILITY': False, + 'ENABLE_CREDIT_ELIGIBILITY': ENABLE_CREDIT_ELIGIBILITY, # Can the visibility of the discussion tab be configured on a per-course basis? 'ALLOW_HIDING_DISCUSSION_TAB': False, @@ -248,7 +251,6 @@ from lms.envs.common import ( COURSE_KEY_PATTERN, COURSE_ID_PATTERN, USAGE_KEY_PATTERN, ASSET_KEY_PATTERN ) - ######################### CSRF ######################################### # Forwards-compatibility with Django 1.7 diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index 0af6632c7f..def1ff1bc0 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -102,7 +102,7 @@ var GradingView = ValidatingView.extend({ renderMinimumGradeCredit: function() { var minimum_grade_credit = this.model.get('minimum_grade_credit'); this.$el.find('#course-minimum_grade_credit').val( - parseFloat(minimum_grade_credit) * 100 + '%' + Math.round(parseFloat(minimum_grade_credit) * 100) + '%' ); }, setGracePeriod : function(event) { diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index 5bacdd2d98..8d4eff1ac5 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -173,18 +173,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): TEST_DATA = { # (providers, course_width, enable_ccx): # of sql queries, # of mongo queries, # of xblocks - ('no_overrides', 1, True): (27, 7, 19), - ('no_overrides', 2, True): (135, 7, 131), - ('no_overrides', 3, True): (595, 7, 537), - ('ccx', 1, True): (27, 7, 47), - ('ccx', 2, True): (135, 7, 455), - ('ccx', 3, True): (595, 7, 2037), - ('no_overrides', 1, False): (27, 7, 19), - ('no_overrides', 2, False): (135, 7, 131), - ('no_overrides', 3, False): (595, 7, 537), - ('ccx', 1, False): (27, 7, 19), - ('ccx', 2, False): (135, 7, 131), - ('ccx', 3, False): (595, 7, 537), + ('no_overrides', 1, True): (26, 7, 19), + ('no_overrides', 2, True): (134, 7, 131), + ('no_overrides', 3, True): (594, 7, 537), + ('ccx', 1, True): (26, 7, 47), + ('ccx', 2, True): (134, 7, 455), + ('ccx', 3, True): (594, 7, 2037), + ('no_overrides', 1, False): (26, 7, 19), + ('no_overrides', 2, False): (134, 7, 131), + ('no_overrides', 3, False): (594, 7, 537), + ('ccx', 1, False): (26, 7, 19), + ('ccx', 2, False): (134, 7, 131), + ('ccx', 3, False): (594, 7, 537), } @@ -196,16 +196,16 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase): __test__ = True TEST_DATA = { - ('no_overrides', 1, True): (27, 4, 9), - ('no_overrides', 2, True): (135, 19, 54), - ('no_overrides', 3, True): (595, 84, 215), - ('ccx', 1, True): (27, 4, 9), - ('ccx', 2, True): (135, 19, 54), - ('ccx', 3, True): (595, 84, 215), - ('no_overrides', 1, False): (27, 4, 9), - ('no_overrides', 2, False): (135, 19, 54), - ('no_overrides', 3, False): (595, 84, 215), - ('ccx', 1, False): (27, 4, 9), - ('ccx', 2, False): (135, 19, 54), - ('ccx', 3, False): (595, 84, 215), + ('no_overrides', 1, True): (26, 4, 9), + ('no_overrides', 2, True): (134, 19, 54), + ('no_overrides', 3, True): (594, 84, 215), + ('ccx', 1, True): (26, 4, 9), + ('ccx', 2, True): (134, 19, 54), + ('ccx', 3, True): (594, 84, 215), + ('no_overrides', 1, False): (26, 4, 9), + ('no_overrides', 2, False): (134, 19, 54), + ('no_overrides', 3, False): (594, 84, 215), + ('ccx', 1, False): (26, 4, 9), + ('ccx', 2, False): (134, 19, 54), + ('ccx', 3, False): (594, 84, 215), } diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index f9e5fc3e0b..2c66444ab9 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -7,6 +7,7 @@ import urllib import json import cgi +from collections import OrderedDict from datetime import datetime from django.utils import translation from django.utils.translation import ugettext as _ @@ -1065,7 +1066,9 @@ def _progress(request, course_key, student_id): # checking certificate generation configuration show_generate_cert_btn = certs_api.cert_generation_enabled(course_key) - if is_credit_course(course_key): + credit_course_requirements = None + is_course_credit = settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key) + if is_course_credit: requirement_statuses = get_credit_requirement_status(course_key, student.username) if any(requirement['status'] == 'failed' for requirement in requirement_statuses): eligibility_status = "not_eligible" @@ -1073,12 +1076,16 @@ def _progress(request, course_key, student_id): eligibility_status = "eligible" else: eligibility_status = "partial_eligible" - credit_course = { + + paired_requirements = {} + for requirement in requirement_statuses: + namespace = requirement.pop("namespace") + paired_requirements.setdefault(namespace, []).append(requirement) + + credit_course_requirements = { 'eligibility_status': eligibility_status, - 'requirements': requirement_statuses + 'requirements': OrderedDict(sorted(paired_requirements.items(), reverse=True)) } - else: - credit_course = None context = { 'course': course, @@ -1089,7 +1096,8 @@ def _progress(request, course_key, student_id): 'student': student, 'passed': is_course_passed(course, grade_summary), 'show_generate_cert_btn': show_generate_cert_btn, - 'credit_course': credit_course + 'credit_course_requirements': credit_course_requirements, + 'is_credit_course': is_course_credit, } if show_generate_cert_btn: diff --git a/lms/envs/common.py b/lms/envs/common.py index d2fedc6fed..15379eb64e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2021,6 +2021,10 @@ FEATURES['CLASS_DASHBOARD'] = False if FEATURES.get('CLASS_DASHBOARD'): INSTALLED_APPS += ('class_dashboard',) +################ Enable credit eligibility feature #################### +ENABLE_CREDIT_ELIGIBILITY = False +FEATURES['ENABLE_CREDIT_ELIGIBILITY'] = ENABLE_CREDIT_ELIGIBILITY + ######################## CAS authentication ########################### if FEATURES.get('AUTH_USE_CAS'): diff --git a/lms/static/js/courseware/credit_progress.js b/lms/static/js/courseware/credit_progress.js index d0600b85c0..61c941206e 100644 --- a/lms/static/js/courseware/credit_progress.js +++ b/lms/static/js/courseware/credit_progress.js @@ -1,11 +1,17 @@ $(document).ready(function() { + var container = $('.requirement-container'); + var collapse = container.data('eligible'); + if (collapse == 'not_eligible') { + container.addClass('is-hidden'); + $('.detail-collapse').find('.fa').toggleClass('fa-caret-up fa-caret-down'); + $('.requirement-detail').text(gettext('More')); + } $('.detail-collapse').on('click', function() { var el = $(this); - $('.requirement-container').toggleClass('is-hidden'); - el.find('.fa').toggleClass('fa-caret-down fa-caret-up'); + container.toggleClass('is-hidden'); + el.find('.fa').toggleClass('fa-caret-up fa-caret-down'); el.find('.requirement-detail').text(function(i, text){ - return text === gettext('More') ? gettext('Less') : gettext('More'); + return text === gettext('Less') ? gettext('More') : gettext('Less'); }); }); - }); diff --git a/lms/static/sass/course/_profile.scss b/lms/static/sass/course/_profile.scss index 32ff94b641..e43f497777 100644 --- a/lms/static/sass/course/_profile.scss +++ b/lms/static/sass/course/_profile.scss @@ -215,11 +215,11 @@ border-bottom: 1px solid $lightGrey; padding: lh(0.5); > .requirement-name { - width: bi-app-invert-percentage(30%); + width: bi-app-invert-percentage(40%); display: inline-block; } > .requirement-status{ - width: bi-app-invert-percentage(70%); + width: bi-app-invert-percentage(60%); @include float(right); display: inline-block; .fa-times{ @@ -231,7 +231,7 @@ color: $success-color; } > .not-achieve{ - color: $lightGrey; + color: $darkGrey; } } } diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index ce6e5e8d41..d2dbdafc69 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -3,7 +3,7 @@ <%! from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse -from util.date_utils import get_time_display, DEFAULT_LONG_DATE_FORMAT +from util.date_utils import get_time_display, DEFAULT_SHORT_DATE_FORMAT from django.conf import settings from django.utils.http import urlquote_plus %> @@ -103,26 +103,27 @@ from django.utils.http import urlquote_plus %endif - %if credit_course is not None: + % if is_credit_course:

${_("Requirements for Course Credit")}

- %if credit_course['eligibility_status'] == 'not_eligible': - ${student.username}, ${_("You are no longer eligible for this course.")} - %elif credit_course['eligibility_status'] == 'eligible': - ${student.username}, ${_("You have met the requirements for credit in this course.")} - ${_("Go to your dashboard")} ${_("to purchase course credit.")} + %if credit_course_requirements['eligibility_status'] == 'not_eligible': + ${student.get_full_name()}, ${_("You are no longer eligible for this course.")} + %elif credit_course_requirements['eligibility_status'] == 'eligible': + ${student.get_full_name()}, ${_("You have met the requirements for credit in this course.")} + ${_("{link} to purchase course credit.").format(link="{url_name}".format(url = reverse('dashboard'), url_name = _('Go to your dashboard')))} - %elif credit_course['eligibility_status'] == 'partial_eligible': - ${student.username}, ${_("You have not yet met the requirements for credit.")} + %elif credit_course_requirements['eligibility_status'] == 'partial_eligible': + ${student.get_full_name()}, ${_("You have not yet met the requirements for credit.")} %endif
-
diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index 9658a1c823..ebec6b977d 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -419,26 +419,23 @@ def get_credit_requirement_status(course_key, username): { "namespace": "reverification", "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", + "display_name": "In Course Reverification", "criteria": {}, - "status": "satisfied", - }, - { - "namespace": "reverification", - "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", - "criteria": {}, - "status": "Not satisfied", + "status": "failed", }, { "namespace": "proctored_exam", "name": "i4x://edX/DemoX/proctoring-block/final_uuid", + "display_name": "Proctored Mid Term Exam", "criteria": {}, - "status": "error", + "status": "satisfied", }, { "namespace": "grade", "name": "i4x://edX/DemoX/proctoring-block/final_uuid", + "display_name": "Minimum Passing Grade", "criteria": {"min_grade": 0.8}, - "status": None, + "status": "failed", }, ] @@ -454,6 +451,7 @@ def get_credit_requirement_status(course_key, username): statuses.append({ "namespace": requirement.namespace, "name": requirement.name, + "display_name": requirement.display_name, "criteria": requirement.criteria, "status": requirement_status.status if requirement_status else None, "status_date": requirement_status.modified if requirement_status else None, @@ -475,18 +473,6 @@ def is_user_eligible_for_credit(username, course_key): return CreditEligibility.is_user_eligible_for_credit(course_key, username) -def is_credit_course(course_key): - """Check if the given course is a credit course - - Arg: - course_key (CourseKey): The identifier for course - - Returns: - True if course is credit course else False - """ - return CreditCourse.is_credit_course(course_key) - - def get_credit_requirement(course_key, namespace, name): """Returns the requirement of a given course, namespace and name. From 8b883ba2c703116d3f6f4d06ff813e6318636564 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 22 Jun 2015 10:35:20 -0400 Subject: [PATCH 14/97] Upgrade rednose to latest to fix bad output. --- requirements/edx/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index da14311683..e5ade359ef 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -144,7 +144,7 @@ pylint==1.4.2 python-subunit==0.0.16 pyquery==1.2.9 radon==1.2 -rednose==0.4.1 +rednose==0.4.3 selenium==2.42.1 splinter==0.5.4 testtools==0.9.34 From 8acc72975e95b80678a0422cdc97fb3510ce4286 Mon Sep 17 00:00:00 2001 From: Marco Morales Date: Wed, 20 May 2015 08:31:33 -0400 Subject: [PATCH 15/97] Iniital refactor of capa styling for basic checkbox and multiple choice problem types, including additional comments to sass styles --- .../lib/capa/capa/templates/choicegroup.html | 37 +- .../lib/capa/capa/templates/choicetext.html | 14 +- .../capa/templates/formulaequationinput.html | 8 +- .../lib/capa/capa/templates/optioninput.html | 13 +- common/lib/capa/capa/templates/textline.html | 22 +- .../capa/capa/tests/test_input_templates.py | 4 +- .../lib/xmodule/xmodule/css/capa/display.scss | 388 ++++++++++++++---- .../xmodule/js/src/capa/display.coffee | 4 +- common/static/sass/_mixins.scss | 9 + .../courseware/features/problems.feature | 8 +- lms/static/sass/base/_variables.scss | 22 + lms/static/sass/course/base/_extends.scss | 2 +- 12 files changed, 391 insertions(+), 140 deletions(-) diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index 814fae6594..6a27a3df4f 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -1,24 +1,5 @@
-
- % if input_type == 'checkbox' or not value: - - - %for choice_id, choice_description in choices: - % if choice_id in value: - ${choice_description}, - %endif - %endfor - - - ${status.display_name} - - - % endif -
-
- % for choice_id, choice_description in choices:
- +
+ % if input_type == 'checkbox' or not value: + + + %for choice_id, choice_description in choices: + % if choice_id in value: + ${choice_description}, + %endif + %endfor + - + ${status.display_name} + + + % endif +
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
${submitted_message}
%endif diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html index 91a894dd4f..e370f10594 100644 --- a/common/lib/capa/capa/templates/choicetext.html +++ b/common/lib/capa/capa/templates/choicetext.html @@ -9,12 +9,7 @@
-
- % if input_type == 'checkbox' or not element_checked: - - % endif -
- +
% for choice_id, choice_description in choices: <%choice_id= choice_id %> @@ -62,6 +57,13 @@
+ +
+ % if input_type == 'checkbox' or not element_checked: + + % endif +
+ % if show_correctness == "never" and (value or status not in ['unsubmitted']):
${_(submitted_message)}
%endif diff --git a/common/lib/capa/capa/templates/formulaequationinput.html b/common/lib/capa/capa/templates/formulaequationinput.html index de56fd5098..04b4f8e965 100644 --- a/common/lib/capa/capa/templates/formulaequationinput.html +++ b/common/lib/capa/capa/templates/formulaequationinput.html @@ -10,9 +10,11 @@ % endif /> -

- ${status.display_name} -

+ + + ${status.display_name} + +
\[\] diff --git a/common/lib/capa/capa/templates/optioninput.html b/common/lib/capa/capa/templates/optioninput.html index f804e771cc..65965597b7 100644 --- a/common/lib/capa/capa/templates/optioninput.html +++ b/common/lib/capa/capa/templates/optioninput.html @@ -13,12 +13,13 @@ - - ${value|h} - ${status.display_name} - - +
+ + ${value|h} - ${status.display_name} + +
% if msg: ${msg|n} % endif diff --git a/common/lib/capa/capa/templates/textline.html b/common/lib/capa/capa/templates/textline.html index 8a9826b0a5..ed006240ca 100644 --- a/common/lib/capa/capa/templates/textline.html +++ b/common/lib/capa/capa/templates/textline.html @@ -27,18 +27,20 @@ /> ${trailing_text | h} -

- %if value: - ${value|h} - % else: - ${label} - %endif - - - ${status.display_name} -

+ aria-describedby="input_${id}" data-tooltip="This is ${status.display_name}."> + + %if value: + ${value|h} + % else: + ${label} + %endif + - + ${status.display_name} + +

diff --git a/common/lib/capa/capa/tests/test_input_templates.py b/common/lib/capa/capa/tests/test_input_templates.py index 09ae25476d..4bed5e88c7 100644 --- a/common/lib/capa/capa/tests/test_input_templates.py +++ b/common/lib/capa/capa/tests/test_input_templates.py @@ -388,9 +388,9 @@ class TextlineTemplateTest(TemplateTestCase): xpath = "//div[@class='%s ']" % div_class self.assert_has_xpath(xml, xpath, self.context) - # Expect that we get a

with class="status" + # Expect that we get a with class="status" # (used to by CSS to draw the green check / red x) - self.assert_has_text(xml, "//p[@class='status']", + self.assert_has_text(xml, "//span[@class='status']", status_mark, exact=False) def test_label(self): diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 727630b79b..c8d5f7787f 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -1,8 +1,54 @@ +// capa - styling +// ==================== + +// Table of Contents +// * +Variables - Capa +// * +Extends - Capa +// * +Mixins - Status Icon - Capa +// * +Resets - Deprecate Please +// * +Problem - Base +// * +Problem - Choice Group +// * +Problem - Misc, Unclassified Mess +// * +Problem - Text Input, Numerical Input +// * +Problem - Option Input (Dropdown) +// * +Problem - CodeMirror +// * +Problem - Misc, Unclassified Mess Part 2 +// * +Problem - Rubric +// * +Problem - Annotation +// * +Problem - Choice Text Group + +// +Variables - Capa +// ==================== $annotation-yellow: rgba(255,255,10,0.3); $color-copy-tip: rgb(100,100,100); -$color-success: rgb(0, 136, 1); -$color-fail: rgb(212, 64, 64); +$correct: $green-d1; +$incorrect: $red; +// +Extends - Capa +// ==================== +// Duplicated from _mixins.scss due to xmodule compilation, inheritance issues +%use-font-awesome { + font-family: FontAwesome; + -webkit-font-smoothing: antialiased; + display: inline-block; + speak: none; +} + +// +Mixins - Status Icon - Capa +// ==================== +@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d"){ + + &:after { + @extend %use-font-awesome; + @include margin-left(17px); + color: $color; + font-size: 1.2em; + content: $fontAwesomeIcon; + } +} + +// +Resets - Deprecate Please +// ==================== h2 { margin-top: 0; margin-bottom: ($baseline*0.75); @@ -24,12 +70,12 @@ h2 { .feedback-hint-correct { margin-top: ($baseline/2); - color: $color-success; + color: $correct; } .feedback-hint-incorrect { margin-top: ($baseline/2); - color: $color-fail; + color: $incorrect; } .feedback-hint-text { @@ -55,10 +101,9 @@ h2 { display: block; } - iframe[seamless]{ overflow: hidden; - padding: 0px; + padding: 0; border: 0px none transparent; background-color: transparent; } @@ -68,13 +113,15 @@ iframe[seamless]{ } div.problem-progress { + @include padding-left($baseline/4); display: inline-block; - padding-left: ($baseline/4); - color: #666; + color: $gray-d1; font-weight: 100; font-size: em(16); } +// +Problem - Base +// ==================== div.problem { @media print { display: block; @@ -89,7 +136,11 @@ div.problem { .inline { display: inline; } +} +// +Problem - Choice Group +// ==================== +div.problem { .choicegroup { @include clearfix(); min-width: 100px; @@ -97,51 +148,100 @@ div.problem { width: 100px; label { - @include float(left); + @include box-sizing(border-box); + display: inline-block; clear: both; - margin-bottom: ($baseline/4); + width: 100%; + border: 2px solid $gray-l4; + border-radius: 3px; + margin-bottom: ($baseline/2); + padding: ($baseline/2); &.choicegroup_correct { - &:after { - margin-left: ($baseline*0.75); - content: url('../images/correct-icon.png'); + @include status-icon($correct, "\f00c"); + border: 2px solid $correct; + + // keep green for correct answers on hover. + &:hover { + border-color: $correct; } } &.choicegroup_incorrect { - &:after { - margin-left: ($baseline*0.75); - content: url('../images/incorrect-icon.png'); + @include status-icon($incorrect, "\f00d"); + border: 2px solid $incorrect; + + // keep red for incorrect answers on hover. + &:hover { + border-color: $incorrect; } } + + &:hover { + border: 2px solid $blue; + } } .indicator_container { - @include float(left); + display: inline-block; + min-height: 1px; width: 25px; - height: 1px; - @include margin-right(15px); } fieldset { @include box-sizing(border-box); - margin: 0px 0px $baseline; - @include padding-left($baseline); - @include border-left(1px solid #ddd); } input[type="radio"], input[type="checkbox"] { - @include float(left); - @include margin(4px, 8px, 0, 0); + @include margin(($baseline/4) ($baseline/2) ($baseline/4) ($baseline/4)); } text { + @include margin-left(25px); display: inline; - margin-left: 25px; } } +} +// +Problem - Status Indicators +// ==================== +// Summary status indicators shown after the input area +div.problem { + + .indicator_container { + + .status { + width: 20px; + height: 20px; + + // CASE: correct answer + &.correct { + @include status-icon($correct, "\f00c"); + } + + // CASE: incorrect answer + &.incorrect { + @include status-icon($incorrect, "\f00d"); + } + + // CASE: unanswered + &.unanswered { + @include status-icon($gray-l4, "\f128"); + } + + // CASE: processing + &.processing { + // add once spinner is rotated through animations + //@include status-icon($gray-d1, "\f110", 0); + } + } + } +} + +// +Problem - Misc, Unclassified Mess +// ==================== +div.problem { ol.enumerate { li { &:before { @@ -187,17 +287,22 @@ div.problem { } } + // known classes using this div: .indicator_container, moved to section above div { + + // TO-DO: Styling used by advanced capa problem types. Should be synced up to use .status class p { &.answer { margin-top: -2px; } + &.status { - margin: 8px 0 0 $baseline/2; + @include margin(8px, 0, 0, $baseline/2); text-indent: 100%; white-space: nowrap; overflow: hidden; } + span.clarification i { font-style: normal; &:hover { @@ -241,7 +346,21 @@ div.problem { } } - &.incorrect, &.incomplete, &.ui-icon-close { + &.ui-icon-close { + p.status { + display: inline-block; + width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; + } + + input { + border-color: red; + } + } + + &.incorrect, &.incomplete { + p.status { display: inline-block; width: 20px; @@ -260,9 +379,9 @@ div.problem { } p.answer { + @include margin-left($baseline/2); display: inline-block; margin-bottom: 0; - margin-left: $baseline/2; &:before { display: inline; @@ -287,8 +406,8 @@ div.problem { } img.loading { + @include padding-left($baseline/2); display: inline-block; - padding-left: ($baseline/2); } span { @@ -303,7 +422,7 @@ div.problem { background: #f1f1f1; } } - } + } // Hides equation previews in symbolic response problems when printing [id^='display'].equation { @@ -312,8 +431,9 @@ div.problem { } } + //TO-DO: review and deprecate all these styles within span {} span { - &.unanswered, &.ui-icon-bullet { + &.ui-icon-bullet { display: inline-block; position: relative; top: 4px; @@ -331,7 +451,7 @@ div.problem { background: url('../images/spinner.gif') center center no-repeat; } - &.correct, &.ui-icon-check { + &.ui-icon-check { display: inline-block; position: relative; top: 3px; @@ -349,7 +469,7 @@ div.problem { background: url('../images/partially-correct-icon.png') center center no-repeat; } - &.incorrect, &.incomplete, &.ui-icon-close { + &.incomplete, &.ui-icon-close { display: inline-block; position: relative; top: 3px; @@ -360,8 +480,8 @@ div.problem { } .reload { - float:right; - margin: $baseline/2; + @include float(right); + margin: ($baseline/2); } @@ -457,15 +577,6 @@ div.problem { } } - form.option-input { - margin: -$baseline/2 0 $baseline; - padding-bottom: $baseline; - - select { - margin-right: flex-gutter(); - } - } - ul { margin-bottom: lh(); margin-left: .75em; @@ -584,6 +695,93 @@ div.problem { white-space: pre; } } +} + +// +Problem - Text Input, Numerical Input +// ==================== +.problem { + .capa_inputtype.textline, .inputtype.formulaequationinput { + + input { + @include box-sizing(border-box); + border: 2px solid $gray-l4; + border-radius: 3px; + height: 46px; + min-width: 160px; + } + + > .incorrect, .correct, .unanswered { + + .status { + display: inline-block; + background: none; + margin-top: ($baseline/2); + } + } + + // CASE: incorrect answer + > .incorrect { + + input { + border: 2px solid $incorrect; + } + + .status { + @include status-icon($incorrect, "\f00d"); + } + } + + // CASE: correct answer + > .correct { + + input { + border: 2px solid $correct; + } + + .status { + @include status-icon($correct, "\f00c"); + } + } + + // CASE: unanswered + > .unanswered { + + input { + border: 2px solid $gray-l4; + } + + .status { + @include status-icon($gray-l4, "\f128"); + } + } + } +} + + +// +Problem - Option Input (Dropdown) +// ==================== +.problem { + .inputtype.option-input { + margin: (-$baseline/2) 0 $baseline; + padding-bottom: $baseline; + + select { + @include margin-right($baseline/2); + } + + .indicator_container { + display: inline-block; + + .status.correct:after, .status.incorrect:after { + @include margin-left(0); + } + } + } +} + +// +Problem - CodeMirror +// ==================== +div.problem { .CodeMirror { border: 1px solid black; @@ -634,7 +832,52 @@ div.problem { .CodeMirror-scroll { margin-right: 0px; } +} +// +Problem - Actions +// ==================== +div.problem .action { + margin-top: $baseline; + + .save, .check, .show, .reset, .hint-button { + @include margin-right($baseline/2); + margin-bottom: ($baseline/2); + height: ($baseline*2); + vertical-align: middle; + font-weight: 600; + text-transform: uppercase; + } + + .save { + @extend .blue-button !optional; + } + + .show { + + .show-label { + font-weight: 600; + font-size: 1.0em; + } + } + + .submission_feedback { + // background: #F3F3F3; + // border: 1px solid #ddd; + // border-radius: 3px; + // padding: 8px 12px; + // margin-top: ($baseline/2); + display: inline-block; + margin-top: 8px; + @include margin-left($baseline/2); + color: $gray-d1; + font-style: italic; + -webkit-font-smoothing: antialiased; + } +} + +// +Problem - Misc, Unclassified Mess Part 2 +// ==================== +div.problem { hr { float: none; clear: both; @@ -663,47 +906,7 @@ div.problem { padding: lh(); border: 1px solid $gray-l3; } - - div.action { - margin-top: $baseline; - - .save, .check, .show, .reset, .hint-button { - height: ($baseline*2); - vertical-align: middle; - font-weight: 600; - - @media print { - display: none; - } - } - - .save { - @extend .blue-button !optional; - } - - .show { - - .show-label { - font-weight: 600; - font-size: 1.0em; - } - } - - .submission_feedback { - // background: #F3F3F3; - // border: 1px solid #ddd; - // border-radius: 3px; - // padding: 8px 12px; - // margin-top: ($baseline/2); - display: inline-block; - margin-top: 8px; - @include margin-left($baseline/2); - color: #666; - font-style: italic; - -webkit-font-smoothing: antialiased; - } - } - + .detailed-solution { > p:first-child { color: #aaa; @@ -951,7 +1154,12 @@ div.problem { } } } +} + +// +Problem - Rubric +// ==================== +div.problem { .rubric { tr { margin: ($baseline/2) 0; @@ -1004,7 +1212,11 @@ div.problem { display: none; } } +} +// +Problem - Annotation +// ==================== +div.problem { .annotation-input { margin: 0 0 1em 0; border: 1px solid $gray-l3; @@ -1102,8 +1314,12 @@ div.problem { } } } +} - .choicetextgroup{ +// +Problem - Choice Text Group +// ==================== +div.problem { + .choicetextgroup { @extend .choicegroup; input[type="text"]{ diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 139654e5e5..f5e6c1baa0 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -468,7 +468,7 @@ class @Problem # They should set handlers on each to reset the whole. formulaequationinput: (element) -> $(element).find('input').on 'input', -> - $p = $(element).find('p.status') + $p = $(element).find('span.status') `// Translators: the word unanswered here is about answering a problem the student must solve.` $p.parent().removeClass().addClass "unanswered" @@ -496,7 +496,7 @@ class @Problem textline: (element) -> $(element).find('input').on 'input', -> - $p = $(element).find('p.status') + $p = $(element).find('span.status') `// Translators: the word unanswered here is about answering a problem the student must solve.` $p.parent().removeClass("correct incorrect").addClass "unanswered" diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss index b86ac1e2bd..6e702fdff6 100644 --- a/common/static/sass/_mixins.scss +++ b/common/static/sass/_mixins.scss @@ -23,6 +23,7 @@ // * +Content - Screenreader Text - Extend // * +Content - Text Wrap - Extend // * +Content - Text Truncate - Extend +// * +Icon - Font-Awesome - Extend // +Font Sizing - Mixin // ==================== @@ -428,3 +429,11 @@ text-overflow: ellipsis; } +// * +Icon - Font-Awesome - Extend +// ==================== +%use-font-awesome { + font-family: FontAwesome; + -webkit-font-smoothing: antialiased; + display: inline-block; + speak: none; +} diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature index b1e0359f62..58c0e90040 100644 --- a/lms/djangoapps/courseware/features/problems.feature +++ b/lms/djangoapps/courseware/features/problems.feature @@ -176,11 +176,11 @@ Feature: LMS.Answer problems Scenario: I can view and hide the answer if the problem has it: Given I am viewing a "numerical" that shows the answer "always" - When I press the button with the label "Show Answer" - Then the Show/Hide button label is "Hide Answer" + When I press the button with the label "SHOW ANSWER" + Then the Show/Hide button label is "HIDE ANSWER" And I should see "4.14159" somewhere in the page - When I press the button with the label "Hide Answer" - Then the Show/Hide button label is "Show Answer" + When I press the button with the label "HIDE ANSWER" + Then the Show/Hide button label is "SHOW ANSWER" And I should not see "4.14159" anywhere on the page Scenario: I can see my score on a problem when I answer it and after I reset it diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index a057789f27..5b1197c3ef 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -82,6 +82,28 @@ $gray-d2: shade($gray,40%); // #4c4c4c $gray-d3: shade($gray,60%); // #323232 $gray-d4: shade($gray,80%); // #191919 +// TO-DO: once existing lms $blue is removed, change $cms-blue to $blue. +$cms-blue: rgb(0, 159, 230); +$blue-l1: tint($cms-blue,20%); +$blue-l2: tint($cms-blue,40%); +$blue-l3: tint($cms-blue,60%); +$blue-l4: tint($cms-blue,80%); +$blue-l5: tint($cms-blue,90%); +$blue-d1: shade($cms-blue,20%); +$blue-d2: shade($cms-blue,40%); +$blue-d3: shade($cms-blue,60%); +$blue-d4: shade($cms-blue,80%); +$blue-s1: saturate($cms-blue,15%); +$blue-s2: saturate($cms-blue,30%); +$blue-s3: saturate($cms-blue,45%); +$blue-u1: desaturate($cms-blue,15%); +$blue-u2: desaturate($cms-blue,30%); +$blue-u3: desaturate($cms-blue,45%); +$blue-t0: rgba($cms-blue, 0.125); +$blue-t1: rgba($cms-blue, 0.25); +$blue-t2: rgba($cms-blue, 0.50); +$blue-t3: rgba($cms-blue, 0.75); + $pink: rgb(182,37,103); // #b72567; $pink-l1: tint($pink,20%); $pink-l2: tint($pink,40%); diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index cac78034be..19e065f31f 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -28,7 +28,7 @@ h1.top-header { .light-button, a.light-button, // only used in askbot as classes .gray-button { - @include button(simple, #eee); + @include button(simple, $gray-l5); @extend .button-reset; font-size: em(13); } From fb2f4fdade1286ff812668f47c4fe0606ba120d3 Mon Sep 17 00:00:00 2001 From: Waheed Ahmed Date: Tue, 9 Jun 2015 16:16:47 +0500 Subject: [PATCH 16/97] Fixed two paly_video events emitted on video replay. TNL-2166 --- .../lib/xmodule/xmodule/js/spec/video/html5_video_spec.js | 8 ++++---- common/lib/xmodule/xmodule/js/src/video/02_html5_video.js | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js b/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js index b22cdd375e..2f9126cd8c 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js @@ -51,14 +51,14 @@ }); }); - it('callback was called', function () { + it('callback was not called', function () { waitsFor(function () { return state.videoPlayer.player.getPlayerState() !== STATUS.PAUSED; }, 'Player state should be changed', WAIT_TIMEOUT); runs(function () { expect(state.videoPlayer.player.callStateChangeCallback) - .toHaveBeenCalled(); + .not.toHaveBeenCalled(); }); }); }); @@ -85,14 +85,14 @@ }); }); - it('callback was called', function () { + it('callback was not called', function () { waitsFor(function () { return state.videoPlayer.player.getPlayerState() !== STATUS.PLAYING; }, 'Player state should be changed', WAIT_TIMEOUT); runs(function () { expect(state.videoPlayer.player.callStateChangeCallback) - .toHaveBeenCalled(); + .not.toHaveBeenCalled(); }); }); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/02_html5_video.js b/common/lib/xmodule/xmodule/js/src/video/02_html5_video.js index dc3fd7974b..dca85c9d85 100644 --- a/common/lib/xmodule/xmodule/js/src/video/02_html5_video.js +++ b/common/lib/xmodule/xmodule/js/src/video/02_html5_video.js @@ -290,13 +290,11 @@ function () { var PlayerState = HTML5Video.PlayerState; if (_this.playerState === PlayerState.PLAYING) { - _this.pauseVideo(); _this.playerState = PlayerState.PAUSED; - _this.callStateChangeCallback(); + _this.pauseVideo(); } else { - _this.playVideo(); _this.playerState = PlayerState.PLAYING; - _this.callStateChangeCallback(); + _this.playVideo(); } }); From f04547cf0f82ba89602240af6321a34a3d49a4c3 Mon Sep 17 00:00:00 2001 From: Xavier Antoviaque Date: Mon, 22 Jun 2015 17:29:21 +0200 Subject: [PATCH 17/97] Updates the XBlock utilsrequirement to the latest This integrates the fixes from https://github.com/edx/xblock-utils/pull/20 and https://github.com/edx/xblock-utils/pull/21 - cf the PRs for the code review & +1s. CC @smarnach @sarina --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 8926e31496..d7fb1bbc04 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -50,7 +50,7 @@ git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c -e git+https://github.com/edx/edx-search.git@release-2015-06-16#egg=edx-search -e git+https://github.com/edx/edx-milestones.git@release-2015-06-17#egg=edx-milestones git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b#egg=edx_lint==0.2.3 --e git+https://github.com/edx/xblock-utils.git@db22bc40fd2a75458a3c66d057f88aff5a7383e6#egg=xblock-utils +-e git+https://github.com/edx/xblock-utils.git@213a97a50276d6a2504d8133650b2930ead357a0#egg=xblock-utils -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive -e git+https://github.com/edx/edx-reverification-block.git@a286e89c73e1b788e35ac5b08a54b71a9fa63cfd#egg=edx-reverification-block git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0 From 8c52c92bcbfe43a134ff846c05cd8afa487aadfb Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 10 Jun 2015 13:54:34 -0400 Subject: [PATCH 18/97] Reverification iOS support and refactor * Delete reverification templates * Delete photocapture.js * Delete unused "name change" end-points * Rebuild the reverification views using Backbone sub-views * Stop passing template names to the JavaScript code * Avoid hard-coding the parent view ID in the webcam view (for getting the capture click sound URL) --- common/djangoapps/course_modes/views.py | 1 - .../student/tests/test_change_name.py | 65 --- common/djangoapps/student/views.py | 60 --- common/templates/course_modes/choose.html | 52 +- .../verify_student/tests/test_views.py | 117 ++-- lms/djangoapps/verify_student/urls.py | 29 +- lms/djangoapps/verify_student/views.py | 158 ++---- lms/envs/common.py | 18 + lms/static/js/spec/main.js | 28 +- lms/static/js/spec/photocapture_spec.js | 55 -- .../make_payment_step_view_spec.js | 1 - .../pay_and_verify_view_spec.js | 39 +- .../spec/verify_student/reverify_view_spec.js | 74 +++ .../review_photos_step_view_spec.js | 1 - .../js/verify_student/pay_and_verify.js | 6 +- lms/static/js/verify_student/photocapture.js | 356 ------------ lms/static/js/verify_student/reverify.js | 44 ++ .../enrollment_confirmation_step_view.js | 7 +- .../views/face_photo_step_view.js | 5 +- .../views/id_photo_step_view.js | 5 +- .../verify_student/views/intro_step_view.js | 2 + .../views/make_payment_step_view.js | 2 + .../views/pay_and_verify_view.js | 3 - .../views/payment_confirmation_step_view.js | 2 + .../views/reverify_success_step_view.js | 17 + .../js/verify_student/views/reverify_view.js | 115 ++++ .../views/review_photos_step_view.js | 2 + .../verify_student/views/webcam_photo_view.js | 6 +- .../sass/views/_decoupled-verification.scss | 2 +- lms/static/sass/views/_verification.scss | 506 ++---------------- .../verify_student/_modal_editname.html | 34 -- .../_reverification_support.html | 28 - .../verify_student/_verification_header.html | 31 -- .../verify_student/_verification_support.html | 21 - .../verify_student/face_photo_step.underscore | 2 +- .../verify_student/incourse_reverify.html | 4 +- .../verify_student/pay_and_verify.html | 3 +- .../verify_student/photo_reverification.html | 402 -------------- .../prompt_midcourse_reverify.html | 5 - .../reverification_confirmation.html | 79 --- .../reverification_window_expired.html | 46 -- lms/templates/verify_student/reverify.html | 46 ++ .../verify_student/reverify_not_allowed.html | 28 + .../reverify_success_step.underscore | 11 + lms/urls.py | 1 - 45 files changed, 607 insertions(+), 1912 deletions(-) delete mode 100644 common/djangoapps/student/tests/test_change_name.py delete mode 100644 lms/static/js/spec/photocapture_spec.js create mode 100644 lms/static/js/spec/verify_student/reverify_view_spec.js delete mode 100644 lms/static/js/verify_student/photocapture.js create mode 100644 lms/static/js/verify_student/reverify.js create mode 100644 lms/static/js/verify_student/views/reverify_success_step_view.js create mode 100644 lms/static/js/verify_student/views/reverify_view.js delete mode 100644 lms/templates/verify_student/_modal_editname.html delete mode 100644 lms/templates/verify_student/_reverification_support.html delete mode 100644 lms/templates/verify_student/_verification_header.html delete mode 100644 lms/templates/verify_student/_verification_support.html delete mode 100644 lms/templates/verify_student/photo_reverification.html delete mode 100644 lms/templates/verify_student/prompt_midcourse_reverify.html delete mode 100644 lms/templates/verify_student/reverification_confirmation.html delete mode 100644 lms/templates/verify_student/reverification_window_expired.html create mode 100644 lms/templates/verify_student/reverify.html create mode 100644 lms/templates/verify_student/reverify_not_allowed.html create mode 100644 lms/templates/verify_student/reverify_success_step.underscore diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index b09b4c6be2..290aef9249 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -119,7 +119,6 @@ class ChooseModeView(View): "course_num": course.display_number_with_default, "chosen_price": chosen_price, "error": error, - "can_audit": "audit" in modes, "responsive": True } if "verified" in modes: diff --git a/common/djangoapps/student/tests/test_change_name.py b/common/djangoapps/student/tests/test_change_name.py deleted file mode 100644 index ee6d708074..0000000000 --- a/common/djangoapps/student/tests/test_change_name.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Unit tests for change_name view of student. -""" -import json - -from django.conf import settings -from django.core.urlresolvers import reverse -from django.test.client import Client -from django.test import TestCase - -from student.tests.factories import UserFactory -from student.models import UserProfile -import unittest - - -@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class TestChangeName(TestCase): - """ - Check the change_name view of student. - """ - def setUp(self): - super(TestChangeName, self).setUp() - self.student = UserFactory.create(password='test') - self.client = Client() - - def test_change_name_get_request(self): - """Get requests are not allowed in this view.""" - change_name_url = reverse('change_name') - resp = self.client.get(change_name_url) - self.assertEquals(resp.status_code, 405) - - def test_change_name_post_request(self): - """Name will be changed when provided with proper data.""" - self.client.login(username=self.student.username, password='test') - change_name_url = reverse('change_name') - resp = self.client.post(change_name_url, { - 'new_name': 'waqas', - 'rationale': 'change identity' - }) - response_data = json.loads(resp.content) - user = UserProfile.objects.get(user=self.student.id) - meta = json.loads(user.meta) - self.assertEquals(user.name, 'waqas') - self.assertEqual(meta['old_names'][0][1], 'change identity') - self.assertTrue(response_data['success']) - - def test_change_name_without_name(self): - """Empty string for name is not allowed in this view.""" - self.client.login(username=self.student.username, password='test') - change_name_url = reverse('change_name') - resp = self.client.post(change_name_url, { - 'new_name': '', - 'rationale': 'change identity' - }) - response_data = json.loads(resp.content) - self.assertFalse(response_data['success']) - - def test_unauthenticated(self): - """Unauthenticated user is not allowed to call this view.""" - change_name_url = reverse('change_name') - resp = self.client.post(change_name_url, { - 'new_name': 'waqas', - 'rationale': 'change identity' - }) - self.assertEquals(resp.status_code, 404) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 17a6bebf5d..81334ed54b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -2078,66 +2078,6 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument raise -# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE -@ensure_csrf_cookie -@require_POST -def change_name_request(request): - """ Log a request for a new name. """ - if not request.user.is_authenticated(): - raise Http404 - - try: - pnc = PendingNameChange.objects.get(user=request.user.id) - except PendingNameChange.DoesNotExist: - pnc = PendingNameChange() - pnc.user = request.user - pnc.new_name = request.POST['new_name'].strip() - pnc.rationale = request.POST['rationale'] - if len(pnc.new_name) < 2: - return JsonResponse({ - "success": False, - "error": _('Name required'), - }) # TODO: this should be status code 400 # pylint: disable=fixme - pnc.save() - - # The following automatically accepts name change requests. Remove this to - # go back to the old system where it gets queued up for admin approval. - accept_name_change_by_id(pnc.id) - - return JsonResponse({"success": True}) - - -# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE -def accept_name_change_by_id(uid): - """ - Accepts the pending name change request for the user represented - by user id `uid`. - """ - try: - pnc = PendingNameChange.objects.get(id=uid) - except PendingNameChange.DoesNotExist: - return JsonResponse({ - "success": False, - "error": _('Invalid ID'), - }) # TODO: this should be status code 400 # pylint: disable=fixme - - user = pnc.user - u_prof = UserProfile.objects.get(user=user) - - # Save old name - meta = u_prof.get_meta() - if 'old_names' not in meta: - meta['old_names'] = [] - meta['old_names'].append([u_prof.name, pnc.rationale, datetime.datetime.now(UTC).isoformat()]) - u_prof.set_meta(meta) - - u_prof.name = pnc.new_name - u_prof.save() - pnc.delete() - - return JsonResponse({"success": True}) - - @require_POST @login_required @ensure_csrf_cookie diff --git a/common/templates/course_modes/choose.html b/common/templates/course_modes/choose.html index f252c6cf2a..c6810697aa 100644 --- a/common/templates/course_modes/choose.html +++ b/common/templates/course_modes/choose.html @@ -4,13 +4,9 @@ from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse %> -<%block name="bodyclass">register verification-process step-select-track ${'is-upgrading' if upgrade else ''} +<%block name="bodyclass">register verification-process step-select-track <%block name="pagetitle"> - % if upgrade: - ${_("Upgrade Your Enrollment for {} | Choose Your Track").format(course_name)} - % else: - ${_("Enroll In {} | Choose Your Track").format(course_name)} - %endif + ${_("Enroll In {} | Choose Your Track").format(course_name)} <%block name="js_extra"> @@ -65,7 +61,11 @@ from django.core.urlresolvers import reverse

- <%include file="/verify_student/_verification_header.html" args="course_name=course_name" /> + % if "verified" in modes: @@ -129,36 +129,32 @@ from django.core.urlresolvers import reverse
% endif - % if not upgrade: - % if "honor" in modes: - - ${_("or")} - + % if "honor" in modes: + + ${_("or")} + -
-
- -

${_("Audit This Course")}

-
-

${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums. If your work is satisfactory and you abide by the Honor Code, you'll receive a personalized Honor Code Certificate to showcase your achievement.")}

-
+
+
+ +

${_("Audit This Course")}

+
+

${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums. If your work is satisfactory and you abide by the Honor Code, you'll receive a personalized Honor Code Certificate to showcase your achievement.")}

- -
    -
  • - -
  • -
- % endif + +
    +
  • + +
  • +
+
% endif
- - <%include file="/verify_student/_verification_support.html" />
diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 833b8e4b95..015f3effdd 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -41,9 +41,8 @@ from student.models import CourseEnrollment from util.date_utils import get_default_time_display from util.testing import UrlResetMixin from verify_student.views import ( - checkout_with_ecommerce_service, - render_to_response, PayAndVerifyView, EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW, - EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY, _send_email, _compose_message_reverification_email + checkout_with_ecommerce_service, render_to_response, PayAndVerifyView, + _send_email, _compose_message_reverification_email ) from verify_student.models import ( SoftwareSecurePhotoVerification, VerificationCheckpoint, @@ -1584,52 +1583,84 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): VerificationStatus.add_verification_status(checkpoint, self.user, "submitted") -class TestReverifyView(ModuleStoreTestCase): +class TestReverifyView(TestCase): """ - Tests for the reverification views. + Tests for the reverification view. + + Reverification occurs when a verification attempt is denied or expired, + and the student is given the option to resubmit. """ + + USERNAME = "shaftoe" + PASSWORD = "detachment-2702" + def setUp(self): super(TestReverifyView, self).setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + success = self.client.login(username=self.USERNAME, password=self.PASSWORD) + self.assertTrue(success, msg="Could not log in") - self.user = UserFactory.create(username="rusty", password="test") - self.user.profile.name = u"Røøsty Bøøgins" - self.user.profile.save() - self.client.login(username="rusty", password="test") - self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') - self.course_key = self.course.id + def test_reverify_view_can_reverify_denied(self): + # User has a denied attempt, so can reverify + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.deny("error") + self._assert_can_reverify() - @patch('verify_student.views.render_to_response', render_mock) - def test_reverify_get(self): - url = reverse('verify_student_reverify') - response = self.client.get(url) - self.assertEquals(response.status_code, 200) - ((_template, context), _kwargs) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertFalse(context['error']) + def test_reverify_view_can_reverify_expired(self): + # User has a verification attempt, but it's expired + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.approve() - @patch('verify_student.views.render_to_response', render_mock) - def test_reverify_post_failure(self): - url = reverse('verify_student_reverify') - response = self.client.post(url, {'face_image': '', - 'photo_id_image': ''}) - self.assertEquals(response.status_code, 200) - ((template, context), _kwargs) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertIn('photo_reverification', template) - self.assertTrue(context['error']) + days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + attempt.created_at = datetime.now(pytz.UTC) - timedelta(days=(days_good_for + 1)) + attempt.save() - @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) - def test_reverify_post_success(self): - url = reverse('verify_student_reverify') - response = self.client.post(url, {'face_image': ',', - 'photo_id_image': ','}) - self.assertEquals(response.status_code, 302) - try: - verification_attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user) - self.assertIsNotNone(verification_attempt) - except ObjectDoesNotExist: - self.fail('No verification object generated') - ((template, context), _kwargs) = render_mock.call_args # pylint: disable=unpacking-non-sequence - self.assertIn('photo_reverification', template) - self.assertTrue(context['error']) + # Allow the student to reverify + self._assert_can_reverify() + + def test_reverify_view_cannot_reverify_pending(self): + # User has submitted a verification attempt, but Software Secure has not yet responded + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + + # Cannot reverify because an attempt has already been submitted. + self._assert_cannot_reverify() + + def test_reverify_view_cannot_reverify_approved(self): + # Submitted attempt has been approved + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.approve() + + # Cannot reverify because the user is already verified. + self._assert_cannot_reverify() + + def _get_reverify_page(self): + """ + Retrieve the reverification page and return the response. + """ + url = reverse("verify_student_reverify") + return self.client.get(url) + + def _assert_can_reverify(self): + """ + Check that the reverification flow is rendered. + """ + response = self._get_reverify_page() + self.assertContains(response, "reverify-container") + + def _assert_cannot_reverify(self): + """ + Check that the user is blocked from reverifying. + """ + response = self._get_reverify_page() + self.assertContains(response, "reverify-blocked") class TestInCourseReverifyView(ModuleStoreTestCase): @@ -1727,7 +1758,7 @@ class TestInCourseReverifyView(ModuleStoreTestCase): # submitting the photo verification self.mock_tracker.track.assert_called_once_with( # pylint: disable=no-member self.user.id, # pylint: disable=no-member - EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW, + 'edx.bi.reverify.started', { 'category': "verification", 'label': unicode(self.course_key), @@ -1781,7 +1812,7 @@ class TestInCourseReverifyView(ModuleStoreTestCase): # photo verification self.mock_tracker.track.assert_called_once_with( # pylint: disable=no-member self.user.id, - EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY, + 'edx.bi.reverify.submitted', { 'category': "verification", 'label': unicode(self.course_key), diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 59b907f7c2..05e4926ea1 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -89,29 +89,24 @@ urlpatterns = patterns( name="verify_student_results_callback", ), + url( + r'^submit-photos/$', + views.submit_photos_for_verification, + name="verify_student_submit_photos" + ), + + # End-point for reverification + # Reverification occurs when a user's initial verification attempt + # is denied or expires. The user is allowed to retry by submitting + # new photos. This is different than *in-course* reverification, + # in which a student submits only face photos, which are matched + # against the ID photo from the user's initial verification attempt. url( r'^reverify$', views.ReverifyView.as_view(), name="verify_student_reverify" ), - url( - r'^reverification_confirmation$', - views.reverification_submission_confirmation, - name="verify_student_reverification_confirmation" - ), - - url( - r'^reverification_window_expired$', - views.reverification_window_expired, - name="verify_student_reverification_window_expired" - ), - - url( - r'^submit-photos/$', - views.submit_photos_for_verification, - name="verify_student_submit_photos" - ), # Endpoint for in-course reverification # Users are sent to this end-point from within courseware # to re-verify their identities by re-submitting face photos. diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 967759346d..38b5d5160f 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -6,7 +6,6 @@ import datetime import decimal import json import logging -from collections import namedtuple from pytz import UTC from ipware.ip import get_ip @@ -14,10 +13,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.mail import send_mail from django.core.urlresolvers import reverse -from django.http import ( - HttpResponse, HttpResponseBadRequest, - HttpResponseRedirect, Http404 -) +from django.http import HttpResponse, HttpResponseBadRequest, Http404 from django.contrib.auth.models import User from django.shortcuts import redirect from django.utils import timezone @@ -63,8 +59,6 @@ from staticfiles.storage import staticfiles_storage log = logging.getLogger(__name__) -EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW = 'edx.bi.reverify.started' -EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY = 'edx.bi.reverify.submitted' class PayAndVerifyView(View): @@ -156,43 +150,14 @@ class PayAndVerifyView(View): INTRO_STEP, ] - Step = namedtuple( - 'Step', - [ - 'title', - 'template_name' - ] - ) - - STEP_INFO = { - INTRO_STEP: Step( - title=ugettext_lazy("Intro"), - template_name="intro_step" - ), - MAKE_PAYMENT_STEP: Step( - title=ugettext_lazy("Make payment"), - template_name="make_payment_step" - ), - PAYMENT_CONFIRMATION_STEP: Step( - title=ugettext_lazy("Payment confirmation"), - template_name="payment_confirmation_step" - ), - FACE_PHOTO_STEP: Step( - title=ugettext_lazy("Take photo"), - template_name="face_photo_step" - ), - ID_PHOTO_STEP: Step( - title=ugettext_lazy("Take a photo of your ID"), - template_name="id_photo_step" - ), - REVIEW_PHOTOS_STEP: Step( - title=ugettext_lazy("Review your info"), - template_name="review_photos_step" - ), - ENROLLMENT_CONFIRMATION_STEP: Step( - title=ugettext_lazy("Enrollment confirmation"), - template_name="enrollment_confirmation_step" - ), + STEP_TITLES = { + INTRO_STEP: ugettext_lazy("Intro"), + MAKE_PAYMENT_STEP: ugettext_lazy("Make payment"), + PAYMENT_CONFIRMATION_STEP: ugettext_lazy("Payment confirmation"), + FACE_PHOTO_STEP: ugettext_lazy("Take photo"), + ID_PHOTO_STEP: ugettext_lazy("Take a photo of your ID"), + REVIEW_PHOTOS_STEP: ugettext_lazy("Review your info"), + ENROLLMENT_CONFIRMATION_STEP: ugettext_lazy("Enrollment confirmation"), } # Messages @@ -554,8 +519,7 @@ class PayAndVerifyView(View): return [ { 'name': step, - 'title': unicode(self.STEP_INFO[step].title), - 'templateName': self.STEP_INFO[step].template_name + 'title': unicode(self.STEP_TITLES[step]), } for step in display_steps if step not in remove_steps @@ -1044,85 +1008,52 @@ def results_callback(request): class ReverifyView(View): """ - The main reverification view. Under similar constraints as the main verification view. - Has to perform these functions: - - take new face photo - - take new id photo - - submit photos to photo verification service + Reverification occurs when a user's initial verification is denied + or expires. When this happens, users can re-submit photos through + the re-verification flow. + + Unlike in-course reverification, this flow requires users to submit + *both* face and ID photos. In contrast, during in-course reverification, + students submit only face photos, which are matched against the ID photo + the user submitted during initial verification. - Does not need to be attached to a particular course. - Does not need to worry about pricing """ @method_decorator(login_required) def get(self, request): """ - display this view + Render the reverification flow. + + Most of the work is done client-side by composing the same + Backbone views used in the initial verification flow. """ - context = { - "user_full_name": request.user.profile.name, - "error": False, - } - - return render_to_response("verify_student/photo_reverification.html", context) - - @method_decorator(login_required) - def post(self, request): - """ - submits the reverification to SoftwareSecure - """ - - try: - attempt = SoftwareSecurePhotoVerification(user=request.user) - b64_face_image = request.POST['face_image'].split(",")[1] - b64_photo_id_image = request.POST['photo_id_image'].split(",")[1] - - attempt.upload_face_image(b64_face_image.decode('base64')) - attempt.upload_photo_id_image(b64_photo_id_image.decode('base64')) - attempt.mark_ready() - - # save this attempt - attempt.save() - # then submit it across - attempt.submit() - return HttpResponseRedirect(reverse('verify_student_reverification_confirmation')) - except Exception: - log.exception( - "Could not submit verification attempt for user {}".format(request.user.id) - ) + status, _ = SoftwareSecurePhotoVerification.user_status(request.user) + if status in ["must_reverify", "expired"]: context = { "user_full_name": request.user.profile.name, - "error": True, + "platform_name": settings.PLATFORM_NAME, + "capture_sound": staticfiles_storage.url("audio/camera_capture.wav"), } - return render_to_response("verify_student/photo_reverification.html", context) - - -@login_required -def reverification_submission_confirmation(_request): - """ - Shows the user a confirmation page if the submission to SoftwareSecure was successful - """ - return render_to_response("verify_student/reverification_confirmation.html") - - -@login_required -def reverification_window_expired(_request): - """ - Displays an error page if a student tries to submit a reverification, but the window - for that reverification has already expired. - """ - # TODO need someone to review the copy for this template - return render_to_response("verify_student/reverification_window_expired.html") + return render_to_response("verify_student/reverify.html", context) + else: + context = { + "status": status + } + return render_to_response("verify_student/reverify_not_allowed.html", context) class InCourseReverifyView(View): """ The in-course reverification view. - Needs to perform these functions: - - take new face photo - - retrieve the old id photo - - submit these photos to photo verification service - Does not need to worry about pricing + In-course reverification occurs while a student is taking a course. + At points in the course, students are prompted to submit face photos, + which are matched against the ID photos the user submitted during their + initial verification. + + Students are prompted to enter this flow from an "In Course Reverification" + XBlock (courseware component) that course authors add to the course. + See https://github.com/edx/edx-reverification-block for more details. + """ @method_decorator(login_required) def get(self, request, course_id, usage_id): @@ -1168,9 +1099,7 @@ class InCourseReverifyView(View): return self._redirect_no_initial_verification(user, course_key) # emit the reverification event - self._track_reverification_events( - EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW, user.id, course_id, checkpoint.checkpoint_name - ) + self._track_reverification_events('edx.bi.reverify.started', user.id, course_id, checkpoint.checkpoint_name) context = { 'course_key': unicode(course_key), @@ -1235,7 +1164,8 @@ class InCourseReverifyView(View): # emit the reverification event self._track_reverification_events( - EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY, user.id, course_id, checkpoint.checkpoint_name + 'edx.bi.reverify.submitted', + user.id, course_id, checkpoint.checkpoint_name ) redirect_url = get_redirect_url(course_key, usage_key) diff --git a/lms/envs/common.py b/lms/envs/common.py index 15379eb64e..35432c0363 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1307,6 +1307,20 @@ verify_student_js = [ ] reverify_js = [ + 'js/verify_student/views/error_view.js', + 'js/verify_student/views/image_input_view.js', + 'js/verify_student/views/webcam_photo_view.js', + 'js/verify_student/views/step_view.js', + 'js/verify_student/views/face_photo_step_view.js', + 'js/verify_student/views/id_photo_step_view.js', + 'js/verify_student/views/review_photos_step_view.js', + 'js/verify_student/views/reverify_success_step_view.js', + 'js/verify_student/models/verification_model.js', + 'js/verify_student/views/reverify_view.js', + 'js/verify_student/reverify.js', +] + +incourse_reverify_js = [ 'js/verify_student/views/error_view.js', 'js/verify_student/views/image_input_view.js', 'js/verify_student/views/webcam_photo_view.js', @@ -1541,6 +1555,10 @@ PIPELINE_JS = { 'source_filenames': reverify_js, 'output_filename': 'js/reverify.js' }, + 'incourse_reverify': { + 'source_filenames': incourse_reverify_js, + 'output_filename': 'js/incourse_reverify.js' + }, 'ccx': { 'source_filenames': ccx_js, 'output_filename': 'js/ccx.js' diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 0a3e224202..169377d7b7 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -61,7 +61,6 @@ // Manually specify LMS files that are not converted to RequireJS 'history': 'js/vendor/history', 'js/mustache': 'js/mustache', - 'js/verify_student/photocapture': 'js/verify_student/photocapture', 'js/staff_debug_actions': 'js/staff_debug_actions', 'js/vendor/jquery.qubit': 'js/vendor/jquery.qubit', @@ -284,9 +283,6 @@ exports: 'js/student_profile/profile', deps: ['jquery', 'underscore', 'backbone', 'gettext', 'jquery.cookie'] }, - 'js/verify_student/photocapture': { - exports: 'js/verify_student/photocapture' - }, 'js/staff_debug_actions': { exports: 'js/staff_debug_actions', deps: ['gettext'] @@ -501,6 +497,7 @@ 'gettext', 'jquery.cookie', 'jquery.url', + 'string_utils', 'js/verify_student/views/step_view', ] }, @@ -550,6 +547,13 @@ 'js/verify_student/views/step_view', ] }, + 'js/verify_student/views/reverify_success_step_view': { + exports: 'edx.verify_student.ReverifySuccessStepView', + deps: [ + 'jquery', + 'js/verify_student/views/step_view', + ] + }, 'js/verify_student/views/pay_and_verify_view': { exports: 'edx.verify_student.PayAndVerifyView', deps: [ @@ -567,6 +571,20 @@ 'js/verify_student/views/enrollment_confirmation_step_view' ] }, + 'js/verify_student/views/reverify_view': { + exports: 'edx.verify_student.ReverifyView', + deps: [ + 'jquery', + 'underscore', + 'backbone', + 'gettext', + 'js/verify_student/models/verification_model', + 'js/verify_student/views/face_photo_step_view', + 'js/verify_student/views/id_photo_step_view', + 'js/verify_student/views/enrollment_confirmation_step_view', + 'js/verify_student/views/reverify_success_step_view' + ] + }, // Student Notes 'annotator_1.2.9': { exports: 'Annotator', @@ -583,7 +601,6 @@ 'lms/include/js/spec/components/header/header_spec.js', 'lms/include/js/spec/components/tabbed/tabbed_view_spec.js', 'lms/include/js/spec/components/card/card_spec.js', - 'lms/include/js/spec/photocapture_spec.js', 'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/views/notification_spec.js', 'lms/include/js/spec/views/file_uploader_spec.js', @@ -610,6 +627,7 @@ 'lms/include/js/spec/student_profile/learner_profile_view_spec.js', 'lms/include/js/spec/student_profile/learner_profile_fields_spec.js', 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', + 'lms/include/js/spec/verify_student/reverify_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', 'lms/include/js/spec/verify_student/image_input_spec.js', 'lms/include/js/spec/verify_student/review_photos_step_view_spec.js', diff --git a/lms/static/js/spec/photocapture_spec.js b/lms/static/js/spec/photocapture_spec.js deleted file mode 100644 index 85ec82ce75..0000000000 --- a/lms/static/js/spec/photocapture_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -define(['backbone', 'jquery', 'js/verify_student/photocapture'], - function (Backbone, $) { - - describe("Photo Verification", function () { - - beforeEach(function () { - setFixtures(''); - }); - - it('retake photo', function () { - spyOn(window, "refereshPageMessage").andCallFake(function () { - return; - }); - spyOn($, "ajax").andCallFake(function (e) { - e.success({"success": false}); - }); - submitToPaymentProcessing(); - expect(window.refereshPageMessage).toHaveBeenCalled(); - }); - - it('successful submission', function () { - spyOn(window, "submitForm").andCallFake(function () { - return; - }); - spyOn($, "ajax").andCallFake(function (e) { - e.success({"success": true}); - }); - submitToPaymentProcessing(); - expect(window.submitForm).toHaveBeenCalled(); - expect($(".payment-button")).toHaveClass("is-disabled"); - }); - - it('Error during process', function () { - spyOn(window, "showSubmissionError").andCallFake(function () { - return; - }); - spyOn($, "ajax").andCallFake(function (e) { - e.error({}); - }); - spyOn($.fn, "addClass").andCallThrough(); - spyOn($.fn, "removeClass").andCallThrough(); - - submitToPaymentProcessing(); - expect(window.showSubmissionError).toHaveBeenCalled(); - - // make sure the button isn't disabled - expect($(".payment-button")).not.toHaveClass("is-disabled"); - - // but also make sure that it was disabled during the ajax call - expect($.fn.addClass).toHaveBeenCalledWith("is-disabled"); - expect($.fn.removeClass).toHaveBeenCalledWith("is-disabled"); - }); - - }); - }); diff --git a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js index 2c6e51caf3..c63915c780 100644 --- a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js +++ b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js @@ -29,7 +29,6 @@ define([ var createView = function( stepDataOverrides ) { var view = new MakePaymentStepView({ el: $( '#current-step-container' ), - templateName: 'make_payment_step', stepData: _.extend( _.clone( STEP_DATA ), stepDataOverrides ), errorModel: new ( Backbone.Model.extend({}) )() }).render(); diff --git a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js index 0c94a54ef9..29fbf52b32 100644 --- a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js +++ b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js @@ -18,19 +18,16 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ ]; var INTRO_STEP = { - templateName: "intro_step", name: "intro-step", title: "Intro" }; var DISPLAY_STEPS_FOR_PAYMENT = [ { - templateName: "make_payment_step", name: "make-payment-step", title: "Make Payment" }, { - templateName: "payment_confirmation_step", name: "payment-confirmation-step", title: "Payment Confirmation" } @@ -38,22 +35,18 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ var DISPLAY_STEPS_FOR_VERIFICATION = [ { - templateName: "face_photo_step", name: "face-photo-step", title: "Take Face Photo" }, { - templateName: "id_photo_step", name: "id-photo-step", title: "ID Photo" }, { - templateName: "review_photos_step", name: "review-photos-step", title: "Review Photos" }, { - templateName: "enrollment_confirmation_step", name: "enrollment-confirmation-step", title: "Enrollment Confirmation" } @@ -67,7 +60,7 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ }).render(); }; - var expectStepRendered = function( stepName, stepNum, numSteps ) { + var expectStepRendered = function( stepName ) { // Expect that the step container div rendered expect( $( '.' + stepName ).length > 0 ).toBe( true ); }; @@ -89,27 +82,27 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ ); // Verify that the first step rendered - expectStepRendered('make-payment-step', 1, 6); + expectStepRendered('make-payment-step'); // Iterate through the steps, ensuring that each is rendered view.nextStep(); - expectStepRendered('payment-confirmation-step', 2, 6); + expectStepRendered('payment-confirmation-step'); view.nextStep(); - expectStepRendered('face-photo-step', 3, 6); + expectStepRendered('face-photo-step'); view.nextStep(); - expectStepRendered('id-photo-step', 4, 6); + expectStepRendered('id-photo-step'); view.nextStep(); - expectStepRendered('review-photos-step', 5, 6); + expectStepRendered('review-photos-step'); view.nextStep(); - expectStepRendered('enrollment-confirmation-step', 6, 6); + expectStepRendered('enrollment-confirmation-step'); // Going past the last step stays on the last step view.nextStep(); - expectStepRendered('enrollment-confirmation-step', 6, 6); + expectStepRendered('enrollment-confirmation-step'); }); it( 'renders intro and verification steps', function() { @@ -119,20 +112,20 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ ); // Verify that the first step rendered - expectStepRendered('intro-step', 1, 5); + expectStepRendered('intro-step'); // Iterate through the steps, ensuring that each is rendered view.nextStep(); - expectStepRendered('face-photo-step', 2, 5); + expectStepRendered('face-photo-step'); view.nextStep(); - expectStepRendered('id-photo-step', 3, 5); + expectStepRendered('id-photo-step'); view.nextStep(); - expectStepRendered('review-photos-step', 4, 5); + expectStepRendered('review-photos-step'); view.nextStep(); - expectStepRendered('enrollment-confirmation-step', 5, 5); + expectStepRendered('enrollment-confirmation-step'); }); it( 'starts from a later step', function() { @@ -143,11 +136,11 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ ); // Verify that we start on the right step - expectStepRendered('payment-confirmation-step', 2, 6); + expectStepRendered('payment-confirmation-step'); // Try moving to the next step view.nextStep(); - expectStepRendered('face-photo-step', 3, 6); + expectStepRendered('face-photo-step'); }); @@ -160,7 +153,7 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ // Jump back to the face photo step view.goToStep('face-photo-step'); - expectStepRendered('face-photo-step', 1, 4); + expectStepRendered('face-photo-step'); }); }); diff --git a/lms/static/js/spec/verify_student/reverify_view_spec.js b/lms/static/js/spec/verify_student/reverify_view_spec.js new file mode 100644 index 0000000000..68b41ab285 --- /dev/null +++ b/lms/static/js/spec/verify_student/reverify_view_spec.js @@ -0,0 +1,74 @@ +/** +* Tests for the reverification view. +**/ +define(['jquery', 'js/common_helpers/template_helpers', 'js/verify_student/views/reverify_view'], + function( $, TemplateHelpers, ReverifyView ) { + 'use strict'; + + describe( 'edx.verify_student.ReverifyView', function() { + + var TEMPLATES = [ + "reverify", + "webcam_photo", + "image_input", + "error", + "face_photo_step", + "id_photo_step", + "review_photos_step", + "reverify_success_step" + ]; + + var STEP_INFO = { + 'face-photo-step': { + platformName: 'edX', + }, + 'id-photo-step': { + platformName: 'edX', + }, + 'review-photos-step': { + fullName: 'John Doe', + platformName: 'edX' + }, + 'reverify-success-step': { + platformName: 'edX' + } + }; + + var createView = function() { + return new ReverifyView({stepInfo: STEP_INFO}).render(); + }; + + var expectStepRendered = function( stepName ) { + // Expect that the step container div rendered + expect( $( '.' + stepName ).length > 0 ).toBe( true ); + }; + + + beforeEach(function() { + window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + + setFixtures('
'); + $.each( TEMPLATES, function( index, templateName ) { + TemplateHelpers.installTemplate('templates/verify_student/' + templateName ); + }); + }); + + it( 'renders verification steps', function() { + var view = createView(); + + // Go through the flow, verifying that each step renders + // We rely on other unit tests to check the behavior of these subviews. + expectStepRendered('face-photo-step'); + + view.nextStep(); + expectStepRendered('id-photo-step'); + + view.nextStep(); + expectStepRendered('review-photos-step'); + + view.nextStep(); + expectStepRendered('reverify-success-step'); + }); + }); + } +); diff --git a/lms/static/js/spec/verify_student/review_photos_step_view_spec.js b/lms/static/js/spec/verify_student/review_photos_step_view_spec.js index 58d3757ab3..59f07447f1 100644 --- a/lms/static/js/spec/verify_student/review_photos_step_view_spec.js +++ b/lms/static/js/spec/verify_student/review_photos_step_view_spec.js @@ -21,7 +21,6 @@ define([ var createView = function() { return new ReviewPhotosStepView({ el: $( '#current-step-container' ), - templateName: 'review_photos_step', stepData: STEP_DATA, model: new VerificationModel({ faceImage: FACE_IMAGE, diff --git a/lms/static/js/verify_student/pay_and_verify.js b/lms/static/js/verify_student/pay_and_verify.js index 0264d6d4c7..65d4781b2e 100644 --- a/lms/static/js/verify_student/pay_and_verify.js +++ b/lms/static/js/verify_student/pay_and_verify.js @@ -74,10 +74,12 @@ var edx = edx || {}; requirements: el.data('requirements') }, 'face-photo-step': { - platformName: el.data('platform-name') + platformName: el.data('platform-name'), + captureSoundPath: el.data('capture-sound') }, 'id-photo-step': { - platformName: el.data('platform-name') + platformName: el.data('platform-name'), + captureSoundPath: el.data('capture-sound') }, 'review-photos-step': { fullName: el.data('full-name'), diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js deleted file mode 100644 index 7d3716238c..0000000000 --- a/lms/static/js/verify_student/photocapture.js +++ /dev/null @@ -1,356 +0,0 @@ -var onVideoFail = function(e) { - if(e == 'NO_DEVICES_FOUND') { - $('#no-webcam').show(); - $('#face_capture_button').hide(); - $('#photo_id_capture_button').hide(); - } - else { - console.log('Failed to get camera access!', e); - } -}; - -// Returns true if we are capable of video capture (regardless of whether the -// user has given permission). -function initVideoCapture() { - window.URL = window.URL || window.webkitURL; - navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || - navigator.mozGetUserMedia || navigator.msGetUserMedia; - return !(navigator.getUserMedia == undefined); -} - -var submitReverificationPhotos = function() { - // add photos to the form - $('').attr({ - type: 'hidden', - name: 'face_image', - value: $("#face_image")[0].src, - }).appendTo("#reverify_form"); - $('').attr({ - type: 'hidden', - name: 'photo_id_image', - value: $("#photo_id_image")[0].src, - }).appendTo("#reverify_form"); - - $("#reverify_form").submit(); - -} - -var submitMidcourseReverificationPhotos = function() { - $('').attr({ - type: 'hidden', - name: 'face_image', - value: $("#face_image")[0].src, - }).appendTo("#reverify_form"); - $("#reverify_form").submit(); -} - -function showSubmissionError() { - if (xhr.status == 400) { - $('#order-error .copy p').html(xhr.responseText); - } - $('#order-error').show(); - $("html, body").animate({ scrollTop: 0 }); -} - -function submitForm(data) { - for (prop in data) { - $('').attr({ - type: 'hidden', - name: prop, - value: data[prop] - }).appendTo('#pay_form'); - } - $("#pay_form").submit(); -} - -function refereshPageMessage() { - $('#photo-error').show(); - $("html, body").animate({ scrollTop: 0 }); -} - -var submitToPaymentProcessing = function() { - $(".payment-button").addClass('is-disabled').attr('aria-disabled', true); - var contribution_input = $("input[name='contribution']:checked") - var contribution = 0; - if(contribution_input.attr('id') == 'contribution-other') { - contribution = $("input[name='contribution-other-amt']").val(); - } - else { - contribution = contribution_input.val(); - } - var course_id = $("input[name='course_id']").val(); - $.ajax({ - url: "/verify_student/create_order", - type: 'POST', - data: { - "course_id" : course_id, - "contribution": contribution, - "face_image" : $("#face_image")[0].src, - "photo_id_image" : $("#photo_id_image")[0].src - }, - success:function(data) { - if (data.success) { - submitForm(data); - } else { - refereshPageMessage(); - } - }, - error:function(xhr,status,error) { - $(".payment-button").removeClass('is-disabled').attr('aria-disabled', false); - showSubmissionError() - } - }); -} - -function doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink) { - approveButton.removeClass('approved'); - nextButtonNav.addClass('is-not-ready'); - nextLink.attr('href', "#"); - - captureButton.show(); - resetButton.hide(); - approveButton.hide(); -} - -function doApproveButton(approveButton, nextButtonNav, nextLink) { - nextButtonNav.removeClass('is-not-ready'); - approveButton.addClass('approved'); - nextLink.attr('href', "#next"); -} - -function doSnapshotButton(captureButton, resetButton, approveButton) { - captureButton.hide(); - resetButton.show(); - approveButton.show(); -} - -function submitNameChange(event) { - event.preventDefault(); - $("#lean_overlay").fadeOut(200); - $("#edit-name").css({ 'display' : 'none' }); - var full_name = $('input[name="name"]').val(); - var xhr = $.post( - "/change_name", - { - "new_name" : full_name, - "rationale": "Want to match ID for ID Verified Certificates." - }, - function(data) { - $('#full-name').html(full_name); - } - ) - .fail(function(jqXhr,text_status, error_thrown) { - $('.message-copy').html(jqXhr.responseText); - }); - -} - -function initSnapshotHandler(names, hasHtml5CameraSupport) { - var name = names.pop(); - if (name == undefined) { - return; - } - - var video = $('#' + name + '_video'); - var canvas = $('#' + name + '_canvas'); - var image = $('#' + name + "_image"); - var captureButton = $("#" + name + "_capture_button"); - var resetButton = $("#" + name + "_reset_button"); - var approveButton = $("#" + name + "_approve_button"); - var nextButtonNav = $("#" + name + "_next_button_nav"); - var nextLink = $("#" + name + "_next_link"); - var flashCapture = $("#" + name + "_flash"); - - var ctx = null; - if (hasHtml5CameraSupport) { - ctx = canvas[0].getContext('2d'); - } - - var localMediaStream = null; - - function snapshot(event) { - if (hasHtml5CameraSupport) { - if (localMediaStream) { - ctx.drawImage(video[0], 0, 0); - image[0].src = canvas[0].toDataURL('image/png'); - } - else { - return false; - } - video[0].pause(); - } - else { - if (flashCapture[0].cameraAuthorized()) { - image[0].src = flashCapture[0].snap(); - } - else { - return false; - } - } - - doSnapshotButton(captureButton, resetButton, approveButton); - return false; - } - - function reset() { - image[0].src = ""; - - if (hasHtml5CameraSupport) { - video[0].play(); - } - else { - flashCapture[0].reset(); - } - - doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink); - return false; - } - - function approve() { - doApproveButton(approveButton, nextButtonNav, nextLink) - return false; - } - - // Initialize state for this picture taker - captureButton.show(); - resetButton.hide(); - approveButton.hide(); - nextButtonNav.addClass('is-not-ready'); - nextLink.attr('href', "#"); - - // Connect event handlers... - video.click(snapshot); - captureButton.click(snapshot); - resetButton.click(reset); - approveButton.click(approve); - - // If it's flash-based, we can just immediate initialize the next one. - // If it's HTML5 based, we have to do it in the callback from getUserMedia - // so that Firefox doesn't eat the second request. - if (hasHtml5CameraSupport) { - navigator.getUserMedia({video: true}, function(stream) { - video[0].src = window.URL.createObjectURL(stream); - localMediaStream = stream; - - // We do this in a recursive call on success because Firefox seems to - // simply eat the request if you stack up two on top of each other before - // the user has a chance to approve the first one. - // - // This appears to be necessary for older versions of Firefox (before 28). - // For more info, see https://github.com/edx/edx-platform/pull/3053 - initSnapshotHandler(names, hasHtml5CameraSupport); - }, onVideoFail); - } - else { - initSnapshotHandler(names, hasHtml5CameraSupport); - } - -} - -function browserHasFlash() { - var hasFlash = false; - try { - var fo = new ActiveXObject('ShockwaveFlash.ShockwaveFlash'); - if(fo) hasFlash = true; - } catch(e) { - if(navigator.mimeTypes["application/x-shockwave-flash"] != undefined) hasFlash = true; - } - return hasFlash; -} - -function objectTagForFlashCamera(name) { - // detect whether or not flash is available - if(browserHasFlash()) { - // I manually update this to have ?v={2,3,4, etc} to avoid caching of flash - // objects on local dev. - return ''; - } - else { - // display a message informing the user to install flash - $('#no-flash').show(); - } -} - -function waitForFlashLoad(func, flash_object) { - if(!flash_object.hasOwnProperty('percentLoaded') || flash_object.percentLoaded() < 100){ - setTimeout(function() { - waitForFlashLoad(func, flash_object); - }, - 50); - } - else { - func(flash_object); - } -} - -$(document).ready(function() { - $(".carousel-nav").addClass('sr'); - $(".payment-button").click(function(){ - analytics.pageview("Payment Form"); - submitToPaymentProcessing(); - }); - - $("#reverify_button").click(function() { - submitReverificationPhotos(); - }); - - $("#midcourse_reverify_button").click(function() { - submitMidcourseReverificationPhotos(); - }); - - // prevent browsers from keeping this button checked - $("#confirm_pics_good").prop("checked", false) - $("#confirm_pics_good").change(function() { - $(".payment-button").toggleClass('disabled'); - $("#reverify_button").toggleClass('disabled'); - $("#midcourse_reverify_button").toggleClass('disabled'); - }); - - - // add in handlers to add/remove the correct classes to the body - // when moving between steps - $('#face_next_link').click(function(){ - analytics.pageview("Capture ID Photo"); - $('#photo-error').hide(); - $('body').addClass('step-photos-id').removeClass('step-photos-cam') - }) - - $('#photo_id_next_link').click(function(){ - analytics.pageview("Review Photos"); - $('body').addClass('step-review').removeClass('step-photos-id') - }) - - // set up edit information dialog - $('#edit-name div[role="alert"]').hide(); - $('#edit-name .action-save').click(submitNameChange); - - var hasHtml5CameraSupport = initVideoCapture(); - - // If HTML5 WebRTC capture is not supported, we initialize jpegcam - if (!hasHtml5CameraSupport) { - $("#face_capture_div").html(objectTagForFlashCamera("face_flash")); - $("#photo_id_capture_div").html(objectTagForFlashCamera("photo_id_flash")); - // wait for the flash object to be loaded and then check for a camera - if(browserHasFlash()) { - waitForFlashLoad(function(flash_object) { - if(!flash_object.hasOwnProperty('hasCamera')){ - onVideoFail('NO_DEVICES_FOUND'); - } - }, $('#face_flash')[0]); - } - } - - analytics.pageview("Capture Face Photo"); - initSnapshotHandler(["photo_id", "face"], hasHtml5CameraSupport); - - $('a[rel="external"]').attr({ - title: gettext('This link will open in a new browser window/tab'), - target: '_blank' - }); - -}); diff --git a/lms/static/js/verify_student/reverify.js b/lms/static/js/verify_student/reverify.js new file mode 100644 index 0000000000..42d732ae2b --- /dev/null +++ b/lms/static/js/verify_student/reverify.js @@ -0,0 +1,44 @@ +/** + * Reverification flow. + * + * This flow allows students who have a denied or expired verification + * to re-submit face and ID photos. It re-uses most of the same sub-views + * as the payment/verification flow. + */ + var edx = edx || {}; + + (function( $, _ ) { + 'use strict'; + var errorView, + el = $('#reverify-container'); + + edx.verify_student = edx.verify_student || {}; + + // Initialize an error view for displaying top-level error messages. + errorView = new edx.verify_student.ErrorView({ + el: $('#error-container') + }); + + // Initialize the base view, passing in information + // from the data attributes on the parent div. + return new edx.verify_student.ReverifyView({ + errorModel: errorView.model, + stepInfo: { + 'face-photo-step': { + platformName: el.data('platform-name'), + captureSoundPath: el.data('capture-sound') + }, + 'id-photo-step': { + platformName: el.data('platform-name'), + captureSoundPath: el.data('capture-sound') + }, + 'review-photos-step': { + fullName: el.data('full-name'), + platformName: el.data('platform-name') + }, + 'reverify-success-step': { + platformName: el.data('platform-name') + } + } + }).render(); +})( jQuery, _ ); diff --git a/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js b/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js index 6529402f91..f9402f56f2 100644 --- a/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js +++ b/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js @@ -4,7 +4,7 @@ */ var edx = edx || {}; -(function( $ ) { +(function() { 'use strict'; edx.verify_student = edx.verify_student || {}; @@ -12,6 +12,9 @@ var edx = edx || {}; // Currently, this step does not need to install any event handlers, // since the displayed information is static. edx.verify_student.EnrollmentConfirmationStepView = edx.verify_student.StepView.extend({ + + templateName: 'enrollment_confirmation_step', + postRender: function() { // Track a virtual pageview, for easy funnel reconstruction. window.analytics.page( 'verification', this.templateName ); @@ -27,4 +30,4 @@ var edx = edx || {}; } }); -})( jQuery ); +})(); diff --git a/lms/static/js/verify_student/views/face_photo_step_view.js b/lms/static/js/verify_student/views/face_photo_step_view.js index c962eaab8e..b2654271b2 100644 --- a/lms/static/js/verify_student/views/face_photo_step_view.js +++ b/lms/static/js/verify_student/views/face_photo_step_view.js @@ -10,6 +10,8 @@ var edx = edx || {}; edx.verify_student.FacePhotoStepView = edx.verify_student.StepView.extend({ + templateName: "face_photo_step", + defaultContext: function() { return { platformName: '' @@ -22,7 +24,8 @@ var edx = edx || {}; model: this.model, modelAttribute: 'faceImage', submitButton: '#next_step_button', - errorModel: this.errorModel + errorModel: this.errorModel, + captureSoundPath: this.stepData.captureSoundPath }).render(); // Track a virtual pageview, for easy funnel reconstruction. diff --git a/lms/static/js/verify_student/views/id_photo_step_view.js b/lms/static/js/verify_student/views/id_photo_step_view.js index 289e772017..f89bd3e7ca 100644 --- a/lms/static/js/verify_student/views/id_photo_step_view.js +++ b/lms/static/js/verify_student/views/id_photo_step_view.js @@ -10,6 +10,8 @@ var edx = edx || {}; edx.verify_student.IDPhotoStepView = edx.verify_student.StepView.extend({ + templateName: "id_photo_step", + defaultContext: function() { return { platformName: '' @@ -22,7 +24,8 @@ var edx = edx || {}; model: this.model, modelAttribute: 'identificationImage', submitButton: '#next_step_button', - errorModel: this.errorModel + errorModel: this.errorModel, + captureSoundPath: this.stepData.captureSoundPath }).render(); // Track a virtual pageview, for easy funnel reconstruction. diff --git a/lms/static/js/verify_student/views/intro_step_view.js b/lms/static/js/verify_student/views/intro_step_view.js index eaa4de4eb2..dd0e75cbc6 100644 --- a/lms/static/js/verify_student/views/intro_step_view.js +++ b/lms/static/js/verify_student/views/intro_step_view.js @@ -10,6 +10,8 @@ var edx = edx || {}; edx.verify_student.IntroStepView = edx.verify_student.StepView.extend({ + templateName: "intro_step", + defaultContext: function() { return { introTitle: '', diff --git a/lms/static/js/verify_student/views/make_payment_step_view.js b/lms/static/js/verify_student/views/make_payment_step_view.js index 68f92a11ce..20f56c03a9 100644 --- a/lms/static/js/verify_student/views/make_payment_step_view.js +++ b/lms/static/js/verify_student/views/make_payment_step_view.js @@ -10,6 +10,8 @@ var edx = edx || {}; edx.verify_student.MakePaymentStepView = edx.verify_student.StepView.extend({ + templateName: "make_payment_step", + defaultContext: function() { return { isActive: true, diff --git a/lms/static/js/verify_student/views/pay_and_verify_view.js b/lms/static/js/verify_student/views/pay_and_verify_view.js index ee410ebacc..bad7d9e487 100644 --- a/lms/static/js/verify_student/views/pay_and_verify_view.js +++ b/lms/static/js/verify_student/views/pay_and_verify_view.js @@ -83,7 +83,6 @@ var edx = edx || {}; subviewConfig = { errorModel: this.errorModel, - templateName: this.displaySteps[i].templateName, nextStepTitle: nextStepTitle, stepData: stepData }; @@ -121,8 +120,6 @@ var edx = edx || {}; } // Render the subview - // Note that this will trigger a GET request for the - // underscore template. // When the view is rendered, it will overwrite the existing // step in the DOM. stepName = this.displaySteps[ this.currentStepIndex ].name; diff --git a/lms/static/js/verify_student/views/payment_confirmation_step_view.js b/lms/static/js/verify_student/views/payment_confirmation_step_view.js index ab662aa8f9..5e581dc7d7 100644 --- a/lms/static/js/verify_student/views/payment_confirmation_step_view.js +++ b/lms/static/js/verify_student/views/payment_confirmation_step_view.js @@ -10,6 +10,8 @@ var edx = edx || {}; edx.verify_student.PaymentConfirmationStepView = edx.verify_student.StepView.extend({ + templateName: "payment_confirmation_step", + defaultContext: function() { return { courseKey: '', diff --git a/lms/static/js/verify_student/views/reverify_success_step_view.js b/lms/static/js/verify_student/views/reverify_success_step_view.js new file mode 100644 index 0000000000..6d87d12eec --- /dev/null +++ b/lms/static/js/verify_student/views/reverify_success_step_view.js @@ -0,0 +1,17 @@ +/** + * Show a message to the student that he/she has successfully + * submitted photos for reverification. + */ + + var edx = edx || {}; + + (function() { + 'use strict'; + + edx.verify_student = edx.verify_student || {}; + + edx.verify_student.ReverifySuccessStepView = edx.verify_student.StepView.extend({ + templateName: 'reverify_success_step' + }); + + })(); diff --git a/lms/static/js/verify_student/views/reverify_view.js b/lms/static/js/verify_student/views/reverify_view.js new file mode 100644 index 0000000000..10547cddd9 --- /dev/null +++ b/lms/static/js/verify_student/views/reverify_view.js @@ -0,0 +1,115 @@ +/** + * Reverification flow. + * + * This flow allows students who have a denied or expired verification + * to re-submit face and ID photos. It re-uses most of the same sub-views + * as the payment/verification flow. + * + */ + + var edx = edx || {}; + +(function($, _, Backbone, gettext) { + 'use strict'; + + edx.verify_student = edx.verify_student || {}; + + edx.verify_student.ReverifyView = Backbone.View.extend({ + el: '#reverify-container', + + stepOrder: [ + "face-photo-step", + "id-photo-step", + "review-photos-step", + "reverify-success-step" + ], + stepViews: {}, + + initialize: function( obj ) { + this.errorModel = obj.errorModel || null; + this.initializeStepViews( obj.stepInfo || {} ); + this.currentStepIndex = 0; + }, + + initializeStepViews: function( stepInfo ) { + var verificationModel, stepViewConstructors, nextStepTitles; + + // We need to initialize this here, because + // outside of this method the subview classes + // might not yet have been loaded. + stepViewConstructors = { + 'face-photo-step': edx.verify_student.FacePhotoStepView, + 'id-photo-step': edx.verify_student.IDPhotoStepView, + 'review-photos-step': edx.verify_student.ReviewPhotosStepView, + 'reverify-success-step': edx.verify_student.ReverifySuccessStepView + }; + + nextStepTitles = [ + gettext( "Take a photo of your ID" ), + gettext( "Review your info" ), + gettext( "Confirm" ), + "" + ]; + + // Create the verification model, which is shared + // among the different steps. This allows + // one step to save photos and another step + // to submit them. + verificationModel = new edx.verify_student.VerificationModel(); + + _.each(this.stepOrder, function(name, index) { + var stepView = new stepViewConstructors[name]({ + errorModel: this.errorModel, + nextStepTitle: nextStepTitles[index], + stepData: stepInfo[name], + model: verificationModel + }); + + this.listenTo(stepView, 'next-step', this.nextStep); + this.listenTo(stepView, 'go-to-step', this.goToStep); + + this.stepViews[name] = stepView; + }, this); + }, + + render: function() { + this.renderCurrentStep(); + return this; + }, + + renderCurrentStep: function() { + var stepView, stepEl; + + // Get or create the step container + stepEl = $("#current-step-container"); + if (!stepEl.length) { + stepEl = $('
').appendTo(this.el); + } + + // Render the step subview + // When the view is rendered, it will overwrite the existing step in the DOM. + stepView = this.stepViews[ this.stepOrder[ this.currentStepIndex ] ]; + stepView.el = stepEl; + stepView.render(); + }, + + nextStep: function() { + this.currentStepIndex = Math.min( + this.currentStepIndex + 1, + this.stepOrder.length - 1 + ); + this.render(); + }, + + goToStep: function( stepName ) { + var stepIndex = _.indexOf(this.stepOrder, stepName); + + if ( stepIndex >= 0 ) { + this.currentStepIndex = stepIndex; + } + + this.render(); + } + }); + +})(jQuery, _, Backbone, gettext); diff --git a/lms/static/js/verify_student/views/review_photos_step_view.js b/lms/static/js/verify_student/views/review_photos_step_view.js index b4a329abb7..a83ed906ee 100644 --- a/lms/static/js/verify_student/views/review_photos_step_view.js +++ b/lms/static/js/verify_student/views/review_photos_step_view.js @@ -10,6 +10,8 @@ var edx = edx || {}; edx.verify_student.ReviewPhotosStepView = edx.verify_student.StepView.extend({ + templateName: "review_photos_step", + defaultContext: function() { return { platformName: '', diff --git a/lms/static/js/verify_student/views/webcam_photo_view.js b/lms/static/js/verify_student/views/webcam_photo_view.js index 835f571920..ea1c84a658 100644 --- a/lms/static/js/verify_student/views/webcam_photo_view.js +++ b/lms/static/js/verify_student/views/webcam_photo_view.js @@ -212,15 +212,11 @@ }, initialize: function( obj ) { - this.mainContainer = $('#pay-and-verify-container'); - if (!this.mainContainer){ - this.mainContainer = $('#incourse-reverify-container'); - } this.submitButton = obj.submitButton || ""; this.modelAttribute = obj.modelAttribute || ""; this.errorModel = obj.errorModel || null; this.backend = this.backends[obj.backendName] || obj.backend; - this.captureSoundPath = this.mainContainer.data('capture-sound'); + this.captureSoundPath = obj.captureSoundPath || ""; this.backend.initialize({ wrapper: "#camera", diff --git a/lms/static/sass/views/_decoupled-verification.scss b/lms/static/sass/views/_decoupled-verification.scss index 6a38eb93dc..95d69833b9 100644 --- a/lms/static/sass/views/_decoupled-verification.scss +++ b/lms/static/sass/views/_decoupled-verification.scss @@ -1,6 +1,6 @@ // Updates for decoupled verification A/B test .verification-process { - .pay-and-verify, .incourse-reverify { + .pay-and-verify, .incourse-reverify, .reverify { .review { .title.center-col { padding: 0 calc( ( 100% - 750px ) / 2 ) 10px; diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 14444798e3..d108f5cf20 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -53,8 +53,7 @@ // ==================== // VIEW: all verification steps -.verification-process, -.midcourse-reverification-process { +.verification-process { // reset: box-sizing (making things so right its scary) * { @@ -555,38 +554,6 @@ // ==================== - // UI: reverification message - .wrapper-reverification { - border-bottom: ($baseline/10) solid $m-pink; - margin-bottom: $baseline; - padding-bottom: $baseline; - position: relative; - - .deco-arrow { - @include triangle($baseline, $m-pink, down); - position: absolute; - bottom: -($baseline); - @include left(50%); - } - } - - .reverification { - - .message { - - .title { - @extend %hd-lv3; - color: $m-pink; - } - - .copy { - @extend %t-copy-sub1; - } - } - } - - // ==================== - // UI: slides .carousel { @@ -1965,441 +1932,6 @@ } } - } - - // VIEW: midcourse re-verification - &.midcourse-reverification-process { - - // step-dash - - &.step-dash { - - .content-main > .title { - @extend %t-title7; - display: block; - font-weight: 600; - color: $m-gray; - } - - .wrapper-reverify-open, - .wrapper-reverify-status { - display: inline-block; - vertical-align: top; - width: 48%; - } - - .copy .title { - @extend %t-title6; - font-weight: 600; - } - - .wrapper-reverify-status .title { - @extend %t-title6; - font-weight: normal; - color: $m-gray; - } - - .action-reverify { - padding: ($baseline/2) ($baseline*0.75); - } - - .reverification-list { - @include margin-right($baseline*1.5); - padding: 0; - list-style-type: none; - - .item { - box-shadow: 0 2px 5px 0 $shadow-l1 inset; - @include margin(($baseline*0.75), ($baseline*0.75), ($baseline*0.75), 0); - border: 1px solid $m-gray-t2; - - &.complete { - border: 1px solid $success-color; - - .course-info { - opacity: .5; - - .course-name { - font-weight: normal; - } - } - - .reverify-status { - @extend %t-weight4; - border-top: 1px solid $light-gray; - background-color: $m-gray-l4; - color: $success-color; - } - } - - &.pending { - border: 1px solid $warning-color; - - .course-info { - opacity: .5; - - .course-name { - font-weight: normal; - } - } - - .reverify-status { - @extend %t-weight4; - border-top: 1px solid $light-gray; - background-color: $m-gray-l4; - color: $warning-color; - } - } - - &.failed { - border: 1px solid $alert-color; - - .course-info { - opacity: .5; - - .course-name { - font-weight: normal; - } - } - - .reverify-status { - @extend %t-weight4; - border-top: 1px solid $light-gray; - background-color: $m-gray-l4; - color: $alert-color; - } - } - } - - .course-info { - margin-bottom: ($baseline/2); - padding: ($baseline/2) ($baseline*0.75); - } - - .course-name { - @extend %t-title5; - display: block; - font-weight: bold; - } - - .deadline { - @extend %copy-detail; - display: block; - margin-top: ($baseline/4); - } - - .reverify-status { - background-color: $light-gray; - padding: ($baseline/2) ($baseline*0.75); - } - } - - .support { - margin-top: $baseline; - @extend %t-copy-sub1; - } - - .wrapper-reverification-help { - margin-top: $baseline; - border-top: 1px solid $light-gray; - padding-top: ($baseline*1.5); - - .faq-item { - display: inline-block; - vertical-align: top; - width: flex-grid(4,12); - @include padding-right($baseline); - - &:last-child { - @include padding-right(0); - } - - .faq-answer { - @extend %t-copy-sub1; - } - } - } - } - - // step-photos - &.step-photos { - - .block-photo .title { - @extend %t-title4; - color: $m-blue-d1; - } - - .wrapper-task { - @include clearfix(); - width: flex-grid(12,12); - margin: $baseline 0; - - .wrapper-help { - @include float(right); - width: flex-grid(6,12); - padding: 0 $baseline; - - .help { - margin-bottom: ($baseline*1.5); - - &:last-child { - margin-bottom: 0; - } - - .title { - @extend %hd-lv3; - } - - .copy { - @extend %copy-detail; - } - - .example { - color: $m-gray-l2; - } - - // help - general list - .list-help { - margin-top: ($baseline/2); - color: $black; - - .help-item { - margin-bottom: ($baseline/4); - border-bottom: 1px solid $m-gray-l4; - padding-bottom: ($baseline/4); - - &:last-child { - margin-bottom: 0; - border-bottom: none; - padding-bottom: 0; - } - } - - .help-item-emphasis { - @extend %t-weight4; - } - } - - // help - faq - .list-faq { - margin-bottom: $baseline; - } - } - } - - .task { - @extend %ui-window; - @include float(left); - @include margin-right(flex-gutter()); - width: flex-grid(6,12); - } - - .controls { - padding: ($baseline*0.75) $baseline; - background: $m-gray-l4; - - .list-controls { - position: relative; - } - - .control { - position: absolute; - - .action { - @extend %btn-primary-blue; - padding: ($baseline/2) ($baseline*0.75); - - .icon { - @extend %t-icon4; - padding: ($baseline*.25) ($baseline*.5); - display: block; - } - } - - // STATE: hidden - &.is-hidden { - visibility: hidden; - } - - // STATE: shown - &.is-shown { - visibility: visible; - } - - // STATE: approved - &.approved { - - .action { - @extend %btn-verify-primary; - padding: ($baseline/2) ($baseline*0.75); - } - } - } - - // control - redo - .control-redo { - position: absolute; - @include left($baseline/2); - } - - // control - take/do, retake - .control-do, .control-retake { - @include left(45%); - } - - // control - approve - .control-approve { - position: absolute; - @include right($baseline/2); - } - } - - .msg { - @include clearfix(); - margin-top: ($baseline*2); - - .copy { - @include float(left); - width: flex-grid(8,12); - @include margin-right(flex-gutter()); - } - - .list-actions { - position: relative; - top: -($baseline/2); - @include float(left); - width: flex-grid(4,12); - @include text-align(right); - - .action-retakephotos a { - @extend %btn-primary-blue; - @include font-size(14); - padding: ($baseline/2) ($baseline*0.75); - } - } - } - - .msg-followup { - border-top: ($baseline/10) solid $m-gray-t0; - padding-top: $baseline; - } - } - - .review-task { - margin-bottom: ($baseline*1.5); - padding: ($baseline*0.75) $baseline; - border-radius: ($baseline/10); - background: $m-gray-l4; - - &:last-child { - margin-bottom: 0; - } - - > .title { - @extend %hd-lv3; - } - - .copy { - @extend %copy-base; - - strong { - @extend %t-weight5; - color: $m-gray-d4; - } - } - } - - - // individual task - name - .review-task-name { - @include clearfix(); - border: 1px solid $light-gray; - - .copy { - @include float(left); - width: flex-grid(8,12); - @include margin-right(flex-gutter()); - } - - .list-actions { - position: relative; - top: -($baseline); - @include float(left); - width: flex-grid(4,12); - @include text-align(right); - - .action-editname a { - @extend %btn-primary-blue; - @include font-size(14); - padding: ($baseline/2) ($baseline*0.75); - } - } - } - - .nav-wizard { - padding: ($baseline*0.75) $baseline; - - .prompt-verify { - @include float(left); - @include margin(0, flex-gutter(), 0, 0); - width: flex-grid(6,12); - - .title { - @extend %hd-lv4; - margin-bottom: ($baseline/4); - } - - .copy { - @extend %t-copy-sub1; - @extend %t-weight3; - } - - .list-actions { - margin-top: ($baseline/2); - } - - .action-verify label { - @extend %t-copy-sub1; - } - } - - .wizard-steps { - margin-top: ($baseline/2); - - .wizard-step { - @include margin-right(flex-gutter()); - display: inline-block; - vertical-align: middle; - - &:last-child { - @include margin-right(0); - } - } - } - } - - - .modal { - - fieldset { - margin-top: $baseline; - } - - .close-modal { - @include font-size(24); - color: $m-blue-d3; - - &:hover, &:focus { - color: $m-blue-d1; - border: none; - } - } - } - - } - } - - &.step-confirmation { .instruction { display: inline-block; @@ -2425,10 +1957,39 @@ margin: $baseline 0; } } + } + .reverify-success-step { + .title { + @extend %t-title4; + text-align: left; + text-transform: none; + } + + .wrapper-actions { + margin-top: 20px; + } } } +.reverify-blocked { + + @include padding(($baseline*1.5), ($baseline*1.5), ($baseline*2), ($baseline*1.5)); + + .title { + @extend %t-title4; + text-align: left; + text-transform: none; + } + + .wrapper-actions { + margin-top: 20px; + } + + .action-primary { + @extend %btn-primary-blue; + } +} //reverify notification special styles .msg-reverify { @@ -2437,13 +1998,6 @@ } } -// UI: photo reverification heading -h2.photo_verification { - @extend %t-title1; - text-align: left; - text-transform: none; -} - .facephoto.view { .wrapper-task { #facecam { diff --git a/lms/templates/verify_student/_modal_editname.html b/lms/templates/verify_student/_modal_editname.html deleted file mode 100644 index 49f26c5bd8..0000000000 --- a/lms/templates/verify_student/_modal_editname.html +++ /dev/null @@ -1,34 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - - diff --git a/lms/templates/verify_student/_reverification_support.html b/lms/templates/verify_student/_reverification_support.html deleted file mode 100644 index bcd6039a2f..0000000000 --- a/lms/templates/verify_student/_reverification_support.html +++ /dev/null @@ -1,28 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -
- -
diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html deleted file mode 100644 index db71f588ab..0000000000 --- a/lms/templates/verify_student/_verification_header.html +++ /dev/null @@ -1,31 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -<%namespace name='static' file='../static_content.html'/> - - - -<%block name="js_extra"> - <%static:js group='rwd_header'/> - diff --git a/lms/templates/verify_student/_verification_support.html b/lms/templates/verify_student/_verification_support.html deleted file mode 100644 index 0da3cd8aa9..0000000000 --- a/lms/templates/verify_student/_verification_support.html +++ /dev/null @@ -1,21 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -
- -
diff --git a/lms/templates/verify_student/face_photo_step.underscore b/lms/templates/verify_student/face_photo_step.underscore index 89297c3805..381cbce274 100644 --- a/lms/templates/verify_student/face_photo_step.underscore +++ b/lms/templates/verify_student/face_photo_step.underscore @@ -14,7 +14,7 @@
-

<%- gettext( "Take Your Photo" ) %>

+

<%- gettext( "Take Your Photo" ) %>

<%= _.sprintf( gettext( "When your face is in position, use the camera button %(icon)s below to take your photo." ), { icon: '(icon)' } ) %>

diff --git a/lms/templates/verify_student/incourse_reverify.html b/lms/templates/verify_student/incourse_reverify.html index 5814d9cf19..a6a3144bc5 100644 --- a/lms/templates/verify_student/incourse_reverify.html +++ b/lms/templates/verify_student/incourse_reverify.html @@ -1,7 +1,5 @@ <%! -import json from django.utils.translation import ugettext as _ -from verify_student.views import PayAndVerifyView %> <%namespace name='static' file='../static_content.html'/> @@ -25,7 +23,7 @@ from verify_student.views import PayAndVerifyView - <%static:js group='reverify'/> + <%static:js group='incourse_reverify'/> <%block name="content"> diff --git a/lms/templates/verify_student/pay_and_verify.html b/lms/templates/verify_student/pay_and_verify.html index ada5813309..802ca32ac0 100644 --- a/lms/templates/verify_student/pay_and_verify.html +++ b/lms/templates/verify_student/pay_and_verify.html @@ -24,7 +24,8 @@ from verify_student.views import PayAndVerifyView <% template_names = ( ["webcam_photo", "image_input", "error"] + - [step['templateName'] for step in display_steps] + ["intro_step", "make_payment_step", "payment_confirmation_step"] + + ["face_photo_step", "id_photo_step", "review_photos_step", "enrollment_confirmation_step"] ) %> % for template_name in template_names: diff --git a/lms/templates/verify_student/photo_reverification.html b/lms/templates/verify_student/photo_reverification.html deleted file mode 100644 index 66170e7452..0000000000 --- a/lms/templates/verify_student/photo_reverification.html +++ /dev/null @@ -1,402 +0,0 @@ -<%inherit file="../main.html" /> -<%namespace name='static' file='/static_content.html'/> -<%! -from django.utils.translation import ugettext as _ -from django.core.urlresolvers import reverse -%> - -<%block name="bodyclass">register verification-process is-not-verified step-photos -<%block name="pagetitle">${_("Re-Verification")} - -<%block name="js_extra"> - - - - - - -<%block name="content"> - - - - - -%if error: -
-
- -
-

${_("Error submitting your images")}

-
-

${_("Oops! Something went wrong. Please confirm your details and try again.")}

-
-
-
-
-%endif - -
-
- -
-
-
-

${_("Verify Your Identity")}

-
- ## Translators: {start_bold} and {end_bold} will be replaced with HTML tags. - ## Please do not translate these variables. -

${_("To verify your identity and continue as a verified student in this course, complete the following steps {start_bold}before the course verification deadline{end_bold}. If you do not verify your identity, you can still receive an honor code certificate for the course.").format(start_bold="", end_bold="")}

-
-
- - -
-
- -
-
-

${_("Your Progress")}

- -
    -
  1. - 1 - ${_("Current Step: ")}${_("Re-Take Photo")} -
  2. - -
  3. - 2 - ${_("Re-Take ID Photo")} -
  4. - -
  5. - 3 - ${_("Review")} -
  6. - -
  7. - - - - ${_("Confirmation")} -
  8. -
- - - - -
-
- -
-
- - -
-
- - <%include file="_reverification_support.html" /> -
-
- -<%include file="_modal_editname.html" /> - diff --git a/lms/templates/verify_student/prompt_midcourse_reverify.html b/lms/templates/verify_student/prompt_midcourse_reverify.html deleted file mode 100644 index 4b5f9f75e7..0000000000 --- a/lms/templates/verify_student/prompt_midcourse_reverify.html +++ /dev/null @@ -1,5 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> -

${_("You need to re-verify to continue")}

-

- ${_("To continue in the ID Verified track in {course}, you need to re-verify your identity by {date}. Go to URL.").format(email)} -

diff --git a/lms/templates/verify_student/reverification_confirmation.html b/lms/templates/verify_student/reverification_confirmation.html deleted file mode 100644 index c1d09eb957..0000000000 --- a/lms/templates/verify_student/reverification_confirmation.html +++ /dev/null @@ -1,79 +0,0 @@ -<%inherit file="../main.html" /> -<%namespace name='static' file='/static_content.html'/> -<%! -from django.utils.translation import ugettext as _ -from django.core.urlresolvers import reverse -%> - -<%block name="bodyclass">register verification-process is-not-verified step-confirmation -<%block name="pagetitle">${_("Re-Verification Submission Confirmation")} - -<%block name="js_extra"> - - - -<%block name="content"> - -
-
- -
-
-

${_("Your Progress")}

- -
    -
  1. - 1 - ${_("Re-Take Photo")} -
  2. - -
  3. - 2 - ${_("Re-Take ID Photo")} -
  4. - -
  5. - 3 - ${_("Review")} -
  6. - -
  7. - - - - ${_("Current Step: ")}${_("Confirmation")} -
  8. -
- - - - -
-
- -
-
-
-
-
-

${_("Your Credentials Have Been Updated")}

- -
-

${_("We've captured your re-submitted information and will review it to verify your identity shortly. You should receive an update to your veriication status within 1-2 days. In the meantime, you still have access to all of your course content.")}

-
- -
    - -
-
-
-
-
-
- - <%include file="_reverification_support.html" /> -
-
- diff --git a/lms/templates/verify_student/reverification_window_expired.html b/lms/templates/verify_student/reverification_window_expired.html deleted file mode 100644 index 05a109682a..0000000000 --- a/lms/templates/verify_student/reverification_window_expired.html +++ /dev/null @@ -1,46 +0,0 @@ -<%inherit file="../main.html" /> -<%namespace name='static' file='/static_content.html'/> -<%! -from django.utils.translation import ugettext as _ -from django.core.urlresolvers import reverse -%> - -<%block name="bodyclass">register verification-process is-not-verified step-confirmation -<%block name="pagetitle">${_("Re-Verification Failed")} - -<%block name="js_extra"> - - - -<%block name="content"> - -
-
- -
-
-
-
-
-

${_("Re-Verification Failed")}

- -
-

${_("Your re-verification was submitted after the re-verification deadline, and you can no longer be re-verified.")}

-

${_("Please contact support if you believe this message to be in error.")}

-
- -
    - -
-
-
-
-
-
- - <%include file="_reverification_support.html" /> -
-
- diff --git a/lms/templates/verify_student/reverify.html b/lms/templates/verify_student/reverify.html new file mode 100644 index 0000000000..3f2d0db042 --- /dev/null +++ b/lms/templates/verify_student/reverify.html @@ -0,0 +1,46 @@ +<%! +from django.utils.translation import ugettext as _ +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../main.html" /> +<%block name="bodyclass">register verification-process step-requirements + +<%block name="pagetitle">${_("Re-Verification")} + +<%block name="header_extras"> + % for template_name in ["webcam_photo", "image_input", "error", "face_photo_step", "id_photo_step", "review_photos_step", "reverify_success_step"]: + + % endfor + +<%block name="js_extra"> + <%static:js group='rwd_header'/> + + + + + <%static:js group='reverify'/> + + +<%block name="content"> +## Top-level wrapper for errors +## JavaScript views may append to this wrapper + + +
+ +
+ diff --git a/lms/templates/verify_student/reverify_not_allowed.html b/lms/templates/verify_student/reverify_not_allowed.html new file mode 100644 index 0000000000..c8facb3998 --- /dev/null +++ b/lms/templates/verify_student/reverify_not_allowed.html @@ -0,0 +1,28 @@ +<%! + from django.utils.translation import ugettext as _ + from django.core.urlresolvers import reverse +%> + +<%inherit file="../main.html" /> +<%block name="pagetitle">${_("Identity Verification")} + +<%block name="content"> +
+

${_("Identity Verification")}

+ +
+

+ % if status in ["pending", "approved"]: + ${_("You have already submitted your verification information. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).")} + % else: + ${_("You cannot verify your identity at this time.")} + % endif +

+
+ + +
+ + diff --git a/lms/templates/verify_student/reverify_success_step.underscore b/lms/templates/verify_student/reverify_success_step.underscore new file mode 100644 index 0000000000..8c6e2e9fdb --- /dev/null +++ b/lms/templates/verify_student/reverify_success_step.underscore @@ -0,0 +1,11 @@ +
+

<%- gettext( "Identity Verification In Progress" ) %>

+ +
+

<%- gettext( "We have received your information and are verifying your identity. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days). In the meantime, you can still access all available course content." ) %>

+
+ + +
diff --git a/lms/urls.py b/lms/urls.py index d1bf250754..52103e5991 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -29,7 +29,6 @@ urlpatterns = ( url(r'^admin_dashboard$', 'dashboard.views.dashboard'), url(r'^email_confirm/(?P[^/]*)$', 'student.views.confirm_email_change'), - url(r'^change_name$', 'student.views.change_name_request', name="change_name"), url(r'^event$', 'track.views.user_track'), url(r'^performance$', 'performance.views.performance_log'), url(r'^segmentio/event$', 'track.views.segmentio.segmentio_event'), From cea06e1217a621cace4bdfc3879baf02edae99de Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 18 Jun 2015 09:00:46 -0400 Subject: [PATCH 19/97] Update the format of the credit provider timestamp. Use the number of seconds since the epoch (Jan 1 1970 UTC) instead of an ISO-formatted datetime string. This is easier for credit providers to parse. --- common/djangoapps/util/date_utils.py | 23 +++++++++++++- openedx/core/djangoapps/credit/api.py | 12 +++++-- openedx/core/djangoapps/credit/models.py | 6 ++-- .../core/djangoapps/credit/tests/test_api.py | 4 +-- .../djangoapps/credit/tests/test_views.py | 7 +++-- openedx/core/djangoapps/credit/views.py | 31 +++++++------------ 6 files changed, 51 insertions(+), 32 deletions(-) diff --git a/common/djangoapps/util/date_utils.py b/common/djangoapps/util/date_utils.py index 11ca25d087..fde0bfee2c 100644 --- a/common/djangoapps/util/date_utils.py +++ b/common/djangoapps/util/date_utils.py @@ -2,7 +2,7 @@ Convenience methods for working with datetime objects """ -from datetime import timedelta +from datetime import datetime, timedelta import re from pytz import timezone, UTC, UnknownTimeZoneError @@ -73,6 +73,27 @@ def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)): return abs(dt1 - dt2) < allowed_delta +def to_timestamp(datetime_value): + """ + Convert a datetime into a timestamp, represented as the number + of seconds since January 1, 1970 UTC. + """ + return int((datetime_value - datetime(1970, 1, 1, tzinfo=UTC)).total_seconds()) + + +def from_timestamp(timestamp): + """ + Convert a timestamp (number of seconds since Jan 1, 1970 UTC) + into a timezone-aware datetime. + + If the timestamp cannot be converted, returns None instead. + """ + try: + return datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + except (ValueError, TypeError): + return None + + DEFAULT_SHORT_DATE_FORMAT = "%b %d, %Y" DEFAULT_LONG_DATE_FORMAT = "%A, %B %d, %Y" DEFAULT_TIME_FORMAT = "%I:%M:%S %p" diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index ebec6b977d..cf8287e217 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -4,9 +4,13 @@ Contains the APIs for course credit requirements. import logging import uuid +import datetime + +import pytz from django.db import transaction +from util.date_utils import to_timestamp from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -191,7 +195,7 @@ def create_credit_request(course_key, provider_id, username): "method": "POST", "parameters": { "request_uuid": "557168d0f7664fe59097106c67c3f847", - "timestamp": "2015-05-04T20:57:57.987119+00:00", + "timestamp": 1434631630, "course_org": "HogwartsX", "course_num": "Potions101", "course_run": "1T2015", @@ -263,6 +267,8 @@ def create_credit_request(course_key, provider_id, username): if created: credit_request.uuid = uuid.uuid4().hex + else: + credit_request.timestamp = datetime.datetime.now(pytz.UTC) # Retrieve user account and profile info user = User.objects.select_related('profile').get(username=username) @@ -285,7 +291,7 @@ def create_credit_request(course_key, provider_id, username): parameters = { "request_uuid": credit_request.uuid, - "timestamp": credit_request.timestamp.isoformat(), + "timestamp": to_timestamp(credit_request.timestamp), "course_org": course_key.org, "course_num": course_key.course, "course_run": course_key.run, @@ -391,7 +397,7 @@ def get_credit_requests_for_user(username): [ { "uuid": "557168d0f7664fe59097106c67c3f847", - "timestamp": "2015-05-04T20:57:57.987119+00:00", + "timestamp": 1434631630, "course_key": "course-v1:HogwartsX+Potions101+1T2015", "provider": { "id": "HogwartsX", diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index 2e273be16f..3d10723d73 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -13,8 +13,8 @@ from django.db import transaction from django.core.validators import RegexValidator from simple_history.models import HistoricalRecords - from jsonfield.fields import JSONField +from util.date_utils import to_timestamp from model_utils.models import TimeStampedModel from xmodule_django.models import CourseKeyField from django.utils.translation import ugettext_lazy @@ -378,7 +378,7 @@ class CreditRequest(TimeStampedModel): [ { "uuid": "557168d0f7664fe59097106c67c3f847", - "timestamp": "2015-05-04T20:57:57.987119+00:00", + "timestamp": 1434631630, "course_key": "course-v1:HogwartsX+Potions101+1T2015", "provider": { "id": "HogwartsX", @@ -393,7 +393,7 @@ class CreditRequest(TimeStampedModel): return [ { "uuid": request.uuid, - "timestamp": request.modified, + "timestamp": to_timestamp(request.modified), "course_key": request.course.course_key, "provider": { "id": request.provider.provider_id, diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 2a8da65ab3..91a902a3f2 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -5,7 +5,6 @@ Tests for the API functions in the credit app. import datetime import ddt import pytz -import dateutil.parser as date_parser from django.test import TestCase from django.test.utils import override_settings from django.db import connection, transaction @@ -13,6 +12,7 @@ from django.db import connection, transaction from opaque_keys.edx.keys import CourseKey from student.tests.factories import UserFactory +from util.date_utils import from_timestamp from openedx.core.djangoapps.credit import api from openedx.core.djangoapps.credit.exceptions import ( InvalidCreditRequirements, @@ -340,7 +340,7 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): # Validate the timestamp self.assertIn('timestamp', parameters) - parsed_date = date_parser.parse(parameters['timestamp']) + parsed_date = from_timestamp(parameters['timestamp']) self.assertTrue(parsed_date < datetime.datetime.now(pytz.UTC)) # Validate course information diff --git a/openedx/core/djangoapps/credit/tests/test_views.py b/openedx/core/djangoapps/credit/tests/test_views.py index 558b7a2a0b..ec7a8ba67c 100644 --- a/openedx/core/djangoapps/credit/tests/test_views.py +++ b/openedx/core/djangoapps/credit/tests/test_views.py @@ -15,6 +15,7 @@ from django.conf import settings from student.tests.factories import UserFactory from util.testing import UrlResetMixin +from util.date_utils import to_timestamp from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.credit import api from openedx.core.djangoapps.credit.signature import signature @@ -186,8 +187,8 @@ class CreditProviderViewTests(UrlResetMixin, TestCase): # Simulate a callback from the credit provider with a timestamp too far in the past # (slightly more than 15 minutes) # Since the message isn't timely, respond with a 403. - timestamp = datetime.datetime.now(pytz.UTC) - datetime.timedelta(0, 60 * 15 + 1) - response = self._credit_provider_callback(request_uuid, "approved", timestamp=timestamp.isoformat()) + timestamp = to_timestamp(datetime.datetime.now(pytz.UTC) - datetime.timedelta(0, 60 * 15 + 1)) + response = self._credit_provider_callback(request_uuid, "approved", timestamp=timestamp) self.assertEqual(response.status_code, 403) def test_credit_provider_callback_is_idempotent(self): @@ -311,7 +312,7 @@ class CreditProviderViewTests(UrlResetMixin, TestCase): """ provider_id = kwargs.get("provider_id", self.PROVIDER_ID) secret_key = kwargs.get("secret_key", TEST_CREDIT_PROVIDER_SECRET_KEY) - timestamp = kwargs.get("timestamp", datetime.datetime.now(pytz.UTC).isoformat()) + timestamp = kwargs.get("timestamp", to_timestamp(datetime.datetime.now(pytz.UTC))) url = reverse("credit:provider_callback", args=[provider_id]) diff --git a/openedx/core/djangoapps/credit/views.py b/openedx/core/djangoapps/credit/views.py index de9a86d1b6..976477616d 100644 --- a/openedx/core/djangoapps/credit/views.py +++ b/openedx/core/djangoapps/credit/views.py @@ -4,8 +4,6 @@ Views for the credit Django app. import json import datetime import logging - -import dateutil import pytz from django.http import ( @@ -21,6 +19,7 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys import InvalidKeyError from util.json_request import JsonResponse +from util.date_utils import from_timestamp from openedx.core.djangoapps.credit import api from openedx.core.djangoapps.credit.signature import signature, get_shared_secret_key from openedx.core.djangoapps.credit.exceptions import CreditApiBadRequest, CreditRequestNotFound @@ -57,7 +56,7 @@ def create_credit_request(request, provider_id): "method": "POST", "parameters": { request_uuid: "557168d0f7664fe59097106c67c3f847" - timestamp: "2015-05-04T20:57:57.987119+00:00" + timestamp: 1434631630, course_org: "ASUx" course_num: "DemoX" course_run: "1T2015" @@ -139,7 +138,7 @@ def credit_provider_callback(request, provider_id): { "request_uuid": "557168d0f7664fe59097106c67c3f847", "status": "approved", - "timestamp": "2015-05-04T20:57:57.987119+00:00", + "timestamp": 1434631630, "signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI=" } @@ -151,8 +150,8 @@ def credit_provider_callback(request, provider_id): * status (string): Either "approved" or "rejected". - * timestamp (string): The datetime at which the POST request was made, in ISO 8601 format. - This will always include time-zone information. + * timestamp (int): The datetime at which the POST request was made, represented + as the number of seconds since January 1, 1970 00:00:00 UTC. * signature (string): A digital signature of the request parameters, created using a secret key shared with the credit provider. @@ -253,29 +252,21 @@ def _validate_signature(parameters, provider_id): return HttpResponseForbidden("Invalid signature.") -def _validate_timestamp(timestamp_str, provider_id): +def _validate_timestamp(timestamp_value, provider_id): """ Check that the timestamp of the request is recent. Arguments: - timestamp_str (str): ISO-8601 datetime formatted string. + timestamp (int): Number of seconds since Jan. 1, 1970 UTC. provider_id (unicode): Identifier for the credit provider. Returns: HttpResponse or None """ - # If we can't parse the datetime string, reject the request. - try: - # dateutil's parser has some counter-intuitive behavior: - # for example, given an empty string or "a" it always returns the current datetime. - # It is the responsibility of the credit provider to send a valid ISO-8601 datetime - # so we can validate it; otherwise, this check might not take effect. - # (Note that the signature check ensures that the timestamp we receive hasn't - # been tampered with after being issued by the credit provider). - timestamp = dateutil.parser.parse(timestamp_str) - except ValueError: - msg = u'"{timestamp}" is not an ISO-8601 formatted datetime'.format(timestamp=timestamp_str) + timestamp = from_timestamp(timestamp_value) + if timestamp is None: + msg = u'"{timestamp}" is not a valid timestamp'.format(timestamp=timestamp_value) log.warning(msg) return HttpResponseBadRequest(msg) @@ -287,6 +278,6 @@ def _validate_timestamp(timestamp_str, provider_id): u'Timestamp %s is too far in the past (%s seconds), ' u'so we are rejecting the notification from the credit provider "%s".' ), - timestamp_str, elapsed_seconds, provider_id, + timestamp_value, elapsed_seconds, provider_id, ) return HttpResponseForbidden(u"Timestamp is too far in the past.") From c426f5506b148b37db92f62380d2338049672e9a Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 22 Jun 2015 10:03:22 -0700 Subject: [PATCH 20/97] Respond to code review feedback. --- openedx/core/djangoapps/credit/api.py | 4 +- .../0008_delete_credit_provider_timestamp.py | 145 ++++++++++++++++++ openedx/core/djangoapps/credit/models.py | 4 +- 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 openedx/core/djangoapps/credit/migrations/0008_delete_credit_provider_timestamp.py diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index cf8287e217..d678c1d91a 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -267,8 +267,6 @@ def create_credit_request(course_key, provider_id, username): if created: credit_request.uuid = uuid.uuid4().hex - else: - credit_request.timestamp = datetime.datetime.now(pytz.UTC) # Retrieve user account and profile info user = User.objects.select_related('profile').get(username=username) @@ -291,7 +289,7 @@ def create_credit_request(course_key, provider_id, username): parameters = { "request_uuid": credit_request.uuid, - "timestamp": to_timestamp(credit_request.timestamp), + "timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)), "course_org": course_key.org, "course_num": course_key.course, "course_run": course_key.run, diff --git a/openedx/core/djangoapps/credit/migrations/0008_delete_credit_provider_timestamp.py b/openedx/core/djangoapps/credit/migrations/0008_delete_credit_provider_timestamp.py new file mode 100644 index 0000000000..6fa6b68501 --- /dev/null +++ b/openedx/core/djangoapps/credit/migrations/0008_delete_credit_provider_timestamp.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Deleting field 'CreditRequest.timestamp' + db.delete_column('credit_creditrequest', 'timestamp') + + # Deleting field 'HistoricalCreditRequest.timestamp' + db.delete_column('credit_historicalcreditrequest', 'timestamp') + + def backwards(self, orm): + # Adding field 'CreditRequest.timestamp' + db.add_column('credit_creditrequest', 'timestamp', + self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, default=datetime.datetime.utcnow(), blank=True), + keep_default=False) + + # Adding field 'HistoricalCreditRequest.timestamp' + db.add_column('credit_historicalcreditrequest', 'timestamp', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.utcnow(), blank=True), + keep_default=False) + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'credit.creditcourse': { + 'Meta': {'object_name': 'CreditCourse'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'providers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['credit.CreditProvider']", 'symmetrical': 'False'}) + }, + 'credit.crediteligibility': { + 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditProvider']"}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.creditprovider': { + 'Meta': {'object_name': 'CreditProvider'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'eligibility_duration': ('django.db.models.fields.PositiveIntegerField', [], {'default': '31556970'}), + 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}) + }, + 'credit.creditrequest': { + 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}) + }, + 'credit.creditrequirement': { + 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'criteria': ('jsonfield.fields.JSONField', [], {}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'credit.creditrequirementstatus': { + 'Meta': {'object_name': 'CreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequest': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}) + } + } + + complete_apps = ['credit'] diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index 3d10723d73..9187be4b01 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -14,7 +14,6 @@ from django.core.validators import RegexValidator from simple_history.models import HistoricalRecords from jsonfield.fields import JSONField -from util.date_utils import to_timestamp from model_utils.models import TimeStampedModel from xmodule_django.models import CourseKeyField from django.utils.translation import ugettext_lazy @@ -343,7 +342,6 @@ class CreditRequest(TimeStampedModel): username = models.CharField(max_length=255, db_index=True) course = models.ForeignKey(CreditCourse, related_name="credit_requests") provider = models.ForeignKey(CreditProvider, related_name="credit_requests") - timestamp = models.DateTimeField(auto_now_add=True) parameters = JSONField() REQUEST_STATUS_PENDING = "pending" @@ -393,7 +391,7 @@ class CreditRequest(TimeStampedModel): return [ { "uuid": request.uuid, - "timestamp": to_timestamp(request.modified), + "timestamp": request.parameters.get("timestamp"), "course_key": request.course.course_key, "provider": { "id": request.provider.provider_id, From 356cdeecb4280e4a2de54564fc574c1011c6bf40 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 22 Jun 2015 13:22:17 -0700 Subject: [PATCH 21/97] Fix JS tests that broke due to a conflict between merges --- lms/static/js/spec/verify_student/reverify_view_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/static/js/spec/verify_student/reverify_view_spec.js b/lms/static/js/spec/verify_student/reverify_view_spec.js index 68b41ab285..52399d47c9 100644 --- a/lms/static/js/spec/verify_student/reverify_view_spec.js +++ b/lms/static/js/spec/verify_student/reverify_view_spec.js @@ -1,7 +1,7 @@ /** * Tests for the reverification view. **/ -define(['jquery', 'js/common_helpers/template_helpers', 'js/verify_student/views/reverify_view'], +define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/views/reverify_view'], function( $, TemplateHelpers, ReverifyView ) { 'use strict'; From f78d45080dfe793c5944e265dbd629668c82e3ee Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Mon, 22 Jun 2015 16:25:52 -0400 Subject: [PATCH 22/97] Correct logistration GET parameter preservation test case --- lms/djangoapps/student_account/test/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index ed8a10aaae..5c46647dfa 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -239,7 +239,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) @ddt.data( (False, "account_login"), - (False, "account_login"), + (False, "account_register"), (True, "account_login"), (True, "account_register"), ) From 328ed9270f0f996e8fa1b3ad66f7e34e81ceef0d Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Wed, 17 Jun 2015 15:26:51 -0400 Subject: [PATCH 23/97] Add new testing decorator check_mongo_calls_range --- .../xmodule/modulestore/tests/factories.py | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 85d8e7f371..f1649fc1e5 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -380,6 +380,37 @@ def mongo_uses_error_check(store): return False +@contextmanager +def check_mongo_calls_range(max_finds=float("inf"), min_finds=0, max_sends=None, min_sends=None): + """ + Instruments the given store to count the number of calls to find (incl find_one) and the number + of calls to send_message which is for insert, update, and remove (if you provide num_sends). At the + end of the with statement, it compares the counts to the bounds provided in the arguments. + + :param max_finds: the maximum number of find calls expected + :param min_finds: the minimum number of find calls expected + :param max_sends: If non-none, make sure number of send calls are <=max_sends + :param min_sends: If non-none, make sure number of send calls are >=min_sends + """ + with check_sum_of_calls( + pymongo.message, + ['query', 'get_more'], + max_finds, + min_finds, + ): + if max_sends is not None or min_sends is not None: + with check_sum_of_calls( + pymongo.message, + # mongo < 2.6 uses insert, update, delete and _do_batched_insert. >= 2.6 _do_batched_write + ['insert', 'update', 'delete', '_do_batched_write_command', '_do_batched_insert', ], + max_sends if max_sends is not None else float("inf"), + min_sends if min_sends is not None else 0, + ): + yield + else: + yield + + @contextmanager def check_mongo_calls(num_finds=0, num_sends=None): """ @@ -391,24 +422,8 @@ def check_mongo_calls(num_finds=0, num_sends=None): :param num_sends: If none, don't instrument the send calls. If non-none, count and compare to the given int value. """ - with check_sum_of_calls( - pymongo.message, - ['query', 'get_more'], - num_finds, - num_finds - ): - if num_sends is not None: - with check_sum_of_calls( - pymongo.message, - # mongo < 2.6 uses insert, update, delete and _do_batched_insert. >= 2.6 _do_batched_write - ['insert', 'update', 'delete', '_do_batched_write_command', '_do_batched_insert', ], - num_sends, - num_sends - ): - yield - else: - yield - + with check_mongo_calls_range(num_finds, num_finds, num_sends, num_sends): + yield # This dict represents the attribute keys for a course's 'about' info. # Note: The 'video' attribute is intentionally excluded as it must be From 1726c136fad5f066115ad532f9b9ec5964507789 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 15 Jun 2015 15:54:37 -0400 Subject: [PATCH 24/97] MA-772 create app course_overviews for caching course metadata --- cms/envs/common.py | 1 + common/djangoapps/student/models.py | 8 + .../xmodule/xmodule/course_metadata_utils.py | 210 ++++++++++++++ common/lib/xmodule/xmodule/course_module.py | 96 +++---- .../lib/xmodule/xmodule/modulestore/django.py | 6 +- .../xmodule/modulestore/tests/factories.py | 18 +- .../tests/test_course_metadata_utils.py | 200 ++++++++++++++ .../xmodule/tests/test_course_module.py | 50 ++++ common/lib/xmodule/xmodule/x_module.py | 10 +- lms/envs/common.py | 1 + .../content/course_overviews/__init__.py | 28 ++ .../migrations/0001_initial.py | 70 +++++ .../course_overviews/migrations/__init__.py | 0 .../content/course_overviews/models.py | 243 +++++++++++++++++ .../content/course_overviews/signals.py | 17 ++ .../content/course_overviews/tests.py | 258 ++++++++++++++++++ 16 files changed, 1138 insertions(+), 78 deletions(-) create mode 100644 common/lib/xmodule/xmodule/course_metadata_utils.py create mode 100644 common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py create mode 100644 openedx/core/djangoapps/content/course_overviews/__init__.py create mode 100644 openedx/core/djangoapps/content/course_overviews/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/content/course_overviews/migrations/__init__.py create mode 100644 openedx/core/djangoapps/content/course_overviews/models.py create mode 100644 openedx/core/djangoapps/content/course_overviews/signals.py create mode 100644 openedx/core/djangoapps/content/course_overviews/tests.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 0057b86f22..084eb4103c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -754,6 +754,7 @@ INSTALLED_APPS = ( # Additional problem types 'edx_jsme', # Molecular Structure + 'openedx.core.djangoapps.content.course_overviews', 'openedx.core.djangoapps.content.course_structures', # Credit courses diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 18c4bcc550..5335b73e7e 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1315,6 +1315,14 @@ class CourseEnrollment(models.Model): def course(self): return modulestore().get_course(self.course_id) + @property + def course_overview(self): + """ + Return a CourseOverview of this enrollment's course. + """ + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + return CourseOverview.get_from_id(self.course_id) + def is_verified_enrollment(self): """ Check the course enrollment mode is verified or not diff --git a/common/lib/xmodule/xmodule/course_metadata_utils.py b/common/lib/xmodule/xmodule/course_metadata_utils.py new file mode 100644 index 0000000000..07e6d384cb --- /dev/null +++ b/common/lib/xmodule/xmodule/course_metadata_utils.py @@ -0,0 +1,210 @@ +""" +Simple utility functions that operate on course metadata. + +This is a place to put simple functions that operate on course metadata. It +allows us to share code between the CourseDescriptor and CourseOverview +classes, which both need these type of functions. +""" +from datetime import datetime +from base64 import b32encode + +from django.utils.timezone import UTC + +from .fields import Date + +DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC()) + + +def clean_course_key(course_key, padding_char): + """ + Encode a course's key into a unique, deterministic base32-encoded ID for + the course. + + Arguments: + course_key (CourseKey): A course key. + padding_char (str): Character used for padding at end of the encoded + string. The standard value for this is '='. + """ + return "course_{}".format( + b32encode(unicode(course_key)).replace('=', padding_char) + ) + + +def url_name_for_course_location(location): + """ + Given a course's usage locator, returns the course's URL name. + + Arguments: + location (BlockUsageLocator): The course's usage locator. + """ + return location.name + + +def display_name_with_default(course): + """ + Calculates the display name for a course. + + Default to the display_name if it isn't None, else fall back to creating + a name based on the URL. + + Unlike the rest of this module's functions, this function takes an entire + course descriptor/overview as a parameter. This is because a few test cases + (specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view) + create scenarios where course.display_name is not None but course.location + is None, which causes calling course.url_name to fail. So, although we'd + like to just pass course.display_name and course.url_name as arguments to + this function, we can't do so without breaking those tests. + + Arguments: + course (CourseDescriptor|CourseOverview): descriptor or overview of + said course. + """ + # TODO: Consider changing this to use something like xml.sax.saxutils.escape + return ( + course.display_name if course.display_name is not None + else course.url_name.replace('_', ' ') + ).replace('<', '<').replace('>', '>') + + +def number_for_course_location(location): + """ + Given a course's block usage locator, returns the course's number. + + This is a "number" in the sense of the "course numbers" that you see at + lots of universities. For example, given a course + "Intro to Computer Science" with the course key "edX/CS-101/2014", the + course number would be "CS-101" + + Arguments: + location (BlockUsageLocator): The usage locator of the course in + question. + """ + return location.course + + +def has_course_started(start_date): + """ + Given a course's start datetime, returns whether the current time's past it. + + Arguments: + start_date (datetime): The start datetime of the course in question. + """ + # TODO: This will throw if start_date is None... consider changing this behavior? + return datetime.now(UTC()) > start_date + + +def has_course_ended(end_date): + """ + Given a course's end datetime, returns whether + (a) it is not None, and + (b) the current time is past it. + + Arguments: + end_date (datetime): The end datetime of the course in question. + """ + return datetime.now(UTC()) > end_date if end_date is not None else False + + +def course_start_date_is_default(start, advertised_start): + """ + Returns whether a course's start date hasn't yet been set. + + Arguments: + start (datetime): The start datetime of the course in question. + advertised_start (str): The advertised start date of the course + in question. + """ + return advertised_start is None and start == DEFAULT_START_DATE + + +def _datetime_to_string(date_time, format_string, strftime_localized): + """ + Formats the given datetime with the given function and format string. + + Adds UTC to the resulting string if the format is DATE_TIME or TIME. + + Arguments: + date_time (datetime): the datetime to be formatted + format_string (str): the date format type, as passed to strftime + strftime_localized ((datetime, str) -> str): a nm localized string + formatting function + """ + # TODO: Is manually appending UTC really the right thing to do here? What if date_time isn't UTC? + result = strftime_localized(date_time, format_string) + return ( + result + u" UTC" if format_string in ['DATE_TIME', 'TIME'] + else result + ) + + +def course_start_datetime_text(start_date, advertised_start, format_string, ugettext, strftime_localized): + """ + Calculates text to be shown to user regarding a course's start + datetime in UTC. + + Prefers .advertised_start, then falls back to .start. + + Arguments: + start_date (datetime): the course's start datetime + advertised_start (str): the course's advertised start date + format_string (str): the date format type, as passed to strftime + ugettext ((str) -> str): a text localization function + strftime_localized ((datetime, str) -> str): a localized string + formatting function + """ + if advertised_start is not None: + # TODO: This will return an empty string if advertised_start == ""... consider changing this behavior? + try: + # from_json either returns a Date, returns None, or raises a ValueError + parsed_advertised_start = Date().from_json(advertised_start) + except ValueError: + parsed_advertised_start = None + return ( + _datetime_to_string(parsed_advertised_start, format_string, strftime_localized) if parsed_advertised_start + else advertised_start.title() + ) + elif start_date != DEFAULT_START_DATE: + return _datetime_to_string(start_date, format_string, strftime_localized) + else: + _ = ugettext + # Translators: TBD stands for 'To Be Determined' and is used when a course + # does not yet have an announced start date. + return _('TBD') + + +def course_end_datetime_text(end_date, format_string, strftime_localized): + """ + Returns a formatted string for a course's end date or datetime. + + If end_date is None, an empty string will be returned. + + Arguments: + end_date (datetime): the end datetime of a course + format_string (str): the date format type, as passed to strftime + strftime_localized ((datetime, str) -> str): a localized string + formatting function + """ + return ( + _datetime_to_string(end_date, format_string, strftime_localized) if end_date is not None + else '' + ) + + +def may_certify_for_course(certificates_display_behavior, certificates_show_before_end, has_ended): + """ + Returns whether it is acceptable to show the student a certificate download + link for a course. + + Arguments: + certificates_display_behavior (str): string describing the course's + certificate display behavior. + See CourseFields.certificates_display_behavior.help for more detail. + certificates_show_before_end (bool): whether user can download the + course's certificates before the course has ended. + has_ended (bool): Whether the course has ended. + """ + show_early = ( + certificates_display_behavior in ('early_with_info', 'early_no_info') + or certificates_show_before_end + ) + return show_early or has_ended diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index a0334fdd89..832791ba59 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -10,8 +10,9 @@ import requests from datetime import datetime import dateutil.parser from lazy import lazy -from base64 import b32encode +from xmodule import course_metadata_utils +from xmodule.course_metadata_utils import DEFAULT_START_DATE from xmodule.exceptions import UndefinedContext from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.graders import grader_from_conf @@ -29,8 +30,6 @@ log = logging.getLogger(__name__) # Make '_' a no-op so we can scrape strings _ = lambda text: text -DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC()) - CATALOG_VISIBILITY_CATALOG_AND_ABOUT = "both" CATALOG_VISIBILITY_ABOUT = "about" CATALOG_VISIBILITY_NONE = "none" @@ -1089,20 +1088,20 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): Returns True if the current time is after the specified course end date. Returns False if there is no end date specified. """ - if self.end is None: - return False - - return datetime.now(UTC()) > self.end + return course_metadata_utils.has_course_ended(self.end) def may_certify(self): """ - Return True if it is acceptable to show the student a certificate download link + Return whether it is acceptable to show the student a certificate download link. """ - show_early = self.certificates_display_behavior in ('early_with_info', 'early_no_info') or self.certificates_show_before_end - return show_early or self.has_ended() + return course_metadata_utils.may_certify_for_course( + self.certificates_display_behavior, + self.certificates_show_before_end, + self.has_ended() + ) def has_started(self): - return datetime.now(UTC()) > self.start + return course_metadata_utils.has_course_started(self.start) @property def grader(self): @@ -1361,36 +1360,13 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): then falls back to .start """ i18n = self.runtime.service(self, "i18n") - _ = i18n.ugettext - strftime = i18n.strftime - - def try_parse_iso_8601(text): - try: - result = Date().from_json(text) - if result is None: - result = text.title() - else: - result = strftime(result, format_string) - if format_string == "DATE_TIME": - result = self._add_timezone_string(result) - except ValueError: - result = text.title() - - return result - - if isinstance(self.advertised_start, basestring): - return try_parse_iso_8601(self.advertised_start) - elif self.start_date_is_still_default: - # Translators: TBD stands for 'To Be Determined' and is used when a course - # does not yet have an announced start date. - return _('TBD') - else: - when = self.advertised_start or self.start - - if format_string == "DATE_TIME": - return self._add_timezone_string(strftime(when, format_string)) - - return strftime(when, format_string) + return course_metadata_utils.course_start_datetime_text( + self.start, + self.advertised_start, + format_string, + i18n.ugettext, + i18n.strftime + ) @property def start_date_is_still_default(self): @@ -1398,26 +1374,20 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): Checks if the start date set for the course is still default, i.e. .start has not been modified, and .advertised_start has not been set. """ - return self.advertised_start is None and self.start == CourseFields.start.default + return course_metadata_utils.course_start_date_is_default( + self.start, + self.advertised_start + ) def end_datetime_text(self, format_string="SHORT_DATE"): """ Returns the end date or date_time for the course formatted as a string. - - If the course does not have an end date set (course.end is None), an empty string will be returned. """ - if self.end is None: - return '' - else: - strftime = self.runtime.service(self, "i18n").strftime - date_time = strftime(self.end, format_string) - return date_time if format_string == "SHORT_DATE" else self._add_timezone_string(date_time) - - def _add_timezone_string(self, date_time): - """ - Adds 'UTC' string to the end of start/end date and time texts. - """ - return date_time + u" UTC" + return course_metadata_utils.course_end_datetime_text( + self.end, + format_string, + self.runtime.service(self, "i18n").strftime + ) def get_discussion_blackout_datetimes(self): """ @@ -1458,7 +1428,15 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): @property def number(self): - return self.location.course + """ + Returns this course's number. + + This is a "number" in the sense of the "course numbers" that you see at + lots of universities. For example, given a course + "Intro to Computer Science" with the course key "edX/CS-101/2014", the + course number would be "CS-101" + """ + return course_metadata_utils.number_for_course_location(self.location) @property def display_number_with_default(self): @@ -1499,9 +1477,7 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): Returns a unique deterministic base32-encoded ID for the course. The optional padding_char parameter allows you to override the "=" character used for padding. """ - return "course_{}".format( - b32encode(unicode(self.location.course_key)).replace('=', padding_char) - ) + return course_metadata_utils.clean_course_key(self.location.course_key, padding_char) @property def teams_enabled(self): diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index 08654425b3..e851f43c71 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -75,11 +75,11 @@ class SignalHandler(object): 1. We receive using the Django Signals mechanism. 2. The sender is going to be the class of the modulestore sending it. - 3. Always have **kwargs in your signal handler, as new things may be added. - 4. The thing that listens for the signal lives in process, but should do + 3. The names of your handler function's parameters *must* be "sender" and "course_key". + 4. Always have **kwargs in your signal handler, as new things may be added. + 5. The thing that listens for the signal lives in process, but should do almost no work. Its main job is to kick off the celery task that will do the actual work. - """ course_published = django.dispatch.Signal(providing_args=["course_key"]) library_updated = django.dispatch.Signal(providing_args=["library_key"]) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index f1649fc1e5..cea8c2ebc4 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -393,18 +393,18 @@ def check_mongo_calls_range(max_finds=float("inf"), min_finds=0, max_sends=None, :param min_sends: If non-none, make sure number of send calls are >=min_sends """ with check_sum_of_calls( - pymongo.message, - ['query', 'get_more'], - max_finds, - min_finds, + pymongo.message, + ['query', 'get_more'], + max_finds, + min_finds, ): if max_sends is not None or min_sends is not None: with check_sum_of_calls( - pymongo.message, - # mongo < 2.6 uses insert, update, delete and _do_batched_insert. >= 2.6 _do_batched_write - ['insert', 'update', 'delete', '_do_batched_write_command', '_do_batched_insert', ], - max_sends if max_sends is not None else float("inf"), - min_sends if min_sends is not None else 0, + pymongo.message, + # mongo < 2.6 uses insert, update, delete and _do_batched_insert. >= 2.6 _do_batched_write + ['insert', 'update', 'delete', '_do_batched_write_command', '_do_batched_insert', ], + max_sends if max_sends is not None else float("inf"), + min_sends if min_sends is not None else 0, ): yield else: diff --git a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py new file mode 100644 index 0000000000..60317d02fd --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py @@ -0,0 +1,200 @@ +""" +Tests for course_metadata_utils. +""" +from collections import namedtuple +from datetime import timedelta, datetime +from unittest import TestCase + +from django.utils.timezone import UTC +from django.utils.translation import ugettext + +from xmodule.course_metadata_utils import ( + clean_course_key, + url_name_for_course_location, + display_name_with_default, + number_for_course_location, + has_course_started, + has_course_ended, + DEFAULT_START_DATE, + course_start_date_is_default, + course_start_datetime_text, + course_end_datetime_text, + may_certify_for_course, +) +from xmodule.fields import Date +from xmodule.modulestore.tests.test_cross_modulestore_import_export import ( + MongoModulestoreBuilder, + VersioningModulestoreBuilder, + MixedModulestoreBuilder +) + + +_TODAY = datetime.now(UTC()) +_LAST_MONTH = _TODAY - timedelta(days=30) +_LAST_WEEK = _TODAY - timedelta(days=7) +_NEXT_WEEK = _TODAY + timedelta(days=7) + + +class CourseMetadataUtilsTestCase(TestCase): + """ + Tests for course_metadata_utils. + """ + + def setUp(self): + """ + Set up module store testing capabilities and initialize test courses. + """ + super(CourseMetadataUtilsTestCase, self).setUp() + + mongo_builder = MongoModulestoreBuilder() + split_builder = VersioningModulestoreBuilder() + mixed_builder = MixedModulestoreBuilder([('mongo', mongo_builder), ('split', split_builder)]) + + with mixed_builder.build_without_contentstore() as (__, mixed_store): + with mixed_store.default_store('mongo'): + self.demo_course = mixed_store.create_course( + org="edX", + course="DemoX.1", + run="Fall_2014", + user_id=-3, # -3 refers to a "testing user" + fields={ + "start": _LAST_MONTH, + "end": _LAST_WEEK + } + ) + with mixed_store.default_store('split'): + self.html_course = mixed_store.create_course( + org="UniversityX", + course="CS-203", + run="Y2096", + user_id=-3, # -3 refers to a "testing user" + fields={ + "start": _NEXT_WEEK, + "display_name": "Intro to " + } + ) + + def test_course_metadata_utils(self): + """ + Test every single function in course_metadata_utils. + """ + + def mock_strftime_localized(date_time, format_string): + """ + Mock version of strftime_localized used for testing purposes. + + Because we don't have a real implementation of strftime_localized + to work with (strftime_localized is provided by the XBlock runtime, + which we don't have access to for this test case), we must declare + this dummy implementation. This does NOT behave like a real + strftime_localized should. It purposely returns a really dumb value + that's only useful for testing purposes. + + Arguments: + date_time (datetime): datetime to be formatted. + format_string (str): format specifier. Valid values include: + - 'DATE_TIME' + - 'TIME' + - 'SHORT_DATE' + - 'LONG_DATE' + + Returns (str): format_string + " " + str(date_time) + """ + if format_string in ['DATE_TIME', 'TIME', 'SHORT_DATE', 'LONG_DATE']: + return format_string + " " + str(date_time) + else: + raise ValueError("Invalid format string :" + format_string) + + test_datetime = datetime(1945, 02, 06, 04, 20, 00, tzinfo=UTC()) + advertised_start_parsable = "2038-01-19 03:14:07" + advertised_start_unparsable = "This coming fall" + + FunctionTest = namedtuple('FunctionTest', 'function scenarios') # pylint: disable=invalid-name + TestScenario = namedtuple('TestScenario', 'arguments expected_return') # pylint: disable=invalid-name + + function_tests = [ + FunctionTest(clean_course_key, [ + TestScenario( + (self.demo_course.id, '='), + "course_MVSFQL2EMVWW6WBOGEXUMYLMNRPTEMBRGQ======" + ), + TestScenario( + (self.html_course.id, '~'), + "course_MNXXK4TTMUWXMMJ2KVXGS5TFOJZWS5DZLAVUGUZNGIYDGK2ZGIYDSNQ~" + ), + ]), + FunctionTest(url_name_for_course_location, [ + TestScenario((self.demo_course.location,), self.demo_course.location.name), + TestScenario((self.html_course.location,), self.html_course.location.name), + ]), + FunctionTest(display_name_with_default, [ + TestScenario((self.demo_course,), "Empty"), + TestScenario((self.html_course,), "Intro to <html>"), + ]), + FunctionTest(number_for_course_location, [ + TestScenario((self.demo_course.location,), "DemoX.1"), + TestScenario((self.html_course.location,), "CS-203"), + ]), + FunctionTest(has_course_started, [ + TestScenario((self.demo_course.start,), True), + TestScenario((self.html_course.start,), False), + ]), + FunctionTest(has_course_ended, [ + TestScenario((self.demo_course.end,), True), + TestScenario((self.html_course.end,), False), + ]), + FunctionTest(course_start_date_is_default, [ + TestScenario((test_datetime, advertised_start_parsable), False), + TestScenario((test_datetime, None), False), + TestScenario((DEFAULT_START_DATE, advertised_start_parsable), False), + TestScenario((DEFAULT_START_DATE, None), True), + ]), + FunctionTest(course_start_datetime_text, [ + TestScenario( + (DEFAULT_START_DATE, advertised_start_parsable, 'DATE_TIME', ugettext, mock_strftime_localized), + mock_strftime_localized(Date().from_json(advertised_start_parsable), 'DATE_TIME') + " UTC" + ), + TestScenario( + (test_datetime, advertised_start_unparsable, 'DATE_TIME', ugettext, mock_strftime_localized), + advertised_start_unparsable.title() + ), + TestScenario( + (test_datetime, None, 'SHORT_DATE', ugettext, mock_strftime_localized), + mock_strftime_localized(test_datetime, 'SHORT_DATE') + ), + TestScenario( + (DEFAULT_START_DATE, None, 'SHORT_DATE', ugettext, mock_strftime_localized), + # Translators: TBD stands for 'To Be Determined' and is used when a course + # does not yet have an announced start date. + ugettext('TBD') + ) + ]), + FunctionTest(course_end_datetime_text, [ + TestScenario( + (test_datetime, 'TIME', mock_strftime_localized), + mock_strftime_localized(test_datetime, 'TIME') + " UTC" + ), + TestScenario( + (None, 'TIME', mock_strftime_localized), + "" + ) + ]), + FunctionTest(may_certify_for_course, [ + TestScenario(('early_with_info', True, True), True), + TestScenario(('early_no_info', False, False), True), + TestScenario(('end', True, False), True), + TestScenario(('end', False, True), True), + TestScenario(('end', False, False), False), + ]), + ] + + for function_test in function_tests: + for scenario in function_test.scenarios: + actual_return = function_test.function(*scenario.arguments) + self.assertEqual(actual_return, scenario.expected_return) + + # Even though we don't care about testing mock_strftime_localized, + # we still need to test it with a bad format string in order to + # satisfy the coverage checker. + with self.assertRaises(ValueError): + mock_strftime_localized(test_datetime, 'BAD_FORMAT_SPECIFIER') diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index a9008d5511..da05da360e 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -19,6 +19,10 @@ COURSE = 'test_course' NOW = datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=UTC()) +_TODAY = datetime.now(UTC()) +_LAST_WEEK = _TODAY - timedelta(days=7) +_NEXT_WEEK = _TODAY + timedelta(days=7) + class CourseFieldsTestCase(unittest.TestCase): def test_default_start_date(self): @@ -348,3 +352,49 @@ class TeamsConfigurationTestCase(unittest.TestCase): self.add_team_configuration(max_team_size=4, topics=topics) self.assertTrue(self.course.teams_enabled) self.assertEqual(self.course.teams_topics, topics) + + +class CourseDescriptorTestCase(unittest.TestCase): + """ + Tests for a select few functions from CourseDescriptor. + + I wrote these test functions in order to satisfy the coverage checker for + PR #8484, which modified some code within CourseDescriptor. However, this + class definitely isn't a comprehensive test case for CourseDescriptor, as + writing a such a test case was out of the scope of the PR. + """ + + def setUp(self): + """ + Initialize dummy testing course. + """ + super(CourseDescriptorTestCase, self).setUp() + self.course = get_dummy_course(start=_TODAY) + + def test_clean_id(self): + """ + Test CourseDescriptor.clean_id. + """ + self.assertEqual( + self.course.clean_id(), + "course_ORSXG5C7N5ZGOL3UMVZXIX3DN52XE43FF52GK43UL5ZHK3Q=" + ) + self.assertEqual( + self.course.clean_id(padding_char='$'), + "course_ORSXG5C7N5ZGOL3UMVZXIX3DN52XE43FF52GK43UL5ZHK3Q$" + ) + + def test_has_started(self): + """ + Test CourseDescriptor.has_started. + """ + self.course.start = _LAST_WEEK + self.assertTrue(self.course.has_started()) + self.course.start = _NEXT_WEEK + self.assertFalse(self.course.has_started()) + + def test_number(self): + """ + Test CourseDescriptor.number. + """ + self.assertEqual(self.course.number, COURSE) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index b7b5e27b04..9c809b1fad 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -26,10 +26,11 @@ from xblock.fields import ( ) from xblock.fragment import Fragment from xblock.runtime import Runtime, IdReader, IdGenerator +from xmodule import course_metadata_utils from xmodule.fields import RelativeTime - from xmodule.errortracker import exc_info_to_str from xmodule.modulestore.exceptions import ItemNotFoundError + from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.asides import AsideUsageKeyV1, AsideDefinitionKeyV1 from xmodule.exceptions import UndefinedContext @@ -335,7 +336,7 @@ class XModuleMixin(XModuleFields, XBlockMixin): @property def url_name(self): - return self.location.name + return course_metadata_utils.url_name_for_course_location(self.location) @property def display_name_with_default(self): @@ -343,10 +344,7 @@ class XModuleMixin(XModuleFields, XBlockMixin): Return a display name for the module: use display_name if defined in metadata, otherwise convert the url name. """ - name = self.display_name - if name is None: - name = self.url_name.replace('_', ' ') - return name.replace('<', '<').replace('>', '>') + return course_metadata_utils.display_name_with_default(self) @property def xblock_kvs(self): diff --git a/lms/envs/common.py b/lms/envs/common.py index 35432c0363..1aa40c9cf5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1889,6 +1889,7 @@ INSTALLED_APPS = ( 'lms.djangoapps.lms_xblock', + 'openedx.core.djangoapps.content.course_overviews', 'openedx.core.djangoapps.content.course_structures', 'course_structure_api', diff --git a/openedx/core/djangoapps/content/course_overviews/__init__.py b/openedx/core/djangoapps/content/course_overviews/__init__.py new file mode 100644 index 0000000000..c5bcc278aa --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/__init__.py @@ -0,0 +1,28 @@ +""" +Library for quickly accessing basic course metadata. + +The rationale behind this app is that loading course metadata from the Split +Mongo Modulestore is too slow. See: + + https://openedx.atlassian.net/wiki/pages/viewpage.action?spaceKey=MA&title= + MA-296%3A+UserCourseEnrollmentList+Performance+Investigation + +This performance issue is not a problem when loading metadata for a *single* +course; however, there are many cases in LMS where we need to load metadata +for a number of courses simultaneously, which can cause very noticeable +latency. +Specifically, the endpoint /api/mobile_api/v0.5/users/{username}/course_enrollments +took an average of 900 ms, and all it does is generate a limited amount of data +for no more than a few dozen courses per user. + +In this app we declare the model CourseOverview, which caches course metadata +and a MySQL table and allows very quick access to it (according to NewRelic, +less than 1 ms). To load a CourseOverview, call CourseOverview.get_from_id +with the appropriate course key. The use cases for this app include things like +a user enrollment dashboard, a course metadata API, or a course marketing +page. +""" + +# importing signals is necessary to activate signal handler, which invalidates +# the CourseOverview cache every time a course is published +import openedx.core.djangoapps.content.course_overviews.signals # pylint: disable=unused-import diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0001_initial.py b/openedx/core/djangoapps/content/course_overviews/migrations/0001_initial.py new file mode 100644 index 0000000000..7c69d3cd92 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseOverview' + db.create_table('course_overviews_courseoverview', ( + ('id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, primary_key=True, db_index=True)), + ('_location', self.gf('xmodule_django.models.UsageKeyField')(max_length=255)), + ('display_name', self.gf('django.db.models.fields.TextField')(null=True)), + ('display_number_with_default', self.gf('django.db.models.fields.TextField')()), + ('display_org_with_default', self.gf('django.db.models.fields.TextField')()), + ('start', self.gf('django.db.models.fields.DateTimeField')(null=True)), + ('end', self.gf('django.db.models.fields.DateTimeField')(null=True)), + ('advertised_start', self.gf('django.db.models.fields.TextField')(null=True)), + ('course_image_url', self.gf('django.db.models.fields.TextField')()), + ('facebook_url', self.gf('django.db.models.fields.TextField')(null=True)), + ('social_sharing_url', self.gf('django.db.models.fields.TextField')(null=True)), + ('end_of_course_survey_url', self.gf('django.db.models.fields.TextField')(null=True)), + ('certificates_display_behavior', self.gf('django.db.models.fields.TextField')(null=True)), + ('certificates_show_before_end', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('has_any_active_web_certificate', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('cert_name_short', self.gf('django.db.models.fields.TextField')()), + ('cert_name_long', self.gf('django.db.models.fields.TextField')()), + ('lowest_passing_grade', self.gf('django.db.models.fields.DecimalField')(max_digits=5, decimal_places=2)), + ('mobile_available', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('visible_to_staff_only', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('_pre_requisite_courses_json', self.gf('django.db.models.fields.TextField')()), + )) + db.send_create_signal('course_overviews', ['CourseOverview']) + + + def backwards(self, orm): + # Deleting model 'CourseOverview' + db.delete_table('course_overviews_courseoverview') + + + models = { + 'course_overviews.courseoverview': { + 'Meta': {'object_name': 'CourseOverview'}, + '_location': ('xmodule_django.models.UsageKeyField', [], {'max_length': '255'}), + '_pre_requisite_courses_json': ('django.db.models.fields.TextField', [], {}), + 'advertised_start': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'cert_name_long': ('django.db.models.fields.TextField', [], {}), + 'cert_name_short': ('django.db.models.fields.TextField', [], {}), + 'certificates_display_behavior': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'certificates_show_before_end': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_image_url': ('django.db.models.fields.TextField', [], {}), + 'display_name': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'display_number_with_default': ('django.db.models.fields.TextField', [], {}), + 'display_org_with_default': ('django.db.models.fields.TextField', [], {}), + 'end': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'end_of_course_survey_url': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'facebook_url': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'has_any_active_web_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'primary_key': 'True', 'db_index': 'True'}), + 'lowest_passing_grade': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}), + 'mobile_available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'social_sharing_url': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'start': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'visible_to_staff_only': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + } + } + + complete_apps = ['course_overviews'] \ No newline at end of file diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/__init__.py b/openedx/core/djangoapps/content/course_overviews/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py new file mode 100644 index 0000000000..b91e86f762 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -0,0 +1,243 @@ +""" +Declaration of CourseOverview model +""" + +import json + +import django.db.models +from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField +from django.utils.translation import ugettext + +from lms.djangoapps.certificates.api import get_active_web_certificate +from lms.djangoapps.courseware.courses import course_image_url +from util.date_utils import strftime_localized +from xmodule import course_metadata_utils +from xmodule.modulestore.django import modulestore +from xmodule_django.models import CourseKeyField, UsageKeyField + + +class CourseOverview(django.db.models.Model): + """ + Model for storing and caching basic information about a course. + + This model contains basic course metadata such as an ID, display name, + image URL, and any other information that would be necessary to display + a course as part of a user dashboard or enrollment API. + """ + + # Course identification + id = CourseKeyField(db_index=True, primary_key=True, max_length=255) # pylint: disable=invalid-name + _location = UsageKeyField(max_length=255) + display_name = TextField(null=True) + display_number_with_default = TextField() + display_org_with_default = TextField() + + # Start/end dates + start = DateTimeField(null=True) + end = DateTimeField(null=True) + advertised_start = TextField(null=True) + + # URLs + course_image_url = TextField() + facebook_url = TextField(null=True) + social_sharing_url = TextField(null=True) + end_of_course_survey_url = TextField(null=True) + + # Certification data + certificates_display_behavior = TextField(null=True) + certificates_show_before_end = BooleanField() + has_any_active_web_certificate = BooleanField() + cert_name_short = TextField() + cert_name_long = TextField() + + # Grading + lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2) + + # Access parameters + mobile_available = BooleanField() + visible_to_staff_only = BooleanField() + _pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings + + @staticmethod + def _create_from_course(course): + """ + Creates a CourseOverview object from a CourseDescriptor. + + Does not touch the database, simply constructs and returns an overview + from the given course. + + Arguments: + course (CourseDescriptor): any course descriptor object + + Returns: + CourseOverview: overview extracted from the given course + """ + return CourseOverview( + id=course.id, + _location=course.location, + display_name=course.display_name, + display_number_with_default=course.display_number_with_default, + display_org_with_default=course.display_org_with_default, + + start=course.start, + end=course.end, + advertised_start=course.advertised_start, + + course_image_url=course_image_url(course), + facebook_url=course.facebook_url, + social_sharing_url=course.social_sharing_url, + + certificates_display_behavior=course.certificates_display_behavior, + certificates_show_before_end=course.certificates_show_before_end, + has_any_active_web_certificate=(get_active_web_certificate(course) is not None), + cert_name_short=course.cert_name_short, + cert_name_long=course.cert_name_long, + lowest_passing_grade=course.lowest_passing_grade, + end_of_course_survey_url=course.end_of_course_survey_url, + + mobile_available=course.mobile_available, + visible_to_staff_only=course.visible_to_staff_only, + _pre_requisite_courses_json=json.dumps(course.pre_requisite_courses) + ) + + @staticmethod + def get_from_id(course_id): + """ + Load a CourseOverview object for a given course ID. + + First, we try to load the CourseOverview from the database. If it + doesn't exist, we load the entire course from the modulestore, create a + CourseOverview object from it, and then cache it in the database for + future use. + + Arguments: + course_id (CourseKey): the ID of the course overview to be loaded + + Returns: + CourseOverview: overview of the requested course + """ + course_overview = None + try: + course_overview = CourseOverview.objects.get(id=course_id) + except CourseOverview.DoesNotExist: + store = modulestore() + with store.bulk_operations(course_id): + course = store.get_course(course_id) + if course: + course_overview = CourseOverview._create_from_course(course) + course_overview.save() # Save new overview to the cache + return course_overview + + def clean_id(self, padding_char='='): + """ + Returns a unique deterministic base32-encoded ID for the course. + + Arguments: + padding_char (str): Character used for padding at end of base-32 + -encoded string, defaulting to '=' + """ + return course_metadata_utils.clean_course_key(self.location.course_key, padding_char) + + @property + def location(self): + """ + Returns the UsageKey of this course. + + UsageKeyField has a strange behavior where it fails to parse the "run" + of a course out of the serialized form of a Mongo Draft UsageKey. This + method is a wrapper around _location attribute that fixes the problem + by calling map_into_course, which restores the run attribute. + """ + if self._location.run is None: + self._location = self._location.map_into_course(self.id) + return self._location + + @property + def number(self): + """ + Returns this course's number. + + This is a "number" in the sense of the "course numbers" that you see at + lots of universities. For example, given a course + "Intro to Computer Science" with the course key "edX/CS-101/2014", the + course number would be "CS-101" + """ + return course_metadata_utils.number_for_course_location(self.location) + + @property + def url_name(self): + """ + Returns this course's URL name. + """ + return course_metadata_utils.url_name_for_course_location(self.location) + + @property + def display_name_with_default(self): + """ + Return reasonable display name for the course. + """ + return course_metadata_utils.display_name_with_default(self) + + def has_started(self): + """ + Returns whether the the course has started. + """ + return course_metadata_utils.has_course_started(self.start) + + def has_ended(self): + """ + Returns whether the course has ended. + """ + return course_metadata_utils.has_course_ended(self.end) + + def start_datetime_text(self, format_string="SHORT_DATE"): + """ + Returns the desired text corresponding the course's start date and + time in UTC. Prefers .advertised_start, then falls back to .start. + """ + return course_metadata_utils.course_start_datetime_text( + self.start, + self.advertised_start, + format_string, + ugettext, + strftime_localized + ) + + @property + def start_date_is_still_default(self): + """ + Checks if the start date set for the course is still default, i.e. + .start has not been modified, and .advertised_start has not been set. + """ + return course_metadata_utils.course_start_date_is_default( + self.start, + self.advertised_start, + ) + + def end_datetime_text(self, format_string="SHORT_DATE"): + """ + Returns the end date or datetime for the course formatted as a string. + """ + return course_metadata_utils.course_end_datetime_text( + self.end, + format_string, + strftime_localized + ) + + def may_certify(self): + """ + Returns whether it is acceptable to show the student a certificate + download link. + """ + return course_metadata_utils.may_certify_for_course( + self.certificates_display_behavior, + self.certificates_show_before_end, + self.has_ended() + ) + + @property + def pre_requisite_courses(self): + """ + Returns a list of ID strings for this course's prerequisite courses. + """ + return json.loads(self._pre_requisite_courses_json) diff --git a/openedx/core/djangoapps/content/course_overviews/signals.py b/openedx/core/djangoapps/content/course_overviews/signals.py new file mode 100644 index 0000000000..37bfcd2681 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/signals.py @@ -0,0 +1,17 @@ +""" +Signal handler for invalidating cached course overviews +""" +from django.dispatch.dispatcher import receiver + +from xmodule.modulestore.django import SignalHandler + +from .models import CourseOverview + + +@receiver(SignalHandler.course_published) +def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument + """ + Catches the signal that a course has been published in Studio and + invalidates the corresponding CourseOverview cache entry if one exists. + """ + CourseOverview.objects.filter(id=course_key).delete() diff --git a/openedx/core/djangoapps/content/course_overviews/tests.py b/openedx/core/djangoapps/content/course_overviews/tests.py new file mode 100644 index 0000000000..2ea41b2521 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/tests.py @@ -0,0 +1,258 @@ +""" +Tests for course_overviews app. +""" +import datetime +import ddt +import itertools +import pytz +import math + +from django.utils import timezone + +from lms.djangoapps.certificates.api import get_active_web_certificate +from lms.djangoapps.courseware.courses import course_image_url +from xmodule.course_metadata_utils import DEFAULT_START_DATE +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, check_mongo_calls_range + +from .models import CourseOverview + + +@ddt.ddt +class CourseOverviewTestCase(ModuleStoreTestCase): + """ + Tests for CourseOverviewDescriptor model. + """ + + TODAY = timezone.now() + LAST_MONTH = TODAY - datetime.timedelta(days=30) + LAST_WEEK = TODAY - datetime.timedelta(days=7) + NEXT_WEEK = TODAY + datetime.timedelta(days=7) + NEXT_MONTH = TODAY + datetime.timedelta(days=30) + + def check_course_overview_against_course(self, course): + """ + Compares a CourseOverview object against its corresponding + CourseDescriptor object. + + Specifically, given a course, test that data within the following three + objects match each other: + - the CourseDescriptor itself + - a CourseOverview that was newly constructed from _create_from_course + - a CourseOverview that was loaded from the MySQL database + """ + + def get_seconds_since_epoch(date_time): + """ + Returns the number of seconds between the Unix Epoch and the given + datetime. If the given datetime is None, return None. + """ + if date_time is None: + return None + epoch = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc) + return math.floor((date_time - epoch).total_seconds()) + + # Load the CourseOverview from the cache twice. The first load will be a cache miss (because the cache + # is empty) so the course will be newly created with CourseOverviewDescriptor.create_from_course. The second + # load will be a cache hit, so the course will be loaded from the cache. + course_overview_cache_miss = CourseOverview.get_from_id(course.id) + course_overview_cache_hit = CourseOverview.get_from_id(course.id) + + # Test if value of these attributes match between the three objects + fields_to_test = [ + 'id', + 'display_name', + 'display_number_with_default', + 'display_org_with_default', + 'advertised_start', + 'facebook_url', + 'social_sharing_url', + 'certificates_display_behavior', + 'certificates_show_before_end', + 'cert_name_short', + 'cert_name_long', + 'lowest_passing_grade', + 'end_of_course_survey_url', + 'mobile_available', + 'visible_to_staff_only', + 'location', + 'number', + 'url_name', + 'display_name_with_default', + 'start_date_is_still_default', + 'pre_requisite_courses', + ] + for attribute_name in fields_to_test: + course_value = getattr(course, attribute_name) + cache_miss_value = getattr(course_overview_cache_miss, attribute_name) + cache_hit_value = getattr(course_overview_cache_hit, attribute_name) + self.assertEqual(course_value, cache_miss_value) + self.assertEqual(cache_miss_value, cache_hit_value) + + # Test if return values for all methods are equal between the three objects + methods_to_test = [ + ('clean_id', ()), + ('clean_id', ('#',)), + ('has_ended', ()), + ('has_started', ()), + ('start_datetime_text', ('SHORT_DATE',)), + ('start_datetime_text', ('DATE_TIME',)), + ('end_datetime_text', ('SHORT_DATE',)), + ('end_datetime_text', ('DATE_TIME',)), + ('may_certify', ()), + ] + for method_name, method_args in methods_to_test: + course_value = getattr(course, method_name)(*method_args) + cache_miss_value = getattr(course_overview_cache_miss, method_name)(*method_args) + cache_hit_value = getattr(course_overview_cache_hit, method_name)(*method_args) + self.assertEqual(course_value, cache_miss_value) + self.assertEqual(cache_miss_value, cache_hit_value) + + # Other values to test + # Note: we test the start and end attributes here instead of in + # fields_to_test, because I ran into trouble while testing datetimes + # for equality. When writing and reading dates from databases, the + # resulting values are often off by fractions of a second. So, as a + # workaround, we simply test if the start and end times are the same + # number of seconds from the Unix epoch. + others_to_test = [( + course_image_url(course), + course_overview_cache_miss.course_image_url, + course_overview_cache_hit.course_image_url + ), ( + get_active_web_certificate(course) is not None, + course_overview_cache_miss.has_any_active_web_certificate, + course_overview_cache_hit.has_any_active_web_certificate + + ), ( + get_seconds_since_epoch(course.start), + get_seconds_since_epoch(course_overview_cache_miss.start), + get_seconds_since_epoch(course_overview_cache_hit.start), + ), ( + get_seconds_since_epoch(course.end), + get_seconds_since_epoch(course_overview_cache_miss.end), + get_seconds_since_epoch(course_overview_cache_hit.end), + )] + for (course_value, cache_miss_value, cache_hit_value) in others_to_test: + self.assertEqual(course_value, cache_miss_value) + self.assertEqual(cache_miss_value, cache_hit_value) + + @ddt.data(*itertools.product( + [ + { + "display_name": "Test Course", # Display name provided + "start": LAST_WEEK, # In the middle of the course + "end": NEXT_WEEK, + "advertised_start": "2015-01-01 11:22:33", # Parse-able advertised_start + "pre_requisite_courses": [ # Has pre-requisites + 'course-v1://edX+test1+run1', + 'course-v1://edX+test2+run1' + ], + "static_asset_path": "/my/abs/path", # Absolute path + "certificates_show_before_end": True, + }, + { + "display_name": "", # Empty display name + "start": NEXT_WEEK, # Course hasn't started yet + "end": NEXT_MONTH, + "advertised_start": "Very Soon!", # Not parse-able advertised_start + "pre_requisite_courses": [], # No pre-requisites + "static_asset_path": "my/relative/path", # Relative asset path + "certificates_show_before_end": False, + }, + { + "display_name": "", # Empty display name + "start": LAST_MONTH, # Course already ended + "end": LAST_WEEK, + "advertised_start": None, # No advertised start + "pre_requisite_courses": [], # No pre-requisites + "static_asset_path": "", # Empty asset path + "certificates_show_before_end": False, + }, + { + # # Don't set display name + "start": DEFAULT_START_DATE, # Default start and end dates + "end": None, + "advertised_start": None, # No advertised start + "pre_requisite_courses": [], # No pre-requisites + "static_asset_path": None, # No asset path + "certificates_show_before_end": False, + } + ], + [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split] + )) + @ddt.unpack + def test_course_overview_behavior(self, course_kwargs, modulestore_type): + """ + Tests if CourseOverviews and CourseDescriptors behave the same + by comparing pairs of them given a variety of scenarios. + + Arguments: + course_kwargs (dict): kwargs to be passed to course constructor + modulestore_type (ModuleStoreEnum.Type) + is_user_enrolled (bool) + """ + + course = CourseFactory.create( + course="TEST101", + org="edX", + run="Run1", + default_store=modulestore_type, + **course_kwargs + ) + self.check_course_overview_against_course(course) + + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + def test_course_overview_cache_invalidation(self, modulestore_type): + """ + Tests that when a course is published, the corresponding + course_overview is removed from the cache. + """ + with self.store.default_store(modulestore_type): + + # Create a course where mobile_available is True. + course = CourseFactory.create( + course="TEST101", + org="edX", + run="Run1", + mobile_available=True, + default_store=modulestore_type + ) + course_overview_1 = CourseOverview.get_from_id(course.id) + self.assertTrue(course_overview_1.mobile_available) + + # Set mobile_available to False and update the course. + # This fires a course_published signal, which should be caught in signals.py, which should in turn + # delete the corresponding CourseOverview from the cache. + course.mobile_available = False + with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): + self.store.update_item(course, ModuleStoreEnum.UserID.test) + + # Make sure that when we load the CourseOverview again, mobile_available is updated. + course_overview_2 = CourseOverview.get_from_id(course.id) + self.assertFalse(course_overview_2.mobile_available) + + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 1), (ModuleStoreEnum.Type.split, 3, 4)) + @ddt.unpack + def test_course_overview_caching(self, modulestore_type, min_mongo_calls, max_mongo_calls): + """ + Tests that CourseOverview structures are actually getting cached. + """ + course = CourseFactory.create( + course="TEST101", + org="edX", + run="Run1", + mobile_available=True, + default_store=modulestore_type + ) + + # The first time we load a CourseOverview, it will be a cache miss, so + # we expect the modulestore to be queried. + with check_mongo_calls_range(max_finds=max_mongo_calls, min_finds=min_mongo_calls): + _course_overview_1 = CourseOverview.get_from_id(course.id) + + # The second time we load a CourseOverview, it will be a cache hit, so + # we expect no modulestore queries to be made. + with check_mongo_calls(0): + _course_overview_2 = CourseOverview.get_from_id(course.id) From d84c3bd7a9db7ad7abae259bccfd38a69ebda5d2 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 15 Jun 2015 15:56:55 -0400 Subject: [PATCH 25/97] MA-776 change UserCourseEnrollmentsList endpoint to use course_overviews --- lms/djangoapps/courseware/access.py | 10 ++++---- .../mobile_api/users/serializers.py | 24 +++++++++---------- lms/djangoapps/mobile_api/users/views.py | 3 ++- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 3fa2cda60c..3d41f5525d 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -663,17 +663,19 @@ def _has_staff_access_to_descriptor(user, descriptor, course_key): return _has_staff_access_to_location(user, descriptor.location, course_key) -def is_mobile_available_for_user(user, course): +def is_mobile_available_for_user(user, descriptor): """ Returns whether the given course is mobile_available for the given user. Checks: mobile_available flag on the course Beta User and staff access overrides the mobile_available flag + Arguments: + descriptor (CourseDescriptor|CourseOverview): course or overview of course in question """ return ( - course.mobile_available or - auth.has_access(user, CourseBetaTesterRole(course.id)) or - _has_staff_access_to_descriptor(user, course, course.id) + descriptor.mobile_available or + auth.has_access(user, CourseBetaTesterRole(descriptor.id)) or + _has_staff_access_to_descriptor(user, descriptor, descriptor.id) ) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 1674313343..b4876b3ae4 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -9,11 +9,11 @@ from student.models import CourseEnrollment, User from certificates.models import certificate_status_for_student, CertificateStatuses -class CourseField(serializers.RelatedField): +class CourseOverviewField(serializers.RelatedField): """Custom field to wrap a CourseDescriptor object. Read-only.""" - def to_native(self, course): - course_id = unicode(course.id) + def to_native(self, course_overview): + course_id = unicode(course_overview.id) request = self.context.get('request', None) if request: video_outline_url = reverse( @@ -38,14 +38,14 @@ class CourseField(serializers.RelatedField): return { "id": course_id, - "name": course.display_name, - "number": course.display_number_with_default, - "org": course.display_org_with_default, - "start": course.start, - "end": course.end, - "course_image": course_image_url(course), + "name": course_overview.display_name, + "number": course_overview.display_number_with_default, + "org": course_overview.display_org_with_default, + "start": course_overview.start, + "end": course_overview.end, + "course_image": course_overview.course_image_url, "social_urls": { - "facebook": course.facebook_url, + "facebook": course_overview.facebook_url, }, "latest_updates": { "video": None @@ -53,7 +53,7 @@ class CourseField(serializers.RelatedField): "video_outline": video_outline_url, "course_updates": course_updates_url, "course_handouts": course_handouts_url, - "subscription_id": course.clean_id(padding_char='_'), + "subscription_id": course_overview.clean_id(padding_char='_'), } @@ -61,7 +61,7 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): """ Serializes CourseEnrollment models """ - course = CourseField() + course = CourseOverviewField(source="course_overview") certificate = serializers.SerializerMethodField('get_certificate') def get_certificate(self, model): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 72e66cb0e5..19dae819c9 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -241,7 +241,8 @@ class UserCourseEnrollmentsList(generics.ListAPIView): ).order_by('created').reverse() return [ enrollment for enrollment in enrollments - if enrollment.course and is_mobile_available_for_user(self.request.user, enrollment.course) + if enrollment.course_overview and + is_mobile_available_for_user(self.request.user, enrollment.course_overview) ] From aa5e2f49bd58ba5d5e066f0db2a47bf6ebc5e912 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Thu, 4 Jun 2015 16:44:08 -0400 Subject: [PATCH 26/97] Fixes tests, adds internationalized strings for new state tooltips, corrected tooltip javascript, etc. --- cms/templates/ux/reference/container.html | 2 +- common/lib/capa/capa/inputtypes.py | 13 +++- .../lib/capa/capa/templates/choicegroup.html | 6 +- .../lib/capa/capa/templates/choicetext.html | 2 +- .../capa/templates/formulaequationinput.html | 2 +- .../lib/capa/capa/templates/optioninput.html | 4 +- common/lib/capa/capa/templates/textline.html | 2 +- .../capa/capa/tests/test_input_templates.py | 26 ++++---- .../lib/xmodule/xmodule/css/capa/display.scss | 64 +++++++++---------- .../xmodule/js/spec/capa/display_spec.coffee | 2 +- .../xmodule/js/src/capa/display.coffee | 4 +- common/static/sass/_mixins.scss | 2 +- common/test/acceptance/pages/lms/problem.py | 2 +- .../courseware/features/problems_setup.py | 8 +-- lms/static/sass/base/_variables.scss | 22 ------- test_root/db/test_edx.db-journal | 0 16 files changed, 73 insertions(+), 88 deletions(-) create mode 100644 test_root/db/test_edx.db-journal diff --git a/cms/templates/ux/reference/container.html b/cms/templates/ux/reference/container.html index 81821b503a..19ab1db780 100644 --- a/cms/templates/ux/reference/container.html +++ b/cms/templates/ux/reference/container.html @@ -351,7 +351,7 @@

What Apple device competed with the portable CD player?

-
+
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index f4ea46932d..c487efed69 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -69,7 +69,7 @@ registry = TagRegistry() # pylint: disable=invalid-name class Status(object): """ Problem status - attributes: classname, display_name + attributes: classname, display_name, display_tooltip """ css_classes = { # status: css class @@ -77,7 +77,7 @@ class Status(object): 'incomplete': 'incorrect', 'queued': 'processing', } - __slots__ = ('classname', '_status', 'display_name') + __slots__ = ('classname', '_status', 'display_name', 'display_tooltip') def __init__(self, status, gettext_func=unicode): self.classname = self.css_classes.get(status, status) @@ -90,7 +90,16 @@ class Status(object): 'unsubmitted': _('unanswered'), 'queued': _('processing'), } + tooltips = { + # Translators: these are tooltips that indicate the state of an assessment question + 'correct': _('This is correct.'), + 'incorrect': _('This is incorrect.'), + 'unanswered': _('This is unanswered.'), + 'unsubmitted': _('This is unanswered.'), + 'queued': _('This is being processed.'), + } self.display_name = names.get(status, unicode(status)) + self.display_tooltip = tooltips.get(status, u'') self._status = status or '' def __str__(self): diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index 6a27a3df4f..da5d372401 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -39,11 +39,9 @@ % endfor -
+
% if input_type == 'checkbox' or not value: - + %for choice_id, choice_description in choices: % if choice_id in value: diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html index e370f10594..448205c2aa 100644 --- a/common/lib/capa/capa/templates/choicetext.html +++ b/common/lib/capa/capa/templates/choicetext.html @@ -58,7 +58,7 @@ -
+
% if input_type == 'checkbox' or not element_checked: % endif diff --git a/common/lib/capa/capa/templates/formulaequationinput.html b/common/lib/capa/capa/templates/formulaequationinput.html index 04b4f8e965..206a8bc21c 100644 --- a/common/lib/capa/capa/templates/formulaequationinput.html +++ b/common/lib/capa/capa/templates/formulaequationinput.html @@ -10,7 +10,7 @@ % endif /> - + ${status.display_name} diff --git a/common/lib/capa/capa/templates/optioninput.html b/common/lib/capa/capa/templates/optioninput.html index 65965597b7..a4f25d801b 100644 --- a/common/lib/capa/capa/templates/optioninput.html +++ b/common/lib/capa/capa/templates/optioninput.html @@ -13,10 +13,10 @@ -
+
+ aria-describedby="input_${id}" data-tooltip="${status.display_tooltip}"> ${value|h} - ${status.display_name}
diff --git a/common/lib/capa/capa/templates/textline.html b/common/lib/capa/capa/templates/textline.html index ed006240ca..b37fb0d67e 100644 --- a/common/lib/capa/capa/templates/textline.html +++ b/common/lib/capa/capa/templates/textline.html @@ -30,7 +30,7 @@ + aria-describedby="input_${id}" data-tooltip="${status.display_tooltip}"> %if value: ${value|h} diff --git a/common/lib/capa/capa/tests/test_input_templates.py b/common/lib/capa/capa/tests/test_input_templates.py index 4bed5e88c7..5af21fd5e2 100644 --- a/common/lib/capa/capa/tests/test_input_templates.py +++ b/common/lib/capa/capa/tests/test_input_templates.py @@ -144,7 +144,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase): # Should mark the entire problem correct xml = self.render_to_xml(self.context) - xpath = "//div[@class='indicator_container']/span[@class='status correct']" + xpath = "//div[@class='indicator-container']/span[@class='status correct']" self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -172,7 +172,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase): for test_conditions in conditions: self.context.update(test_conditions) xml = self.render_to_xml(self.context) - xpath = "//div[@class='indicator_container']/span[@class='status incorrect']" + xpath = "//div[@class='indicator-container']/span[@class='status incorrect']" self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -204,7 +204,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase): for test_conditions in conditions: self.context.update(test_conditions) xml = self.render_to_xml(self.context) - xpath = "//div[@class='indicator_container']/span[@class='status unanswered']" + xpath = "//div[@class='indicator-container']/span[@class='status unanswered']" self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -234,7 +234,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase): self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark the whole problem - xpath = "//div[@class='indicator_container']/span" + xpath = "//div[@class='indicator-container']/span" self.assert_no_xpath(xml, xpath, self.context) def test_option_marked_incorrect(self): @@ -255,7 +255,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase): self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark the whole problem - xpath = "//div[@class='indicator_container']/span" + xpath = "//div[@class='indicator-container']/span" self.assert_no_xpath(xml, xpath, self.context) def test_never_show_correctness(self): @@ -289,10 +289,10 @@ class ChoiceGroupTemplateTest(TemplateTestCase): xml = self.render_to_xml(self.context) # Should NOT mark the entire problem correct/incorrect - xpath = "//div[@class='indicator_container']/span[@class='status correct']" + xpath = "//div[@class='indicator-container']/span[@class='status correct']" self.assert_no_xpath(xml, xpath, self.context) - xpath = "//div[@class='indicator_container']/span[@class='status incorrect']" + xpath = "//div[@class='indicator-container']/span[@class='status incorrect']" self.assert_no_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -390,7 +390,7 @@ class TextlineTemplateTest(TemplateTestCase): # Expect that we get a with class="status" # (used to by CSS to draw the green check / red x) - self.assert_has_text(xml, "//span[@class='status']", + self.assert_has_text(xml, "//span[@class='status']/span[@class='sr']", status_mark, exact=False) def test_label(self): @@ -848,7 +848,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase): # Should mark the entire problem correct xml = self.render_to_xml(self.context) - xpath = "//div[@class='indicator_container']/span[@class='status correct']" + xpath = "//div[@class='indicator-container']/span[@class='status correct']" self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -875,7 +875,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase): for test_conditions in conditions: self.context.update(test_conditions) xml = self.render_to_xml(self.context) - xpath = "//div[@class='indicator_container']/span[@class='status incorrect']" + xpath = "//div[@class='indicator-container']/span[@class='status incorrect']" self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -907,7 +907,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase): for test_conditions in conditions: self.context.update(test_conditions) xml = self.render_to_xml(self.context) - xpath = "//div[@class='indicator_container']/span[@class='status unanswered']" + xpath = "//div[@class='indicator-container']/span[@class='status unanswered']" self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark individual options @@ -937,7 +937,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase): self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark the whole problem - xpath = "//div[@class='indicator_container']/span" + xpath = "//div[@class='indicator-container']/span" self.assert_no_xpath(xml, xpath, self.context) def test_option_marked_incorrect(self): @@ -957,7 +957,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase): self.assert_has_xpath(xml, xpath, self.context) # Should NOT mark the whole problem - xpath = "//div[@class='indicator_container']/span" + xpath = "//div[@class='indicator-container']/span" self.assert_no_xpath(xml, xpath, self.context) def test_label(self): diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index c8d5f7787f..02e4b45ebf 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -114,6 +114,7 @@ iframe[seamless]{ div.problem-progress { @include padding-left($baseline/4); + @extend %t-ultralight; display: inline-block; color: $gray-d1; font-weight: 100; @@ -151,11 +152,11 @@ div.problem { @include box-sizing(border-box); display: inline-block; clear: both; - width: 100%; + margin-bottom: ($baseline/2); border: 2px solid $gray-l4; border-radius: 3px; - margin-bottom: ($baseline/2); padding: ($baseline/2); + width: 100%; &.choicegroup_correct { @include status-icon($correct, "\f00c"); @@ -182,7 +183,7 @@ div.problem { } } - .indicator_container { + .indicator-container { display: inline-block; min-height: 1px; width: 25px; @@ -209,11 +210,11 @@ div.problem { // Summary status indicators shown after the input area div.problem { - .indicator_container { + .indicator-container { .status { - width: 20px; - height: 20px; + width: $baseline; + height: $baseline; // CASE: correct answer &.correct { @@ -232,8 +233,6 @@ div.problem { // CASE: processing &.processing { - // add once spinner is rotated through animations - //@include status-icon($gray-d1, "\f110", 0); } } } @@ -287,7 +286,7 @@ div.problem { } } - // known classes using this div: .indicator_container, moved to section above + // known classes using this div: .indicator-container, moved to section above div { // TO-DO: Styling used by advanced capa problem types. Should be synced up to use .status class @@ -297,7 +296,7 @@ div.problem { } &.status { - @include margin(8px, 0, 0, $baseline/2); + @include margin(8px, 0, 0, ($baseline/2)); text-indent: 100%; white-space: nowrap; overflow: hidden; @@ -329,7 +328,7 @@ div.problem { } input { - border-color: green; + border-color: $correct; } } @@ -355,7 +354,7 @@ div.problem { } input { - border-color: red; + border-color: $incorrect; } } @@ -369,7 +368,7 @@ div.problem { } input { - border-color: red; + border-color: $incorrect; } } @@ -384,9 +383,9 @@ div.problem { margin-bottom: 0; &:before { + @extend %t-strong; display: inline; content: "Answer: "; - font-weight: bold; } &:empty { @@ -596,7 +595,8 @@ div.problem { } dl dt { - font-weight: bold; + @extend %t-strong; + } dl dd { @@ -642,8 +642,8 @@ div.problem { } th { + @extend %t-strong; text-align: left; - font-weight: bold; } td { @@ -706,16 +706,16 @@ div.problem { @include box-sizing(border-box); border: 2px solid $gray-l4; border-radius: 3px; - height: 46px; min-width: 160px; + height: 46px; } > .incorrect, .correct, .unanswered { .status { display: inline-block; - background: none; margin-top: ($baseline/2); + background: none; } } @@ -769,7 +769,7 @@ div.problem { @include margin-right($baseline/2); } - .indicator_container { + .indicator-container { display: inline-block; .status.correct:after, .status.incorrect:after { @@ -844,8 +844,8 @@ div.problem .action { margin-bottom: ($baseline/2); height: ($baseline*2); vertical-align: middle; - font-weight: 600; text-transform: uppercase; + font-weight: 600; } .save { @@ -866,9 +866,9 @@ div.problem .action { // border-radius: 3px; // padding: 8px 12px; // margin-top: ($baseline/2); + @include margin-left($baseline/2); display: inline-block; margin-top: 8px; - @include margin-left($baseline/2); color: $gray-d1; font-style: italic; -webkit-font-smoothing: antialiased; @@ -909,9 +909,9 @@ div.problem { .detailed-solution { > p:first-child { + @extend %t-strong; color: #aaa; text-transform: uppercase; - font-weight: bold; font-style: normal; font-size: 0.9em; } @@ -923,9 +923,9 @@ div.problem { .detailed-targeted-feedback { > p:first-child { - color: red; + @extend %t-strong; + color: $incorrect; text-transform: uppercase; - font-weight: bold; font-style: normal; font-size: 0.9em; } @@ -937,9 +937,9 @@ div.problem { .detailed-targeted-feedback-correct { > p:first-child { - color: green; + @extend %t-strong; + color: $correct; text-transform: uppercase; - font-weight: bold; font-style: normal; font-size: 0.9em; } @@ -980,11 +980,11 @@ div.problem { border: 1px solid $gray-l3; h3 { + @extend %t-strong; padding: 9px; border-bottom: 1px solid #e3e3e3; background: #eee; text-shadow: 0 1px 0 $white; - font-weight: bold; font-size: em(16); } @@ -1021,9 +1021,9 @@ div.problem { margin-bottom: 12px; h3 { + @extend %t-strong; color: #aaa; text-transform: uppercase; - font-weight: bold; font-style: normal; font-size: 0.9em; } @@ -1080,7 +1080,7 @@ div.problem { } .shortform { - font-weight: bold; + @extend %t-strong; } .longform { @@ -1223,9 +1223,9 @@ div.problem { border-radius: 1em; .annotation-header { + @extend %t-strong; padding: .5em 1em; border-bottom: 1px solid $gray-l3; - font-weight: bold; } .annotation-body { padding: .5em 1em; } @@ -1306,10 +1306,10 @@ div.problem { pre { background-color: $gray-l3; color: $black; } &:before { + @extend %t-strong; display: block; content: "debug input value"; text-transform: uppercase; - font-weight: bold; font-size: 1.5em; } } @@ -1330,7 +1330,7 @@ div.problem { @extend label.choicegroup_correct; input[type="text"] { - border-color: green; + border-color: $correct; } } diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee index 892864f631..a8d881c6e6 100644 --- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee @@ -323,7 +323,7 @@ describe 'Problem', ->

-
+
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index f5e6c1baa0..da817c1254 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -470,7 +470,7 @@ class @Problem $(element).find('input').on 'input', -> $p = $(element).find('span.status') `// Translators: the word unanswered here is about answering a problem the student must solve.` - $p.parent().removeClass().addClass "unanswered" + $p.parent().removeClass().addClass "unsubmitted" choicegroup: (element) -> $element = $(element) @@ -498,7 +498,7 @@ class @Problem $(element).find('input').on 'input', -> $p = $(element).find('span.status') `// Translators: the word unanswered here is about answering a problem the student must solve.` - $p.parent().removeClass("correct incorrect").addClass "unanswered" + $p.parent().removeClass("correct incorrect").addClass "unsubmitted" inputtypeSetupMethods: diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss index 6e702fdff6..9b78525951 100644 --- a/common/static/sass/_mixins.scss +++ b/common/static/sass/_mixins.scss @@ -432,8 +432,8 @@ // * +Icon - Font-Awesome - Extend // ==================== %use-font-awesome { + display: inline-block; font-family: FontAwesome; -webkit-font-smoothing: antialiased; - display: inline-block; speak: none; } diff --git a/common/test/acceptance/pages/lms/problem.py b/common/test/acceptance/pages/lms/problem.py index fa49ea824e..0183d5c3cd 100644 --- a/common/test/acceptance/pages/lms/problem.py +++ b/common/test/acceptance/pages/lms/problem.py @@ -66,7 +66,7 @@ class ProblemPage(PageObject): """ Is there a "correct" status showing? """ - return self.q(css="div.problem div.capa_inputtype.textline div.correct p.status").is_present() + return self.q(css="div.problem div.capa_inputtype.textline div.correct span.status").is_present() def click_clarification(self, index=0): """ diff --git a/lms/djangoapps/courseware/features/problems_setup.py b/lms/djangoapps/courseware/features/problems_setup.py index e7ac96a535..b4a0aa135d 100644 --- a/lms/djangoapps/courseware/features/problems_setup.py +++ b/lms/djangoapps/courseware/features/problems_setup.py @@ -84,7 +84,7 @@ PROBLEM_DICT = { 'answer': 'correct string'}, 'correct': ['div.correct'], 'incorrect': ['div.incorrect'], - 'unanswered': ['div.unanswered']}, + 'unanswered': ['div.unanswered', 'div.unsubmitted']}, 'numerical': { 'factory': NumericalResponseXMLFactory(), @@ -95,7 +95,7 @@ PROBLEM_DICT = { 'math_display': True}, 'correct': ['div.correct'], 'incorrect': ['div.incorrect'], - 'unanswered': ['div.unanswered']}, + 'unanswered': ['div.unanswered', 'div.unsubmitted']}, 'formula': { 'factory': FormulaResponseXMLFactory(), @@ -108,7 +108,7 @@ PROBLEM_DICT = { 'answer': 'x^2+2*x+y'}, 'correct': ['div.correct'], 'incorrect': ['div.incorrect'], - 'unanswered': ['div.unanswered']}, + 'unanswered': ['div.unanswered', 'div.unsubmitted']}, 'script': { 'factory': CustomResponseXMLFactory(), @@ -129,7 +129,7 @@ PROBLEM_DICT = { """)}, 'correct': ['div.correct'], 'incorrect': ['div.incorrect'], - 'unanswered': ['div.unanswered']}, + 'unanswered': ['div.unanswered', 'div.unsubmitted']}, 'code': { 'factory': CodeResponseXMLFactory(), diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 5b1197c3ef..a057789f27 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -82,28 +82,6 @@ $gray-d2: shade($gray,40%); // #4c4c4c $gray-d3: shade($gray,60%); // #323232 $gray-d4: shade($gray,80%); // #191919 -// TO-DO: once existing lms $blue is removed, change $cms-blue to $blue. -$cms-blue: rgb(0, 159, 230); -$blue-l1: tint($cms-blue,20%); -$blue-l2: tint($cms-blue,40%); -$blue-l3: tint($cms-blue,60%); -$blue-l4: tint($cms-blue,80%); -$blue-l5: tint($cms-blue,90%); -$blue-d1: shade($cms-blue,20%); -$blue-d2: shade($cms-blue,40%); -$blue-d3: shade($cms-blue,60%); -$blue-d4: shade($cms-blue,80%); -$blue-s1: saturate($cms-blue,15%); -$blue-s2: saturate($cms-blue,30%); -$blue-s3: saturate($cms-blue,45%); -$blue-u1: desaturate($cms-blue,15%); -$blue-u2: desaturate($cms-blue,30%); -$blue-u3: desaturate($cms-blue,45%); -$blue-t0: rgba($cms-blue, 0.125); -$blue-t1: rgba($cms-blue, 0.25); -$blue-t2: rgba($cms-blue, 0.50); -$blue-t3: rgba($cms-blue, 0.75); - $pink: rgb(182,37,103); // #b72567; $pink-l1: tint($pink,20%); $pink-l2: tint($pink,40%); diff --git a/test_root/db/test_edx.db-journal b/test_root/db/test_edx.db-journal new file mode 100644 index 0000000000..e69de29bb2 From e6cbff47b71d3ae01fcc5708b2a6b4b843bd16f9 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 22 Jun 2015 10:54:41 -0400 Subject: [PATCH 27/97] Switch tabs to use ugettext_noop for titles. --- common/lib/xmodule/xmodule/tabs.py | 3 ++- lms/djangoapps/ccx/plugins.py | 4 ++-- lms/djangoapps/course_wiki/tab.py | 4 ++-- lms/djangoapps/courseware/tabs.py | 14 +++++++------- .../django_comment_client/forum/views.py | 4 ++-- lms/djangoapps/edxnotes/plugins.py | 4 ++-- .../instructor/views/instructor_dashboard.py | 4 ++-- lms/djangoapps/notes/views.py | 4 ++-- lms/djangoapps/teams/plugins.py | 4 ++-- 9 files changed, 23 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index 9a713ac046..8671d715cd 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -28,7 +28,8 @@ class CourseTab(object): # subclass, shared by all instances of the subclass. type = '' - # The title of the tab, which should be internationalized + # The title of the tab, which should be internationalized using + # ugettext_noop since the user won't be available in this context. title = None # Class property that specifies whether the tab can be hidden for a particular course diff --git a/lms/djangoapps/ccx/plugins.py b/lms/djangoapps/ccx/plugins.py index 61d01009aa..5408dbdaf7 100644 --- a/lms/djangoapps/ccx/plugins.py +++ b/lms/djangoapps/ccx/plugins.py @@ -3,7 +3,7 @@ Registers the CCX feature for the edX platform. """ from django.conf import settings -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop from xmodule.tabs import CourseTab from student.roles import CourseCcxCoachRole @@ -15,7 +15,7 @@ class CcxCourseTab(CourseTab): """ type = "ccx_coach" - title = _("CCX Coach") + title = ugettext_noop("CCX Coach") view_name = "ccx_coach_dashboard" is_dynamic = True # The CCX view is dynamically added to the set of tabs when it is enabled diff --git a/lms/djangoapps/course_wiki/tab.py b/lms/djangoapps/course_wiki/tab.py index cf681eaa4a..e825ea8087 100644 --- a/lms/djangoapps/course_wiki/tab.py +++ b/lms/djangoapps/course_wiki/tab.py @@ -4,7 +4,7 @@ a user has on an article. """ from django.conf import settings -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop from courseware.tabs import EnrolledTab @@ -15,7 +15,7 @@ class WikiTab(EnrolledTab): """ type = "wiki" - title = _('Wiki') + title = ugettext_noop('Wiki') view_name = "course_wiki" is_hideable = True is_default = False diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index cc2d6916d8..d23de14ceb 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -3,7 +3,7 @@ This module is essentially a broker to xmodule/tabs.py -- it was originally intr perform some LMS-specific tab display gymnastics for the Entrance Exams feature """ from django.conf import settings -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext as _, ugettext_noop from courseware.access import has_access from courseware.entrance_exams import user_must_complete_entrance_exam @@ -28,7 +28,7 @@ class CoursewareTab(EnrolledTab): The main courseware view. """ type = 'courseware' - title = _('Courseware') + title = ugettext_noop('Courseware') priority = 10 view_name = 'courseware' is_movable = False @@ -40,7 +40,7 @@ class CourseInfoTab(CourseTab): The course info view. """ type = 'course_info' - title = _('Course Info') + title = ugettext_noop('Course Info') priority = 20 view_name = 'info' tab_id = 'info' @@ -57,7 +57,7 @@ class SyllabusTab(EnrolledTab): A tab for the course syllabus. """ type = 'syllabus' - title = _('Syllabus') + title = ugettext_noop('Syllabus') priority = 30 view_name = 'syllabus' allow_multiple = True @@ -75,7 +75,7 @@ class ProgressTab(EnrolledTab): The course progress view. """ type = 'progress' - title = _('Progress') + title = ugettext_noop('Progress') priority = 40 view_name = 'progress' is_hideable = True @@ -93,7 +93,7 @@ class TextbookTabsBase(CourseTab): Abstract class for textbook collection tabs classes. """ # Translators: 'Textbooks' refers to the tab in the course that leads to the course' textbooks - title = _("Textbooks") + title = ugettext_noop("Textbooks") is_collection = True is_default = False @@ -225,7 +225,7 @@ class ExternalDiscussionCourseTab(LinkTab): type = 'external_discussion' # Translators: 'Discussion' refers to the tab in the courseware that leads to the discussion forums - title = _('Discussion') + title = ugettext_noop('Discussion') priority = None is_default = False diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 8470cb0b36..8608f1bce8 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -13,7 +13,7 @@ from django.core.context_processors import csrf from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.http import Http404, HttpResponseBadRequest -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop from django.views.decorators.http import require_GET import newrelic.agent @@ -55,7 +55,7 @@ class DiscussionTab(EnrolledTab): """ type = 'discussion' - title = _('Discussion') + title = ugettext_noop('Discussion') priority = None view_name = 'django_comment_client.forum.views.forum_form_discussion' is_hideable = settings.FEATURES.get('ALLOW_HIDING_DISCUSSION_TAB', False) diff --git a/lms/djangoapps/edxnotes/plugins.py b/lms/djangoapps/edxnotes/plugins.py index f427a3e9a2..0c0d3c559a 100644 --- a/lms/djangoapps/edxnotes/plugins.py +++ b/lms/djangoapps/edxnotes/plugins.py @@ -2,7 +2,7 @@ Registers the "edX Notes" feature for the edX platform. """ -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop from courseware.tabs import EnrolledTab @@ -13,7 +13,7 @@ class EdxNotesTab(EnrolledTab): """ type = "edxnotes" - title = _("Notes") + title = ugettext_noop("Notes") view_name = "edxnotes" @classmethod diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7e83dfe5a2..4adf82eddc 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -11,7 +11,7 @@ import pytz from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_POST -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext as _, ugettext_noop from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from edxmako.shortcuts import render_to_response @@ -53,7 +53,7 @@ class InstructorDashboardTab(CourseTab): """ type = "instructor" - title = _('Instructor') + title = ugettext_noop('Instructor') view_name = "instructor_dashboard" is_dynamic = True # The "Instructor" tab is instead dynamically added when it is enabled diff --git a/lms/djangoapps/notes/views.py b/lms/djangoapps/notes/views.py index eedce977aa..4b2ce51c02 100644 --- a/lms/djangoapps/notes/views.py +++ b/lms/djangoapps/notes/views.py @@ -13,7 +13,7 @@ from courseware.tabs import EnrolledTab from notes.models import Note from notes.utils import notes_enabled_for_course from xmodule.annotator_token import retrieve_token -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop @login_required @@ -45,7 +45,7 @@ class NotesTab(EnrolledTab): A tab for the course notes. """ type = 'notes' - title = _("My Notes") + title = ugettext_noop("My Notes") view_name = "notes" @classmethod diff --git a/lms/djangoapps/teams/plugins.py b/lms/djangoapps/teams/plugins.py index 9ff162288e..a613eb7740 100644 --- a/lms/djangoapps/teams/plugins.py +++ b/lms/djangoapps/teams/plugins.py @@ -2,7 +2,7 @@ Definition of the course team feature. """ -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop from courseware.tabs import EnrolledTab from .views import is_feature_enabled @@ -13,7 +13,7 @@ class TeamsTab(EnrolledTab): """ type = "teams" - title = _("Teams") + title = ugettext_noop("Teams") view_name = "teams_dashboard" @classmethod From 79527f8191424581477788f2adf98c3ece879a00 Mon Sep 17 00:00:00 2001 From: Jesse Zoldak Date: Tue, 23 Jun 2015 11:22:12 -0400 Subject: [PATCH 28/97] Update bok-choy version --- requirements/edx/base.txt | 1 + requirements/edx/github.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index e5ade359ef..5ce6c3a4f0 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -123,6 +123,7 @@ django_debug_toolbar==1.2.2 # Used for testing astroid==1.3.4 +bok-choy==0.4.1 chrono==1.0.2 coverage==3.7 ddt==0.8.0 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index d7fb1bbc04..3db9e7883e 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -36,7 +36,6 @@ git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c -e git+https://github.com/edx/codejail.git@6b17c33a89bef0ac510926b1d7fea2748b73aadd#egg=codejail -e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool -e git+https://github.com/edx/event-tracking.git@0.2.0#egg=event-tracking --e git+https://github.com/edx/bok-choy.git@1c968796129f4d281e112804b889b6f369f52011#egg=bok_choy -e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash -e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock -e git+https://github.com/edx/edx-ora2.git@release-2015-05-08T16.15#egg=edx-ora2 From de142b2b51b0eb38ac73616102f5131886536e57 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 17 Jun 2015 18:33:02 -0400 Subject: [PATCH 29/97] Set a minimum number of Xblock constructions in field override tests --- lms/djangoapps/ccx/tests/test_field_override_performance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index 8d4eff1ac5..170f20fd0c 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -143,7 +143,7 @@ class FieldOverridePerformanceTestCase(ProceduralCourseTestMixin, with self.assertNumQueries(queries): with check_mongo_calls(reads): - with check_sum_of_calls(XBlock, ['__init__'], xblocks): + with check_sum_of_calls(XBlock, ['__init__'], xblocks, xblocks): self.grade_course(self.course) @ddt.data(*itertools.product(('no_overrides', 'ccx'), range(1, 4), (True, False))) From 54756317f1ab388c000408983ec9d0826869f343 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 17 Jun 2015 00:41:18 -0400 Subject: [PATCH 30/97] Add followed thread retrieval to discussion API This implements the "following" value for the "view" query parameter on the thread list endpoint. --- lms/djangoapps/discussion_api/api.py | 43 ++++++++++++++----- lms/djangoapps/discussion_api/forms.py | 12 +++++- lms/djangoapps/discussion_api/serializers.py | 4 +- .../discussion_api/tests/test_api.py | 28 ++++++++++++ .../discussion_api/tests/test_forms.py | 15 +++++-- .../discussion_api/tests/test_views.py | 25 +++++++++++ lms/djangoapps/discussion_api/tests/utils.py | 13 ++++++ lms/djangoapps/discussion_api/views.py | 7 +++ 8 files changed, 131 insertions(+), 16 deletions(-) diff --git a/lms/djangoapps/discussion_api/api.py b/lms/djangoapps/discussion_api/api.py index c307140552..0e842d2931 100644 --- a/lms/djangoapps/discussion_api/api.py +++ b/lms/djangoapps/discussion_api/api.py @@ -92,14 +92,28 @@ def _get_comment_and_context(request, comment_id): raise Http404 -def get_thread_list_url(request, course_key, topic_id_list=None): +def _is_user_author_or_privileged(cc_content, context): + """ + Check if the user is the author of a content object or a privileged user. + + Returns: + Boolean + """ + return ( + context["is_requester_privileged"] or + context["cc_requester"]["id"] == cc_content["user_id"] + ) + + +def get_thread_list_url(request, course_key, topic_id_list=None, following=False): """ Returns the URL for the thread_list_url field, given a list of topic_ids """ path = reverse("thread-list") query_list = ( [("course_id", unicode(course_key))] + - [("topic_id", topic_id) for topic_id in topic_id_list or []] + [("topic_id", topic_id) for topic_id in topic_id_list or []] + + ([("following", following)] if following else []) ) return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), ""))) @@ -132,7 +146,8 @@ def get_course(request, course_key): {"start": blackout["start"].isoformat(), "end": blackout["end"].isoformat()} for blackout in course.get_discussion_blackout_datetimes() ], - "thread_list_url": get_thread_list_url(request, course_key, topic_id_list=[]), + "thread_list_url": get_thread_list_url(request, course_key), + "following_thread_list_url": get_thread_list_url(request, course_key, following=True), "topics_url": request.build_absolute_uri( reverse("course_topics", kwargs={"course_id": course_key}) ) @@ -211,7 +226,7 @@ def get_course_topics(request, course_key): } -def get_thread_list(request, course_key, page, page_size, topic_id_list=None, text_search=None): +def get_thread_list(request, course_key, page, page_size, topic_id_list=None, text_search=None, following=False): """ Return the list of all discussion threads pertaining to the given course @@ -223,8 +238,9 @@ def get_thread_list(request, course_key, page, page_size, topic_id_list=None, te page_size: The number of threads to retrieve per page topic_id_list: The list of topic_ids to get the discussion threads for text_search A text search query string to match + following: If true, retrieve only threads the requester is following - Note that topic_id_list and text_search are mutually exclusive. + Note that topic_id_list, text_search, and following are mutually exclusive. Returns: @@ -238,15 +254,13 @@ def get_thread_list(request, course_key, page, page_size, topic_id_list=None, te Http404: if the requesting user does not have access to the requested course or a page beyond the last is requested """ - exclusive_param_count = sum(1 for param in [topic_id_list, text_search] if param) + exclusive_param_count = sum(1 for param in [topic_id_list, text_search, following] if param) if exclusive_param_count > 1: # pragma: no cover raise ValueError("More than one mutually exclusive param passed to get_thread_list") course = _get_course_or_404(course_key, request.user) context = get_context(course, request) - topic_ids_csv = ",".join(topic_id_list) if topic_id_list else None - threads, result_page, num_pages, text_search_rewrite = Thread.search({ - "course_id": unicode(course.id), + query_params = { "group_id": ( None if context["is_requester_privileged"] else get_cohort_id(request.user, course.id) @@ -255,9 +269,16 @@ def get_thread_list(request, course_key, page, page_size, topic_id_list=None, te "sort_order": "desc", "page": page, "per_page": page_size, - "commentable_ids": topic_ids_csv, "text": text_search, - }) + } + text_search_rewrite = None + if following: + threads, result_page, num_pages = context["cc_requester"].subscribed_threads(query_params) + else: + query_params["course_id"] = unicode(course.id) + query_params["commentable_ids"] = ",".join(topic_id_list) if topic_id_list else None + query_params["text"] = text_search + threads, result_page, num_pages, text_search_rewrite = Thread.search(query_params) # The comments service returns the last page of results if the requested # page is beyond the last page, but we want be consistent with DRF's general # behavior and return a 404 in that case diff --git a/lms/djangoapps/discussion_api/forms.py b/lms/djangoapps/discussion_api/forms.py index 67a32b0f42..6ebc6bcd9d 100644 --- a/lms/djangoapps/discussion_api/forms.py +++ b/lms/djangoapps/discussion_api/forms.py @@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError from django.forms import ( BooleanField, CharField, + ChoiceField, Field, Form, IntegerField, @@ -45,11 +46,12 @@ class ThreadListGetForm(_PaginationForm): """ A form to validate query parameters in the thread list retrieval endpoint """ - EXCLUSIVE_PARAMS = ["topic_id", "text_search"] + EXCLUSIVE_PARAMS = ["topic_id", "text_search", "following"] course_id = CharField() topic_id = TopicIdField(required=False) text_search = CharField(required=False) + following = NullBooleanField(required=False) def clean_course_id(self): """Validate course_id""" @@ -59,6 +61,14 @@ class ThreadListGetForm(_PaginationForm): except InvalidKeyError: raise ValidationError("'{}' is not a valid course id".format(value)) + def clean_following(self): + """Validate following""" + value = self.cleaned_data["following"] + if value is False: + raise ValidationError("The value of the 'following' parameter must be true.") + else: + return value + def clean(self): cleaned_data = super(ThreadListGetForm, self).clean() exclusive_params_count = sum( diff --git a/lms/djangoapps/discussion_api/serializers.py b/lms/djangoapps/discussion_api/serializers.py index 147e612b44..4ee207e16f 100644 --- a/lms/djangoapps/discussion_api/serializers.py +++ b/lms/djangoapps/discussion_api/serializers.py @@ -47,6 +47,8 @@ def get_context(course, request, thread=None): for user in role.users.all() } requester = request.user + cc_requester = CommentClientUser.from_django_user(requester).retrieve() + cc_requester["course_id"] = course.id return { "course": course, "request": request, @@ -56,7 +58,7 @@ def get_context(course, request, thread=None): "is_requester_privileged": requester.id in staff_user_ids or requester.id in ta_user_ids, "staff_user_ids": staff_user_ids, "ta_user_ids": ta_user_ids, - "cc_requester": CommentClientUser.from_django_user(requester).retrieve(), + "cc_requester": cc_requester, } diff --git a/lms/djangoapps/discussion_api/tests/test_api.py b/lms/djangoapps/discussion_api/tests/test_api.py index e46a95e527..2008b2a907 100644 --- a/lms/djangoapps/discussion_api/tests/test_api.py +++ b/lms/djangoapps/discussion_api/tests/test_api.py @@ -99,6 +99,9 @@ class GetCourseTest(UrlResetMixin, ModuleStoreTestCase): "id": unicode(self.course.id), "blackouts": [], "thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz", + "following_thread_list_url": ( + "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz&following=True" + ), "topics_url": "http://testserver/api/discussion/v1/course_topics/x/y/z", } ) @@ -741,6 +744,31 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "text": ["test search string"], }) + def test_following(self): + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + following=True, + ) + self.assertEqual( + result, + {"results": [], "next": None, "previous": None, "text_search_rewrite": None} + ) + self.assertEqual( + urlparse(httpretty.last_request().path).path, + "/api/v1/users/{}/subscribed_threads".format(self.user.id) + ) + self.assert_last_query_params({ + "course_id": [unicode(self.course.id)], + "sort_key": ["date"], + "sort_order": ["desc"], + "page": ["1"], + "per_page": ["11"], + }) + @ddt.ddt class GetCommentListTest(CommentsServiceMockMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/discussion_api/tests/test_forms.py b/lms/djangoapps/discussion_api/tests/test_forms.py index 60223c58fd..1d3c8e87e5 100644 --- a/lms/djangoapps/discussion_api/tests/test_forms.py +++ b/lms/djangoapps/discussion_api/tests/test_forms.py @@ -94,6 +94,7 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): "page_size": 13, "topic_id": [], "text_search": "", + "following": None, } ) @@ -125,12 +126,20 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): self.form_data.setlist("topic_id", ["", "not empty"]) self.assert_error("topic_id", "This field cannot be empty.") - @ddt.data(*itertools.combinations(["topic_id", "text_search"], 2)) + def test_following_true(self): + self.form_data["following"] = "True" + self.assert_field_value("following", True) + + def test_following_false(self): + self.form_data["following"] = "False" + self.assert_error("following", "The value of the 'following' parameter must be true.") + + @ddt.data(*itertools.combinations(["topic_id", "text_search", "following"], 2)) def test_mutually_exclusive(self, params): - self.form_data.update({param: "dummy" for param in params}) + self.form_data.update({param: "True" for param in params}) self.assert_error( "__all__", - "The following query parameters are mutually exclusive: topic_id, text_search" + "The following query parameters are mutually exclusive: topic_id, text_search, following" ) diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index 302badbc14..fff1722d82 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -92,6 +92,9 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "id": unicode(self.course.id), "blackouts": [], "thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz", + "following_thread_list_url": ( + "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz&following=True" + ), "topics_url": "http://testserver/api/discussion/v1/course_topics/x/y/z", } ) @@ -270,6 +273,28 @@ class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "text": ["test search string"], }) + def test_following(self): + self.register_get_user_response(self.user) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1) + response = self.client.get( + self.url, + { + "course_id": unicode(self.course.id), + "page": "1", + "page_size": "4", + "following": "True", + } + ) + self.assert_response_correct( + response, + 200, + {"results": [], "next": None, "previous": None, "text_search_rewrite": None} + ) + self.assertEqual( + urlparse(httpretty.last_request().path).path, + "/api/v1/users/{}/subscribed_threads".format(self.user.id) + ) + @httpretty.activate class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/discussion_api/tests/utils.py b/lms/djangoapps/discussion_api/tests/utils.py index 5dc0e3f5f5..aacab5b42d 100644 --- a/lms/djangoapps/discussion_api/tests/utils.py +++ b/lms/djangoapps/discussion_api/tests/utils.py @@ -191,6 +191,19 @@ class CommentsServiceMockMixin(object): status=200 ) + def register_subscribed_threads_response(self, user, threads, page, num_pages): + """Register a mock response for GET on the CS user instance endpoint""" + httpretty.register_uri( + httpretty.GET, + "http://localhost:4567/api/v1/users/{}/subscribed_threads".format(user.id), + body=json.dumps({ + "collection": threads, + "page": page, + "num_pages": num_pages, + }), + status=200 + ) + def register_subscription_response(self, user): """ Register a mock response for POST and DELETE on the CS user subscription diff --git a/lms/djangoapps/discussion_api/views.py b/lms/djangoapps/discussion_api/views.py index 0658bcf382..9002187fe6 100644 --- a/lms/djangoapps/discussion_api/views.py +++ b/lms/djangoapps/discussion_api/views.py @@ -141,6 +141,12 @@ class ThreadViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): (including the bodies of comments in the thread) matches the search string will be returned. + * following: If true, retrieve only threads the requesting user is + following + + The topic_id, text_search, and following parameters are mutually + exclusive (i.e. only one may be specified in a request) + **POST Parameters**: * course_id (required): The course to create the thread in @@ -229,6 +235,7 @@ class ThreadViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): form.cleaned_data["page_size"], form.cleaned_data["topic_id"], form.cleaned_data["text_search"], + form.cleaned_data["following"], ) ) From e184c78c0ac9f8ac7b4def7d74added86bb51a3f Mon Sep 17 00:00:00 2001 From: Nickersoft Date: Fri, 19 Jun 2015 15:05:46 -0400 Subject: [PATCH 31/97] XCOM-416: Embargo restrictions are now enforced during logistration --- common/djangoapps/embargo/api.py | 30 +++++++++++++++++++ .../djangoapps/enrollment/tests/test_views.py | 7 ++--- common/djangoapps/enrollment/views.py | 20 +++---------- lms/djangoapps/commerce/tests/test_views.py | 15 +++++++++- lms/djangoapps/commerce/views.py | 6 ++++ openedx/core/lib/django_test_client_utils.py | 5 ++++ 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/common/djangoapps/embargo/api.py b/common/djangoapps/embargo/api.py index 1142db9116..c928dd7946 100644 --- a/common/djangoapps/embargo/api.py +++ b/common/djangoapps/embargo/api.py @@ -10,6 +10,9 @@ import pygeoip from django.core.cache import cache from django.conf import settings +from rest_framework.response import Response +from rest_framework import status +from ipware.ip import get_ip from embargo.models import CountryAccessRule, RestrictedCourse @@ -166,3 +169,30 @@ def _country_code_from_ip(ip_addr): return pygeoip.GeoIP(settings.GEOIPV6_PATH).country_code_by_addr(ip_addr) else: return pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr) + + +def get_embargo_response(request, course_id, user): + """ + Check whether any country access rules block the user from enrollment. + + Args: + request (HttpRequest): The request object + course_id (str): The requested course ID + user (str): The current user object + + Returns: + HttpResponse: Response of the embargo page if embargoed, None if not + + """ + redirect_url = redirect_if_blocked( + course_id, user=user, ip_address=get_ip(request), url=request.path) + if redirect_url: + return Response( + status=status.HTTP_403_FORBIDDEN, + data={ + "message": ( + u"Users from this location cannot access the course '{course_id}'." + ).format(course_id=course_id), + "user_message_url": request.build_absolute_uri(redirect_url) + } + ) diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index 5d4355fd04..7fa5cf9e10 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -26,6 +26,7 @@ from util.models import RateLimitConfiguration from util.testing import UrlResetMixin from enrollment import api from enrollment.errors import CourseEnrollmentError +from openedx.core.lib.django_test_client_utils import get_absolute_url from openedx.core.djangoapps.user_api.models import UserOrgTag from student.tests.factories import UserFactory, CourseModeFactory from student.models import CourseEnrollment @@ -725,10 +726,6 @@ class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestC 'user': self.user.username }) - def _get_absolute_url(self, path): - """ Generate an absolute URL for a resource on the test server. """ - return u'http://testserver/{}'.format(path.lstrip('/')) - def assert_access_denied(self, user_message_path): """ Verify that the view returns HTTP status 403 and includes a URL in the response, and no enrollment is created. @@ -741,7 +738,7 @@ class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestC # Expect that the redirect URL is included in the response resp_data = json.loads(response.content) - user_message_url = self._get_absolute_url(user_message_path) + user_message_url = get_absolute_url(user_message_path) self.assertEqual(resp_data['user_message_url'], user_message_url) # Verify that we were not enrolled diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index eef151a8b9..0444d8385a 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -32,7 +32,6 @@ from enrollment.errors import ( ) from student.models import User - log = logging.getLogger(__name__) @@ -406,21 +405,10 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): } ) - # Check whether any country access rules block the user from enrollment - # We do this at the view level (rather than the Python API level) - # because this check requires information about the HTTP request. - redirect_url = embargo_api.redirect_if_blocked( - course_id, user=user, ip_address=get_ip(request), url=request.path) - if redirect_url: - return Response( - status=status.HTTP_403_FORBIDDEN, - data={ - "message": ( - u"Users from this location cannot access the course '{course_id}'." - ).format(course_id=course_id), - "user_message_url": request.build_absolute_uri(redirect_url) - } - ) + embargo_response = embargo_api.get_embargo_response(request, course_id, user) + + if embargo_response: + return embargo_response try: is_active = request.DATA.get('is_active') diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 0c88269c8b..4a5cbc5a47 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -5,6 +5,7 @@ from uuid import uuid4 from nose.plugins.attrib import attr import ddt +from django.conf import settings from django.core.urlresolvers import reverse from django.test import TestCase from django.test.utils import override_settings @@ -17,6 +18,8 @@ from commerce.constants import Messages from commerce.tests import TEST_BASKET_ID, TEST_ORDER_NUMBER, TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY from commerce.tests.mocks import mock_basket_order, mock_create_basket from course_modes.models import CourseMode +from embargo.test_utils import restrict_course +from openedx.core.lib.django_test_client_utils import get_absolute_url from enrollment.api import get_enrollment from student.models import CourseEnrollment from student.tests.factories import UserFactory, CourseModeFactory @@ -42,7 +45,6 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase) """ Tests for the commerce orders view. """ - def _post_to_view(self, course_id=None): """ POST to the view being tested. @@ -96,6 +98,17 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase) # Ignore events fired from UserFactory creation self.reset_tracker() + @mock.patch.dict(settings.FEATURES, {'EMBARGO': True}) + def test_embargo_restriction(self): + """ + The view should return HTTP 403 status if the course is embargoed. + """ + with restrict_course(self.course.id) as redirect_url: + response = self._post_to_view() + self.assertEqual(403, response.status_code) + body = json.loads(response.content) + self.assertEqual(get_absolute_url(redirect_url), body['user_message_url']) + def test_login_required(self): """ The view should return HTTP 403 status if the user is not logged in. diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index d3e2a80a34..03394c4ebb 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -20,6 +20,7 @@ from course_modes.models import CourseMode from courseware import courses from edxmako.shortcuts import render_to_response from enrollment.api import add_enrollment +from embargo import api as embargo_api from microsite_configuration import microsite from student.models import CourseEnrollment from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser @@ -76,6 +77,11 @@ class BasketsView(APIView): if not valid: return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE) + embargo_response = embargo_api.get_embargo_response(request, course_key, user) + + if embargo_response: + return embargo_response + # Don't do anything if an enrollment already exists course_id = unicode(course_key) enrollment = CourseEnrollment.get_enrollment(user, course_key) diff --git a/openedx/core/lib/django_test_client_utils.py b/openedx/core/lib/django_test_client_utils.py index 42d67f8fe9..e48c502550 100644 --- a/openedx/core/lib/django_test_client_utils.py +++ b/openedx/core/lib/django_test_client_utils.py @@ -52,3 +52,8 @@ if not hasattr(RequestFactory, 'patch'): if not hasattr(Client, 'patch'): setattr(Client, 'patch', client_patch) + + +def get_absolute_url(path): + """ Generate an absolute URL for a resource on the test server. """ + return u'http://testserver/{}'.format(path.lstrip('/')) From f81e373bd487e550fa7e2b01b49831e88555e28e Mon Sep 17 00:00:00 2001 From: Christine Lytwynec Date: Tue, 23 Jun 2015 14:04:19 -0400 Subject: [PATCH 32/97] don't use hardcoded date in verification email test --- lms/djangoapps/verify_student/tests/test_views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 015f3effdd..dc6620da0c 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -2023,8 +2023,7 @@ class TestEmailMessageWithCustomICRVBlock(ModuleStoreTestCase): def test_denied_email_message_with_close_verification_dates(self): # Due date given and expired - - return_value = datetime(2016, 1, 1, tzinfo=timezone.utc) + return_value = datetime.now(tz=pytz.UTC) + timedelta(days=22) with patch.object(timezone, 'now', return_value=return_value): __, body = _compose_message_reverification_email( self.course.id, self.user.id, self.reverification_location, "denied", self.request From 16745cdec3fd6c2d1fbd829e267832b12cdbd89c Mon Sep 17 00:00:00 2001 From: Christine Lytwynec Date: Mon, 22 Jun 2015 15:54:01 -0400 Subject: [PATCH 33/97] pass debug option through to compile_sass --- pavelib/assets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pavelib/assets.py b/pavelib/assets.py index 212cd7158d..a49a586fd3 100644 --- a/pavelib/assets.py +++ b/pavelib/assets.py @@ -132,10 +132,12 @@ def compile_coffeescript(*files): @task @no_help -def compile_sass(debug=False): +@cmdopts([('debug', 'd', 'Debug mode')]) +def compile_sass(options): """ Compile Sass to CSS. """ + debug = options.get('debug') parts = ["sass"] parts.append("--update") parts.append("--cache-location {cache}".format(cache=SASS_CACHE_PATH)) @@ -244,7 +246,7 @@ def update_assets(args): compile_templated_sass(args.system, args.settings) process_xmodule_assets() compile_coffeescript() - compile_sass(args.debug) + call_task('compile_sass', options={'debug': args.debug}) if args.collect: collect_assets(args.system, args.settings) From b89ad57cb7ccacb954ac51ded2b203a7b6a340ab Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 23 Jun 2015 12:47:15 -0700 Subject: [PATCH 34/97] Remove duplicate logged-in-cookie helper methods There were two helper methods that set the marketing site "logged in" cookie. This commit deletes one of them and ensures that all callers are using the other one. --- common/djangoapps/student/views.py | 29 +---------------------- openedx/core/djangoapps/user_api/views.py | 5 ++-- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 81334ed54b..5cc906ad23 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1550,33 +1550,6 @@ def create_account_with_params(request, params): AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email)) -def set_marketing_cookie(request, response): - """ - Set the login cookie for the edx marketing site on the given response. Its - expiration will match that of the given request's session. - """ - if request.session.get_expire_at_browser_close(): - max_age = None - expires = None - else: - max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) - - # we want this cookie to be accessed via javascript - # so httponly is set to None - response.set_cookie( - settings.EDXMKTG_COOKIE_NAME, - 'true', - max_age=max_age, - expires=expires, - domain=settings.SESSION_COOKIE_DOMAIN, - path='/', - secure=None, - httponly=None - ) - - @csrf_exempt def create_account(request, post_override=None): """ @@ -1611,7 +1584,7 @@ def create_account(request, post_override=None): 'success': True, 'redirect_url': redirect_url, }) - set_marketing_cookie(request, response) + set_logged_in_cookie(request, response) return response diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index efc13a291c..e357cd97cb 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -24,7 +24,8 @@ from openedx.core.lib.api.permissions import ApiKeyHeaderPermission import third_party_auth from django_comment_common.models import Role from edxmako.shortcuts import marketing_link -from student.views import create_account_with_params, set_marketing_cookie +from student.views import create_account_with_params +from student.helpers import set_logged_in_cookie from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser from util.json_request import JsonResponse from .preferences.api import update_email_opt_in @@ -306,7 +307,7 @@ class RegistrationView(APIView): return JsonResponse(errors, status=400) response = JsonResponse({"success": True}) - set_marketing_cookie(request, response) + set_logged_in_cookie(request, response) return response def _add_email_field(self, form_desc, required=True): From 51a72c63fa95f1295e71e231aa0d4b7cf813d782 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 23 Jun 2015 16:12:36 -0400 Subject: [PATCH 35/97] Delete files that have no references to them. --- lms/static/js/help.js | 10 - lms/static/js/html5shiv.js | 3 - lms/static/js/simplewiki-AutoSuggest_c_2.0.js | 961 ------------------ lms/static/js/sticky_footer.js | 23 - 4 files changed, 997 deletions(-) delete mode 100644 lms/static/js/help.js delete mode 100644 lms/static/js/html5shiv.js delete mode 100644 lms/static/js/simplewiki-AutoSuggest_c_2.0.js delete mode 100644 lms/static/js/sticky_footer.js diff --git a/lms/static/js/help.js b/lms/static/js/help.js deleted file mode 100644 index dc6e22b1fb..0000000000 --- a/lms/static/js/help.js +++ /dev/null @@ -1,10 +0,0 @@ -$(document).ready(function() { - var open_question = ""; - var question_id; - - $('.response').click(function(){ - $(this).toggleClass('opened'); - answer = $(this).find(".answer"); - answer.slideToggle('fast'); - }); -}); diff --git a/lms/static/js/html5shiv.js b/lms/static/js/html5shiv.js deleted file mode 100644 index 1ec510f2a4..0000000000 --- a/lms/static/js/html5shiv.js +++ /dev/null @@ -1,3 +0,0 @@ -// HTML5 Shiv v3 | @jon_neal @afarkas @rem | MIT/GPL2 Licensed -// Uncompressed source: https://github.com/aFarkas/html5shiv -(function(a,b){function f(a){var c,d,e,f;b.documentMode>7?(c=b.createElement("font"),c.setAttribute("data-html5shiv",a.nodeName.toLowerCase())):c=b.createElement("shiv:"+a.nodeName);while(a.firstChild)c.appendChild(a.childNodes[0]);for(d=a.attributes,e=d.length,f=0;f7?e[g][e[g].length-1]=e[g][e[g].length-1].replace(d,'$1font[data-html5shiv="$2"]'):e[g][e[g].length-1]=e[g][e[g].length-1].replace(d,"$1shiv\\:$2"),e[g]=e[g].join("}");return e.join("{")}var c=function(a){return a.innerHTML="",a.childNodes.length===1}(b.createElement("a")),d=function(a,b,c){return b.appendChild(a),(c=(c?c(a):a.currentStyle).display)&&b.removeChild(a)&&c==="block"}(b.createElement("nav"),b.documentElement,a.getComputedStyle),e={elements:"abbr article aside audio bdi canvas data datalist details figcaption figure footer header hgroup mark meter nav output progress section summary time video".split(" "),shivDocument:function(a){a=a||b;if(a.documentShived)return;a.documentShived=!0;var f=a.createElement,g=a.createDocumentFragment,h=a.getElementsByTagName("head")[0],i=function(a){f(a)};c||(e.elements.join(" ").replace(/\w+/g,i),a.createElement=function(a){var b=f(a);return b.canHaveChildren&&e.shivDocument(b.document),b},a.createDocumentFragment=function(){return e.shivDocument(g())});if(!d&&h){var j=f("div");j.innerHTML=["x"].join(""),h.insertBefore(j.lastChild,h.firstChild)}return a}};e.shivDocument(b),a.html5=e;if(c||!a.attachEvent)return;a.attachEvent("onbeforeprint",function(){if(a.html5.supportsXElement||!b.namespaces)return;b.namespaces.shiv||b.namespaces.add("shiv");var c=-1,d=new RegExp("^("+a.html5.elements.join("|")+")$","i"),e=b.getElementsByTagName("*"),g=e.length,j,k=i(h(function(a,b){var c=[],d=a.length;while(d)c.unshift(a[--d]);d=b.length;while(d)c.unshift(b[--d]);c.sort(function(a,b){return a.sourceIndex-b.sourceIndex}),d=c.length;while(d)c[--d]=c[d].styleSheet;return c}(b.getElementsByTagName("style"),b.getElementsByTagName("link"))));while(++cthis.nInputChars && this.aSuggestions.length && this.oP.cache) - { - var arr = []; - for (var i=0;i" + val.substring(st, st+this.sInput.length) + "" + val.substring(st+this.sInput.length); - - - var span = _bsn.DOM.createElement("span", {}, output, true); - if (arr[i].info != "") - { - var br = _bsn.DOM.createElement("br", {}); - span.appendChild(br); - var small = _bsn.DOM.createElement("small", {}, arr[i].info); - span.appendChild(small); - } - - var a = _bsn.DOM.createElement("a", { href:"#" }); - - var tl = _bsn.DOM.createElement("span", {className:"tl"}, " "); - var tr = _bsn.DOM.createElement("span", {className:"tr"}, " "); - a.appendChild(tl); - a.appendChild(tr); - - a.appendChild(span); - - a.name = i+1; - a.onclick = function () { pointer.setHighlightedValue(); return false; } - a.onmouseover = function () { pointer.setHighlight(this.name); } - - var li = _bsn.DOM.createElement( "li", {}, a ); - - ul.appendChild( li ); - } - - - // no results - // - if (arr.length == 0) - { - var li = _bsn.DOM.createElement( "li", {className:"as_warning"}, this.oP.noresults ); - - ul.appendChild( li ); - } - - - div.appendChild( ul ); - - - var fcorner = _bsn.DOM.createElement("div", {className:"as_corner"}); - var fbar = _bsn.DOM.createElement("div", {className:"as_bar"}); - var footer = _bsn.DOM.createElement("div", {className:"as_footer"}); - footer.appendChild(fcorner); - footer.appendChild(fbar); - div.appendChild(footer); - - - - // get position of target textfield - // position holding div below it - // set width of holding div to width of field - // - var pos = _bsn.DOM.getPos(this.fld); - - div.style.left = pos.x + "px"; - div.style.top = ( pos.y + this.fld.offsetHeight + this.oP.offsety ) + "px"; - div.style.width = this.fld.offsetWidth + "px"; - - - - // set mouseover functions for div - // when mouse pointer leaves div, set a timeout to remove the list after an interval - // when mouse enters div, kill the timeout so the list won't be removed - // - div.onmouseover = function(){ pointer.killTimeout() } - div.onmouseout = function(){ pointer.resetTimeout() } - - - // add DIV to document - // - document.getElementsByTagName("body")[0].appendChild(div); - - - - // currently no item is highlighted - // - this.iHighlighted = 0; - - - - - - - // remove list after an interval - // - var pointer = this; - this.toID = setTimeout(function () { pointer.clearSuggestions() }, this.oP.timeout); -} - - - - - - - - - - - - - - - -_bsn.AutoSuggest.prototype.changeHighlight = function(key) -{ - var list = _bsn.DOM.getElement("as_ul"); - if (!list) - return false; - - var n; - - if (key == 40) - n = this.iHighlighted + 1; - else if (key == 38) - n = this.iHighlighted - 1; - - - if (n > list.childNodes.length) - n = list.childNodes.length; - if (n < 1) - n = 1; - - - this.setHighlight(n); -} - - - -_bsn.AutoSuggest.prototype.setHighlight = function(n) -{ - var list = _bsn.DOM.getElement("as_ul"); - if (!list) - return false; - - if (this.iHighlighted > 0) - this.clearHighlight(); - - this.iHighlighted = Number(n); - - list.childNodes[this.iHighlighted-1].className = "as_highlight"; - - - this.killTimeout(); -} - - -_bsn.AutoSuggest.prototype.clearHighlight = function() -{ - var list = _bsn.DOM.getElement("as_ul"); - if (!list) - return false; - - if (this.iHighlighted > 0) - { - list.childNodes[this.iHighlighted-1].className = ""; - this.iHighlighted = 0; - } -} - - -_bsn.AutoSuggest.prototype.setHighlightedValue = function () -{ - if (this.iHighlighted) - { - this.sInput = this.fld.value = this.aSuggestions[ this.iHighlighted-1 ].value; - - // move cursor to end of input (safari) - // - this.fld.focus(); - if (this.fld.selectionStart) - this.fld.setSelectionRange(this.sInput.length, this.sInput.length); - - - this.clearSuggestions(); - - // pass selected object to callback function, if exists - // - if (typeof(this.oP.callback) == "function") - this.oP.callback( this.aSuggestions[this.iHighlighted-1] ); - } -} - - - - - - - - - - - - - -_bsn.AutoSuggest.prototype.killTimeout = function() -{ - clearTimeout(this.toID); -} - -_bsn.AutoSuggest.prototype.resetTimeout = function() -{ - clearTimeout(this.toID); - var pointer = this; - this.toID = setTimeout(function () { pointer.clearSuggestions() }, 1000); -} - - - - - - - -_bsn.AutoSuggest.prototype.clearSuggestions = function () -{ - - this.killTimeout(); - - var ele = _bsn.DOM.getElement(this.idAs); - var pointer = this; - if (ele) - { - var fade = new _bsn.Fader(ele,1,0,250,function () { _bsn.DOM.removeElement(pointer.idAs) }); - } -} - - - - - - - - - - -// AJAX PROTOTYPE _____________________________________________ - - -if (typeof(_bsn.Ajax) == "undefined") - _bsn.Ajax = {} - - - -_bsn.Ajax = function () -{ - this.req = {}; - this.isIE = false; -} - - - -_bsn.Ajax.prototype.makeRequest = function (url, meth, onComp, onErr) -{ - - if (meth != "POST") - meth = "GET"; - - this.onComplete = onComp; - this.onError = onErr; - - var pointer = this; - - // branch for native XMLHttpRequest object - if (window.XMLHttpRequest) - { - this.req = new XMLHttpRequest(); - this.req.onreadystatechange = function () { pointer.processReqChange() }; - this.req.open("GET", url, true); // - this.req.send(null); - // branch for IE/Windows ActiveX version - } - else if (window.ActiveXObject) - { - this.req = new ActiveXObject("Microsoft.XMLHTTP"); - if (this.req) - { - this.req.onreadystatechange = function () { pointer.processReqChange() }; - this.req.open(meth, url, true); - this.req.send(); - } - } -} - - -_bsn.Ajax.prototype.processReqChange = function() -{ - - // only if req shows "loaded" - if (this.req.readyState == 4) { - // only if "OK" - if (this.req.status == 200) - { - this.onComplete( this.req ); - } else { - this.onError( this.req.status ); - } - } -} - - - - - - - - - - -// DOM PROTOTYPE _____________________________________________ - - -if (typeof(_bsn.DOM) == "undefined") - _bsn.DOM = {} - - - - -_bsn.DOM.createElement = function ( type, attr, cont, html ) -{ - var ne = document.createElement( type ); - if (!ne) - return false; - - for (var a in attr) - ne[a] = attr[a]; - - if (typeof(cont) == "string" && !html) - ne.appendChild( document.createTextNode(cont) ); - else if (typeof(cont) == "string" && html) - ne.innerHTML = cont; - else if (typeof(cont) == "object") - ne.appendChild( cont ); - - return ne; -} - - - - - -_bsn.DOM.clearElement = function ( id ) -{ - var ele = this.getElement( id ); - - if (!ele) - return false; - - while (ele.childNodes.length) - ele.removeChild( ele.childNodes[0] ); - - return true; -} - - - - - - - - - -_bsn.DOM.removeElement = function ( ele ) -{ - var e = this.getElement(ele); - - if (!e) - return false; - else if (e.parentNode.removeChild(e)) - return true; - else - return false; -} - - - - - -_bsn.DOM.replaceContent = function ( id, cont, html ) -{ - var ele = this.getElement( id ); - - if (!ele) - return false; - - this.clearElement( ele ); - - if (typeof(cont) == "string" && !html) - ele.appendChild( document.createTextNode(cont) ); - else if (typeof(cont) == "string" && html) - ele.innerHTML = cont; - else if (typeof(cont) == "object") - ele.appendChild( cont ); -} - - - - - - - - - -_bsn.DOM.getElement = function ( ele ) -{ - if (typeof(ele) == "undefined") - { - return false; - } - else if (typeof(ele) == "string") - { - var re = document.getElementById( ele ); - if (!re) - return false; - else if (typeof(re.appendChild) != "undefined" ) { - return re; - } else { - return false; - } - } - else if (typeof(ele.appendChild) != "undefined") - return ele; - else - return false; -} - - - - - - - -_bsn.DOM.appendChildren = function ( id, arr ) -{ - var ele = this.getElement( id ); - - if (!ele) - return false; - - - if (typeof(arr) != "object") - return false; - - for (var i=0;i Date: Tue, 23 Jun 2015 16:36:25 -0400 Subject: [PATCH 36/97] Remove unused setting. --- lms/envs/common.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 1aa40c9cf5..003c6d9430 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -535,11 +535,6 @@ AUTHENTICATION_BACKENDS = ( STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB MAX_FILEUPLOADS_PER_INPUT = 20 -# FIXME: -# We should have separate S3 staged URLs in case we need to make changes to -# these assets and test them. -LIB_URL = '/static/js/' - # Dev machines shouldn't need the book # BOOK_URL = '/static/book/' BOOK_URL = 'https://mitxstatic.s3.amazonaws.com/book_images/' # For AWS deploys From 52a3306e2a1d18d28de5ce556d6083076f1c1140 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 17 Jun 2015 18:45:19 -0400 Subject: [PATCH 37/97] Add abuse flagging to discussion API Flagging/unflagging is done by issuing a PATCH request on a thread or comment endpoint with the "abuse_flagged" field set. --- lms/djangoapps/discussion_api/api.py | 5 + lms/djangoapps/discussion_api/forms.py | 2 + lms/djangoapps/discussion_api/permissions.py | 2 +- .../discussion_api/tests/test_api.py | 110 ++++++++++++++++-- .../discussion_api/tests/test_permissions.py | 4 +- .../discussion_api/tests/test_serializers.py | 4 +- .../discussion_api/tests/test_views.py | 12 +- lms/djangoapps/discussion_api/tests/utils.py | 22 ++++ 8 files changed, 142 insertions(+), 19 deletions(-) diff --git a/lms/djangoapps/discussion_api/api.py b/lms/djangoapps/discussion_api/api.py index c307140552..4f4520ac79 100644 --- a/lms/djangoapps/discussion_api/api.py +++ b/lms/djangoapps/discussion_api/api.py @@ -368,6 +368,11 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con context["cc_requester"].follow(cc_content) else: context["cc_requester"].unfollow(cc_content) + elif field == "abuse_flagged": + if form_value: + cc_content.flagAbuse(context["cc_requester"], cc_content) + else: + cc_content.unFlagAbuse(context["cc_requester"], cc_content, removeAll=False) else: assert field == "voted" if form_value: diff --git a/lms/djangoapps/discussion_api/forms.py b/lms/djangoapps/discussion_api/forms.py index 67a32b0f42..c63f750b5d 100644 --- a/lms/djangoapps/discussion_api/forms.py +++ b/lms/djangoapps/discussion_api/forms.py @@ -80,6 +80,7 @@ class ThreadActionsForm(Form): """ following = BooleanField(required=False) voted = BooleanField(required=False) + abuse_flagged = BooleanField(required=False) class CommentListGetForm(_PaginationForm): @@ -98,3 +99,4 @@ class CommentActionsForm(Form): interactions with the comments service. """ voted = BooleanField(required=False) + abuse_flagged = BooleanField(required=False) diff --git a/lms/djangoapps/discussion_api/permissions.py b/lms/djangoapps/discussion_api/permissions.py index b76b58cbbe..596e3792f4 100644 --- a/lms/djangoapps/discussion_api/permissions.py +++ b/lms/djangoapps/discussion_api/permissions.py @@ -23,7 +23,7 @@ def get_editable_fields(cc_content, context): Return the set of fields that the requester can edit on the given content """ # Shared fields - ret = {"voted"} + ret = {"abuse_flagged", "voted"} if _is_author_or_privileged(cc_content, context): ret |= {"raw_body"} diff --git a/lms/djangoapps/discussion_api/tests/test_api.py b/lms/djangoapps/discussion_api/tests/test_api.py index e46a95e527..0d71e64432 100644 --- a/lms/djangoapps/discussion_api/tests/test_api.py +++ b/lms/djangoapps/discussion_api/tests/test_api.py @@ -611,7 +611,7 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], }, { "id": "test_thread_id_1", @@ -642,7 +642,7 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "non_endorsed_comment_list_url": ( "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" ), - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], }, ] self.assertEqual( @@ -970,7 +970,7 @@ class GetCommentListTest(CommentsServiceMockMixin, ModuleStoreTestCase): "voted": False, "vote_count": 4, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], }, { "id": "test_comment_2", @@ -990,7 +990,7 @@ class GetCommentListTest(CommentsServiceMockMixin, ModuleStoreTestCase): "voted": False, "vote_count": 7, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], }, ] actual_comments = self.get_comment_list( @@ -1210,7 +1210,7 @@ class CreateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } self.assertEqual(actual, expected) self.assertEqual( @@ -1278,6 +1278,18 @@ class CreateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC {"user_id": [str(self.user.id)], "value": ["up"]} ) + def test_abuse_flagged(self): + self.register_post_thread_response({"id": "test_id"}) + self.register_thread_flag_response("test_id") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_thread(self.request, data) + self.assertEqual(result["abuse_flagged"], True) + cs_request = httpretty.last_request() + self.assertEqual(urlparse(cs_request.path).path, "/api/v1/threads/test_id/abuse_flag") + self.assertEqual(cs_request.method, "PUT") + self.assertEqual(cs_request.parsed_body, {"user_id": [str(self.user.id)]}) + def test_course_id_missing(self): with self.assertRaises(ValidationError) as assertion: create_thread(self.request, {}) @@ -1376,7 +1388,7 @@ class CreateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"] + "editable_fields": ["abuse_flagged", "raw_body", "voted"] } self.assertEqual(actual, expected) expected_url = ( @@ -1431,6 +1443,18 @@ class CreateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest {"user_id": [str(self.user.id)], "value": ["up"]} ) + def test_abuse_flagged(self): + self.register_post_comment_response({"id": "test_comment"}, "test_thread") + self.register_comment_flag_response("test_comment") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_comment(self.request, data) + self.assertEqual(result["abuse_flagged"], True) + cs_request = httpretty.last_request() + self.assertEqual(urlparse(cs_request.path).path, "/api/v1/comments/test_comment/abuse_flag") + self.assertEqual(cs_request.method, "PUT") + self.assertEqual(cs_request.parsed_body, {"user_id": [str(self.user.id)]}) + def test_thread_id_missing(self): with self.assertRaises(ValidationError) as assertion: create_comment(self.request, {}) @@ -1590,7 +1614,7 @@ class UpdateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } self.assertEqual(actual, expected) self.assertEqual( @@ -1770,6 +1794,41 @@ class UpdateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC expected_request_data["value"] = ["up"] self.assertEqual(actual_request_data, expected_request_data) + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + def test_abuse_flagged(self, old_flagged, new_flagged): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the thread should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_thread_flag_response("test_thread") + self.register_thread({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) + data = {"abuse_flagged": new_flagged} + result = update_thread(self.request, "test_thread", data) + self.assertEqual(result["abuse_flagged"], new_flagged) + last_request_path = urlparse(httpretty.last_request().path).path + flag_url = "/api/v1/threads/test_thread/abuse_flag" + unflag_url = "/api/v1/threads/test_thread/abuse_unflag" + if old_flagged == new_flagged: + self.assertNotEqual(last_request_path, flag_url) + self.assertNotEqual(last_request_path, unflag_url) + else: + self.assertEqual( + last_request_path, + flag_url if new_flagged else unflag_url + ) + self.assertEqual(httpretty.last_request().method, "PUT") + self.assertEqual( + httpretty.last_request().parsed_body, + {"user_id": [str(self.user.id)]} + ) + def test_invalid_field(self): self.register_thread() with self.assertRaises(ValidationError) as assertion: @@ -1852,7 +1911,7 @@ class UpdateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"] + "editable_fields": ["abuse_flagged", "raw_body", "voted"] } self.assertEqual(actual, expected) self.assertEqual( @@ -2038,6 +2097,41 @@ class UpdateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest expected_request_data["value"] = ["up"] self.assertEqual(actual_request_data, expected_request_data) + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + def test_abuse_flagged(self, old_flagged, new_flagged): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the comment should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_comment_flag_response("test_comment") + self.register_comment({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) + data = {"abuse_flagged": new_flagged} + result = update_comment(self.request, "test_comment", data) + self.assertEqual(result["abuse_flagged"], new_flagged) + last_request_path = urlparse(httpretty.last_request().path).path + flag_url = "/api/v1/comments/test_comment/abuse_flag" + unflag_url = "/api/v1/comments/test_comment/abuse_unflag" + if old_flagged == new_flagged: + self.assertNotEqual(last_request_path, flag_url) + self.assertNotEqual(last_request_path, unflag_url) + else: + self.assertEqual( + last_request_path, + flag_url if new_flagged else unflag_url + ) + self.assertEqual(httpretty.last_request().method, "PUT") + self.assertEqual( + httpretty.last_request().parsed_body, + {"user_id": [str(self.user.id)]} + ) + @ddt.ddt class DeleteThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/discussion_api/tests/test_permissions.py b/lms/djangoapps/discussion_api/tests/test_permissions.py index 66e8e84a8d..0de1c13d54 100644 --- a/lms/djangoapps/discussion_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion_api/tests/test_permissions.py @@ -30,7 +30,7 @@ class GetEditableFieldsTest(TestCase): thread = Thread(user_id="5" if is_author else "6", type="thread") context = _get_context(requester_id="5", is_requester_privileged=is_privileged) actual = get_editable_fields(thread, context) - expected = {"following", "voted"} + expected = {"abuse_flagged", "following", "voted"} if is_author or is_privileged: expected |= {"topic_id", "type", "title", "raw_body"} self.assertEqual(actual, expected) @@ -45,7 +45,7 @@ class GetEditableFieldsTest(TestCase): thread=Thread(user_id="5" if is_thread_author else "6", thread_type=thread_type) ) actual = get_editable_fields(comment, context) - expected = {"voted"} + expected = {"abuse_flagged", "voted"} if is_author or is_privileged: expected |= {"raw_body"} if (is_thread_author and thread_type == "question") or is_privileged: diff --git a/lms/djangoapps/discussion_api/tests/test_serializers.py b/lms/djangoapps/discussion_api/tests/test_serializers.py index db4f180cb3..7992715a30 100644 --- a/lms/djangoapps/discussion_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion_api/tests/test_serializers.py @@ -197,7 +197,7 @@ class ThreadSerializerSerializationTest(SerializerTestMixin, ModuleStoreTestCase "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], } self.assertEqual(self.serialize(thread), expected) @@ -305,7 +305,7 @@ class CommentSerializerTest(SerializerTestMixin, ModuleStoreTestCase): "voted": False, "vote_count": 4, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], } self.assertEqual(self.serialize(comment), expected) diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index 302badbc14..4cc0c3664b 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -204,7 +204,7 @@ class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], }] self.register_get_threads_response(source_threads, page=1, num_pages=2) response = self.client.get(self.url, {"course_id": unicode(self.course.id)}) @@ -318,7 +318,7 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } response = self.client.post( self.url, @@ -409,7 +409,7 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } response = self.client.patch( # pylint: disable=no-member self.url, @@ -553,7 +553,7 @@ class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "voted": True, "vote_count": 4, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], }] self.register_get_thread_response({ "id": self.thread_id, @@ -707,7 +707,7 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"], + "editable_fields": ["abuse_flagged", "raw_body", "voted"], } response = self.client.post( self.url, @@ -791,7 +791,7 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"], + "editable_fields": ["abuse_flagged", "raw_body", "voted"], } response = self.client.patch( # pylint: disable=no-member self.url, diff --git a/lms/djangoapps/discussion_api/tests/utils.py b/lms/djangoapps/discussion_api/tests/utils.py index 5dc0e3f5f5..54aa847068 100644 --- a/lms/djangoapps/discussion_api/tests/utils.py +++ b/lms/djangoapps/discussion_api/tests/utils.py @@ -230,6 +230,28 @@ class CommentsServiceMockMixin(object): status=200 ) + def register_flag_response(self, content_type, content_id): + """Register a mock response for PUT on the CS flag endpoints""" + for path in ["abuse_flag", "abuse_unflag"]: + httpretty.register_uri( + "PUT", + "http://localhost:4567/api/v1/{content_type}s/{content_id}/{path}".format( + content_type=content_type, + content_id=content_id, + path=path + ), + body=json.dumps({}), # body is unused + status=200 + ) + + def register_thread_flag_response(self, thread_id): + """Register a mock response for PUT on the CS thread flag endpoints""" + self.register_flag_response("thread", thread_id) + + def register_comment_flag_response(self, comment_id): + """Register a mock response for PUT on the CS comment flag endpoints""" + self.register_flag_response("comment", comment_id) + def register_delete_thread_response(self, thread_id): """ Register a mock response for DELETE on the CS thread instance endpoint From 987889fc37d2b4716df9ed965db96957866da75f Mon Sep 17 00:00:00 2001 From: aamir-khan Date: Fri, 5 Jun 2015 19:13:38 +0500 Subject: [PATCH 38/97] ECOM-1524: Display credit availability on the dashboard --- common/djangoapps/student/views.py | 58 ++++++- lms/envs/common.py | 10 ++ lms/static/sass/multicourse/_dashboard.scss | 4 + lms/templates/dashboard.html | 3 +- .../dashboard/_dashboard_course_listing.html | 8 +- .../_dashboard_credit_information.html | 70 ++++++++ openedx/core/djangoapps/credit/api.py | 140 +++++++++++++++- ...o__del_field_crediteligibility_provider.py | 138 ++++++++++++++++ openedx/core/djangoapps/credit/models.py | 42 ++++- .../core/djangoapps/credit/tests/test_api.py | 152 ++++++++++++++++-- .../djangoapps/credit/tests/test_views.py | 1 - 11 files changed, 598 insertions(+), 28 deletions(-) create mode 100644 lms/templates/dashboard/_dashboard_credit_information.html create mode 100644 openedx/core/djangoapps/credit/migrations/0009_auto__del_field_crediteligibility_provider.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 5cc906ad23..58b6425eaf 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -7,8 +7,10 @@ import uuid import time import json import warnings +from datetime import timedelta from collections import defaultdict from pytz import UTC +from requests import HTTPError from ipware.ip import get_ip from django.conf import settings @@ -25,21 +27,19 @@ from django.db import IntegrityError, transaction from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError, Http404) from django.shortcuts import redirect +from django.utils import timezone from django.utils.translation import ungettext from django.utils.http import cookie_date, base36_to_int from django.utils.translation import ugettext as _, get_language from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.http import require_POST, require_GET - from django.db.models.signals import post_save from django.dispatch import receiver - from django.template.response import TemplateResponse from ratelimitbackend.exceptions import RateLimitException -from requests import HTTPError from social.apps.django_app import utils as social_utils from social.backends import oauth as social_oauth @@ -123,13 +123,12 @@ from notification_prefs.views import enable_notifications # Note that this lives in openedx, so this dependency should be refactored. from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.core.djangoapps.credit.api import get_credit_eligibility, get_purchased_credit_courses log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") - ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name - SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' @@ -523,6 +522,13 @@ def dashboard(request): course_enrollment_pairs, course_modes_by_course ) + # Retrieve the course modes for each course + enrolled_courses_dict = {} + for course, __ in course_enrollment_pairs: + enrolled_courses_dict[unicode(course.id)] = course + + credit_messages = _create_credit_availability_message(enrolled_courses_dict, user) + course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) message = "" @@ -628,6 +634,7 @@ def dashboard(request): context = { 'enrollment_message': enrollment_message, + 'credit_messages': credit_messages, 'course_enrollment_pairs': course_enrollment_pairs, 'course_optouts': course_optouts, 'message': message, @@ -692,6 +699,47 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes): ) +def _create_credit_availability_message(enrolled_courses_dict, user): # pylint: disable=invalid-name + """Builds a dict of credit availability for courses. + + Construct a for courses user has completed and has not purchased credit + from the credit provider yet. + + Args: + course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information. + user (User): User object. + + Returns: + A dict of courses user is eligible for credit. + + """ + user_eligibilities = get_credit_eligibility(user.username) + user_purchased_credit = get_purchased_credit_courses(user.username) + + eligibility_messages = {} + for course_id, eligibility in user_eligibilities.iteritems(): + if course_id not in user_purchased_credit: + duration = eligibility["seconds_good_for_display"] + curr_time = timezone.now() + validity_till = eligibility["created_at"] + timedelta(seconds=duration) + if validity_till > curr_time: + diff = validity_till - curr_time + urgent = diff.days <= 30 + eligibility_messages[course_id] = { + "user_id": user.id, + "course_id": course_id, + "course_name": enrolled_courses_dict[course_id].display_name, + "providers": eligibility["providers"], + "status": eligibility["status"], + "provider": eligibility.get("provider"), + "urgent": urgent, + "user_full_name": user.get_full_name(), + "expiry": validity_till + } + + return eligibility_messages + + def _get_recently_enrolled_courses(course_enrollment_pairs): """Checks to see if the student has recently enrolled in courses. diff --git a/lms/envs/common.py b/lms/envs/common.py index 1aa40c9cf5..33ffef2d45 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1339,6 +1339,12 @@ certificates_web_view_js = [ 'js/src/logger.js', ] +credit_web_view_js = [ + 'js/vendor/jquery.min.js', + 'js/vendor/jquery.cookie.js', + 'js/src/logger.js', +] + PIPELINE_CSS = { 'style-vendor': { 'source_filenames': [ @@ -1578,6 +1584,10 @@ PIPELINE_JS = { 'utility': { 'source_filenames': ['js/src/utility.js'], 'output_filename': 'js/utility.js' + }, + 'credit_wv': { + 'source_filenames': credit_web_view_js, + 'output_filename': 'js/credit/web_view.js' } } diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 5edbdb4864..5d4e3ca26c 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -531,6 +531,10 @@ padding: 0; } + .purchase_credit { + float: right; + } + .message { @extend %ui-depth1; border-radius: 3px; diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 3dc379fab6..821a1291f5 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -83,7 +83,8 @@ from django.core.urlresolvers import reverse <% is_course_blocked = (course.id in block_courses) %> <% course_verification_status = verification_status_by_course.get(course.id, {}) %> <% course_requirements = courses_requirements_not_met.get(course.id) %> - <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings" /> + <% credit_message = credit_messages.get(unicode(course.id)) %> + <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, credit_message=credit_message, user=user" /> % endfor % if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index bf44791adc..aa431bbf0b 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings" /> +<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, credit_message" /> <%! import urllib @@ -273,7 +273,11 @@ from student.helpers import (
    % if course.may_certify() and cert_status: <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/> - % endif + % endif + + % if credit_message: + <%include file='_dashboard_credit_information.html' args='credit_message=credit_message'/> + % endif % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked:
    diff --git a/lms/templates/dashboard/_dashboard_credit_information.html b/lms/templates/dashboard/_dashboard_credit_information.html new file mode 100644 index 0000000000..efdb39337b --- /dev/null +++ b/lms/templates/dashboard/_dashboard_credit_information.html @@ -0,0 +1,70 @@ +<%page args="credit_message" /> + +<%! +from django.utils.translation import ugettext as _ +from course_modes.models import CourseMode +from util.date_utils import get_default_time_display +%> +<%namespace name='static' file='../static_content.html'/> + +<%block name="js_extra" args="credit_message"> + <%static:js group='credit_wv'/> + + + +
    +

    + % if credit_message["status"] == "requirements_meet": + + % if credit_message["urgent"]: + ${_("{username}, your eligibility for credit expires on {expiry}. Don't miss out!").format( + username=credit_message["user_full_name"], + expiry=get_default_time_display(credit_message["expiry"]) + ) + } + % else: + ${_("{congrats} {username}, You have meet requirements for credit.").format( + congrats="Congratulations", + username=credit_message["user_full_name"] + ) + } + % endif + + ${_("Purchase Credit")} + + % elif credit_message["status"] == "pending": + ${_("Thank you, your payment is complete, your credit is processing. Please see {provider_link} for more information.").format( + provider_link='{}'.format(credit_message["provider"]["display_name"]) + ) + } + % elif credit_message["status"] == "approved": + ${_("Thank you, your credit is approved. Please see {provider_link} for more information.").format( + provider_link='{}'.format(credit_message["provider"]["display_name"]) + ) + } + % elif credit_message["status"] == "rejected": + ${_("Your credit has been denied. Please contact {provider_link} for more information.").format( + provider_link='{}'.format(credit_message["provider"]["display_name"]) + ) + } + % endif + +

    + +
    diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index d678c1d91a..bb567d7ea1 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -26,6 +26,7 @@ from .exceptions import ( ) from .models import ( CreditCourse, + CreditProvider, CreditRequirement, CreditRequirementStatus, CreditRequest, @@ -33,6 +34,7 @@ from .models import ( ) from .signature import signature, get_shared_secret_key + log = logging.getLogger(__name__) @@ -211,14 +213,13 @@ def create_credit_request(course_key, provider_id, username): """ try: - user_eligibility = CreditEligibility.objects.select_related('course', 'provider').get( + user_eligibility = CreditEligibility.objects.select_related('course').get( username=username, - course__course_key=course_key, - provider__provider_id=provider_id + course__course_key=course_key ) credit_course = user_eligibility.course - credit_provider = user_eligibility.provider - except CreditEligibility.DoesNotExist: + credit_provider = credit_course.providers.get(provider_id=provider_id) + except (CreditEligibility.DoesNotExist, CreditProvider.DoesNotExist): log.warning(u'User tried to initiate a request for credit, but the user is not eligible for credit') raise UserIsNotEligible @@ -614,3 +615,132 @@ def is_credit_course(course_key): return False return CreditCourse.is_credit_course(course_key=course_key) + + +def get_credit_request_status(username, course_key): + """Get the credit request status. + + This function returns the status of credit request of user for given course. + It returns the latest request status for the any credit provider. + The valid status are 'pending', 'approved' or 'rejected'. + + Args: + username(str): The username of user + course_key(CourseKey): The course locator key + + Returns: + A dictionary of credit request user has made if any + + """ + credit_request = CreditRequest.get_user_request_status(username, course_key) + if credit_request: + credit_status = { + "uuid": credit_request.uuid, + "timestamp": credit_request.modified, + "course_key": credit_request.course.course_key, + "provider": { + "id": credit_request.provider.provider_id, + "display_name": credit_request.provider.display_name + }, + "status": credit_request.status + } + else: + credit_status = {} + return credit_status + + +def _get_duration_and_providers(credit_course): + """Returns the credit providers and eligibility durations. + + The eligibility_duration is the max of the credit duration of + all the credit providers of given course. + + Args: + credit_course(CreditCourse): The CreditCourse object + + Returns: + Tuple of eligibility_duration and credit providers of given course + + """ + providers = credit_course.providers.all() + seconds_good_for_display = 0 + providers_list = [] + for provider in providers: + providers_list.append( + { + "id": provider.provider_id, + "display_name": provider.display_name, + "eligibility_duration": provider.eligibility_duration, + "provider_url": provider.provider_url + } + ) + eligibility_duration = int(provider.eligibility_duration) if provider.eligibility_duration else 0 + seconds_good_for_display = max(eligibility_duration, seconds_good_for_display) + + return seconds_good_for_display, providers_list + + +def get_credit_eligibility(username): + """ + Returns the all the eligibility the user has meet. + + Args: + username(str): The username of user + + Example: + >> get_credit_eligibility('Aamir'): + { + "edX/DemoX/Demo_Course": { + "created_at": "2015-12-21", + "providers": [ + "id": 12, + "display_name": "Arizona State University", + "eligibility_duration": 60, + "provider_url": "http://arizona/provideere/link" + ], + "seconds_good_for_display": 90 + } + } + + Returns: + A dict of eligibilities + """ + eligibilities = CreditEligibility.get_user_eligibility(username) + user_credit_requests = get_credit_requests_for_user(username) + request_dict = {} + # Change the list to dict for iteration + for request in user_credit_requests: + request_dict[unicode(request["course_key"])] = request + user_eligibilities = {} + for eligibility in eligibilities: + course_key = eligibility.course.course_key + duration, providers_list = _get_duration_and_providers(eligibility.course) + user_eligibilities[unicode(course_key)] = { + "created_at": eligibility.created, + "seconds_good_for_display": duration, + "providers": providers_list, + } + + # Default status is requirements_meet + user_eligibilities[unicode(course_key)]["status"] = "requirements_meet" + # If there is some request user has made for this eligibility then update the status + if unicode(course_key) in request_dict: + user_eligibilities[unicode(course_key)]["status"] = request_dict[unicode(course_key)]["status"] + user_eligibilities[unicode(course_key)]["provider"] = request_dict[unicode(course_key)]["provider"] + + return user_eligibilities + + +def get_purchased_credit_courses(username): # pylint: disable=unused-argument + """ + Returns the purchased credit courses. + + Args: + username(str): Username of the student + + Returns: + A dict of courses user has purchased from the credit provider after completion + + """ + # TODO: How to track the purchased courses. It requires Will's work for credit provider integration + return {} diff --git a/openedx/core/djangoapps/credit/migrations/0009_auto__del_field_crediteligibility_provider.py b/openedx/core/djangoapps/credit/migrations/0009_auto__del_field_crediteligibility_provider.py new file mode 100644 index 0000000000..73cd9be397 --- /dev/null +++ b/openedx/core/djangoapps/credit/migrations/0009_auto__del_field_crediteligibility_provider.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Deleting field 'CreditEligibility.provider' + db.delete_column('credit_crediteligibility', 'provider_id') + + + def backwards(self, orm): + # Adding field 'CreditEligibility.provider' + db.add_column('credit_crediteligibility', 'provider', + self.gf('django.db.models.fields.related.ForeignKey')(default=1, related_name='eligibilities', to=orm['credit.CreditProvider']), + keep_default=False) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'credit.creditcourse': { + 'Meta': {'object_name': 'CreditCourse'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'providers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['credit.CreditProvider']", 'symmetrical': 'False'}) + }, + 'credit.crediteligibility': { + 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.creditprovider': { + 'Meta': {'object_name': 'CreditProvider'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'eligibility_duration': ('django.db.models.fields.PositiveIntegerField', [], {'default': '31556970'}), + 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}) + }, + 'credit.creditrequest': { + 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}) + }, + 'credit.creditrequirement': { + 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'criteria': ('jsonfield.fields.JSONField', [], {}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'credit.creditrequirementstatus': { + 'Meta': {'object_name': 'CreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequest': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}) + } + } + + complete_apps = ['credit'] \ No newline at end of file diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index 9187be4b01..d107ceee77 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -307,11 +307,23 @@ class CreditEligibility(TimeStampedModel): """ username = models.CharField(max_length=255, db_index=True) course = models.ForeignKey(CreditCourse, related_name="eligibilities") - provider = models.ForeignKey(CreditProvider, related_name="eligibilities") class Meta(object): # pylint: disable=missing-docstring unique_together = ('username', 'course') + @classmethod + def get_user_eligibility(cls, username): + """Returns the eligibilities of given user. + + Args: + username(str): Username of the user + + Returns: + CreditEligibility queryset for the user + + """ + return cls.objects.filter(username=username).select_related('course').prefetch_related('course__providers') + @classmethod def is_user_eligible_for_credit(cls, course_key, username): """Check if the given user is eligible for the provided credit course @@ -361,6 +373,12 @@ class CreditRequest(TimeStampedModel): history = HistoricalRecords() + class Meta(object): # pylint: disable=missing-docstring + # Enforce the constraint that each user can have exactly one outstanding + # request to a given provider. Multiple requests use the same UUID. + unique_together = ('username', 'course', 'provider') + get_latest_by = 'created' + @classmethod def credit_requests_for_user(cls, username): """ @@ -402,7 +420,21 @@ class CreditRequest(TimeStampedModel): for request in cls.objects.select_related('course', 'provider').filter(username=username) ] - class Meta(object): # pylint: disable=missing-docstring - # Enforce the constraint that each user can have exactly one outstanding - # request to a given provider. Multiple requests use the same UUID. - unique_together = ('username', 'course', 'provider') + @classmethod + def get_user_request_status(cls, username, course_key): + """Returns the latest credit request of user against the given course. + + Args: + username(str): The username of requesting user + course_key(CourseKey): The course identifier + + Returns: + CreditRequest if any otherwise None + + """ + try: + return cls.objects.filter( + username=username, course__course_key=course_key + ).select_related('course', 'provider').latest() + except cls.DoesNotExist: + return None diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 91a902a3f2..dd2f3cbd83 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -1,17 +1,18 @@ """ Tests for the API functions in the credit app. """ - +import unittest import datetime import ddt import pytz from django.test import TestCase from django.test.utils import override_settings from django.db import connection, transaction +from django.core.urlresolvers import reverse +from django.conf import settings from opaque_keys.edx.keys import CourseKey -from student.tests.factories import UserFactory from util.date_utils import from_timestamp from openedx.core.djangoapps.credit import api from openedx.core.djangoapps.credit.exceptions import ( @@ -34,13 +35,20 @@ from openedx.core.djangoapps.credit.api import ( set_credit_requirement_status, get_credit_requirement ) +from student.models import CourseEnrollment +from student.views import _create_credit_availability_message +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6" @override_settings(CREDIT_PROVIDER_SECRET_KEYS={ - "hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY + "hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY, + "ASU": TEST_CREDIT_PROVIDER_SECRET_KEY, + "MIT": TEST_CREDIT_PROVIDER_SECRET_KEY }) class CreditApiTestBase(TestCase): """ @@ -212,7 +220,7 @@ class CreditRequirementApiTests(CreditApiTestBase): def test_is_user_eligible_for_credit(self): credit_course = self.add_credit_course() CreditEligibility.objects.create( - course=credit_course, username="staff", provider=CreditProvider.objects.get(provider_id=self.PROVIDER_ID) + course=credit_course, username="staff" ) is_eligible = api.is_user_eligible_for_credit('staff', credit_course.course_key) self.assertTrue(is_eligible) @@ -380,19 +388,26 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): # Initial status should be "pending" self._assert_credit_status("pending") + credit_request_status = api.get_credit_request_status(self.USER_INFO['username'], self.course_key) + self.assertEqual(credit_request_status["status"], "pending") + # Update the status api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, status) self._assert_credit_status(status) + credit_request_status = api.get_credit_request_status(self.USER_INFO['username'], self.course_key) + self.assertEqual(credit_request_status["status"], status) + def test_query_counts(self): # Yes, this is a lot of queries, but this API call is also doing a lot of work :) - # - 1 query: Check the user's eligibility and retrieve the credit course and provider. + # - 1 query: Check the user's eligibility and retrieve the credit course + # - 1 Get the provider of the credit course. # - 2 queries: Get-or-create the credit request. # - 1 query: Retrieve user account and profile information from the user API. # - 1 query: Look up the user's final grade from the credit requirements table. # - 2 queries: Update the request. # - 2 queries: Update the history table for the request. - with self.assertNumQueries(9): + with self.assertNumQueries(10): request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) # - 3 queries: Retrieve and update the request @@ -522,12 +537,131 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): status.save() CreditEligibility.objects.create( - username=self.USER_INFO["username"], - course=CreditCourse.objects.get(course_key=self.course_key), - provider=CreditProvider.objects.get(provider_id=self.PROVIDER_ID) + username=self.USER_INFO['username'], + course=CreditCourse.objects.get(course_key=self.course_key) ) def _assert_credit_status(self, expected_status): """Check the user's credit status. """ statuses = api.get_credit_requests_for_user(self.USER_INFO["username"]) self.assertEqual(statuses[0]["status"], expected_status) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class CreditMessagesTests(ModuleStoreTestCase, CreditApiTestBase): + """ + Test dashboard messages of credit course. + """ + + FINAL_GRADE = 0.8 + + def setUp(self): + super(CreditMessagesTests, self).setUp() + self.student = UserFactory() + self.student.set_password('test') # pylint: disable=no-member + self.student.save() # pylint: disable=no-member + + self.client.login(username=self.student.username, password='test') + # New Course + self.course = CourseFactory.create() + self.enrollment = CourseEnrollment.enroll(self.student, self.course.id) + + def _set_creditcourse(self): + """ + Mark the course to credit + + """ + # pylint: disable=attribute-defined-outside-init + self.first_provider = CreditProvider.objects.create( + provider_id="ASU", + display_name="Arizona State University", + provider_url="google.com", + enable_integration=True + ) # pylint: disable=attribute-defined-outside-init + self.second_provider = CreditProvider.objects.create( + provider_id="MIT", + display_name="Massachusetts Institute of Technology", + provider_url="MIT.com", + enable_integration=True + ) # pylint: disable=attribute-defined-outside-init + + self.credit_course = CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=attribute-defined-outside-init + self.credit_course.providers.add(self.first_provider) + self.credit_course.providers.add(self.second_provider) + + def _set_user_eligible(self, credit_course, username): + """ + Mark the user eligible for credit for the given credit course. + """ + self.eligibility = CreditEligibility.objects.create(username=username, course=credit_course) # pylint: disable=attribute-defined-outside-init + + def test_user_request_status(self): + request_status = api.get_credit_request_status(self.student.username, self.course.id) + self.assertEqual(len(request_status), 0) + + def test_credit_messages(self): + self._set_creditcourse() + + requirement = CreditRequirement.objects.create( + course=self.credit_course, + namespace="grade", + name="grade", + active=True + ) + status = CreditRequirementStatus.objects.create( + username=self.student.username, + requirement=requirement, + ) + status.status = "satisfied" + status.reason = {"final_grade": self.FINAL_GRADE} + status.save() + + self._set_user_eligible(self.credit_course, self.student.username) + response = self.client.get(reverse("dashboard")) + self.assertContains( + response, + "Congratulations {}, You have meet requirements for credit.".format( + self.student.get_full_name() # pylint: disable=no-member + ) + ) + + api.create_credit_request(self.course.id, self.first_provider.provider_id, self.student.username) + + response = self.client.get(reverse("dashboard")) + self.assertContains( + response, + 'Thank you, your payment is complete, your credit is processing. ' + 'Please see {provider_link} for more information.'.format( + provider_link='{provider_name}'.format( + provider_name=self.first_provider.display_name + ) + ) + ) + + def test_query_counts(self): + # This check the number of queries executed while rendering the + # credit message to display on the dashboard. + # - 1 query: Check the user's eligibility. + # - 1 query: Get the user credit requests. + + self._set_creditcourse() + + requirement = CreditRequirement.objects.create( + course=self.credit_course, + namespace="grade", + name="grade", + active=True + ) + status = CreditRequirementStatus.objects.create( + username=self.student.username, + requirement=requirement, + ) + status.status = "satisfied" + status.reason = {"final_grade": self.FINAL_GRADE} + status.save() + + with self.assertNumQueries(2): + enrollment_dict = {unicode(self.course.id): self.course} + _create_credit_availability_message( + enrollment_dict, self.student + ) diff --git a/openedx/core/djangoapps/credit/tests/test_views.py b/openedx/core/djangoapps/credit/tests/test_views.py index ec7a8ba67c..d454009ace 100644 --- a/openedx/core/djangoapps/credit/tests/test_views.py +++ b/openedx/core/djangoapps/credit/tests/test_views.py @@ -99,7 +99,6 @@ class CreditProviderViewTests(UrlResetMixin, TestCase): CreditEligibility.objects.create( username=self.USERNAME, course=credit_course, - provider=credit_provider, ) def test_credit_request_and_response(self): From 0ae22a3b08c14e86f5160ed6bf274c54e5e326e8 Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Tue, 23 Jun 2015 13:28:12 +0500 Subject: [PATCH 39/97] Credit Eligibility display on the CMS accessibility issues There were some accessibility issues on the cms side which are addressed ECOM-1594 --- cms/templates/settings.html | 9 ++++++--- cms/templates/settings_graders.html | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 31876cb599..d24b534cd7 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -130,8 +130,9 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
      % if 'grade' in credit_requirements:
    1. - + % for requirement in credit_requirements['grade']: + % endfor @@ -140,8 +141,9 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % if 'proctored_exam' in credit_requirements:
    2. - + % for requirement in credit_requirements['proctored_exam']: + % endfor @@ -150,9 +152,10 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % if 'reverification' in credit_requirements:
    3. - + % for requirement in credit_requirements['reverification']: ## Translators: 'Access to Assessment 1' means the access for a requirement with name 'Assessment 1' + % endfor diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index 701ad854c9..dd2281c791 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -85,8 +85,8 @@
      1. - - ${_("Must be greater than or equal to passing grade")} + + ${_("Must be greater than or equal to passing grade")}
From df3d9112e1c7ecbc58fe4ae55b197da80e229ab1 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 24 Jun 2015 11:06:16 -0400 Subject: [PATCH 40/97] Move forgot_password_modal to login.html. That is the only place using it. --- lms/templates/login.html | 3 ++- lms/templates/navigation-edx.html | 4 ---- lms/templates/navigation.html | 4 ---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/lms/templates/login.html b/lms/templates/login.html index ed666adf51..3280ca7696 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -130,6 +130,8 @@ from microsite_configuration import microsite +<%include file="forgot_password_modal.html" /> +

@@ -239,4 +241,3 @@ from microsite_configuration import microsite

- diff --git a/lms/templates/navigation-edx.html b/lms/templates/navigation-edx.html index e99a40d531..d678e36055 100644 --- a/lms/templates/navigation-edx.html +++ b/lms/templates/navigation-edx.html @@ -150,8 +150,4 @@ site_status_msg = get_site_status_msg(course_id) % endif -%if not user.is_authenticated(): - <%include file="forgot_password_modal.html" /> -%endif - <%include file="help_modal.html"/> diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index f925bfee85..2ce84db750 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -156,8 +156,4 @@ site_status_msg = get_site_status_msg(course_id) % endif -%if not user.is_authenticated(): - <%include file="forgot_password_modal.html" /> -%endif - <%include file="help_modal.html"/> From 7f9bec933cf067abd712a17ad775c3a8bad7950e Mon Sep 17 00:00:00 2001 From: Christine Lytwynec Date: Wed, 24 Jun 2015 11:33:57 -0400 Subject: [PATCH 41/97] split common/lib and js unit tests into separate shards --- scripts/all-tests.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/all-tests.sh b/scripts/all-tests.sh index fdc50aa319..3c3a8b6b28 100755 --- a/scripts/all-tests.sh +++ b/scripts/all-tests.sh @@ -17,6 +17,8 @@ set -e # - "quality": Run the quality (pep8/pylint) checks # - "lms-unit": Run the LMS Python unit tests # - "cms-unit": Run the CMS Python unit tests +# - "js-unit": Run the JavaScript tests +# - "commonlib-unit": Run Python unit tests from the common/lib directory # - "commonlib-js-unit": Run the JavaScript tests and the Python unit # tests from the common/lib directory # - "lms-acceptance": Run the acceptance (Selenium/Lettuce) tests for @@ -113,6 +115,14 @@ END paver test_system -s cms --extra_args="--with-flaky" --cov_args="-p" ;; + "commonlib-unit") + paver test_lib --extra_args="--with-flaky" --cov_args="-p" + ;; + + "js-unit") + paver test_js --coverage + ;; + "commonlib-js-unit") paver test_js --coverage --skip_clean || { EXIT=1; } paver test_lib --skip_clean --extra_args="--with-flaky" --cov_args="-p" || { EXIT=1; } From e0ed2d8a3a81146d19c395b9106f44c761886ee5 Mon Sep 17 00:00:00 2001 From: Bertrand Marron Date: Sat, 20 Jun 2015 00:43:32 +0200 Subject: [PATCH 42/97] Fix misleading enrollment API documentation The user parameter corresponds to the username, not the user ID. --- common/djangoapps/enrollment/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 0444d8385a..bb9d4faf2c 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -266,7 +266,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): **Post Parameters** - * user: The user ID of the currently logged in user. Optional. You cannot use the command to enroll a different user. + * user: The username of the currently logged in user. Optional. You cannot use the command to enroll a different user. * mode: The Course Mode for the enrollment. Individual users cannot upgrade their enrollment mode from 'honor'. Only server-to-server requests can enroll with other modes. Optional. @@ -316,7 +316,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): * invite_only: Whether students must be invited to enroll in the course; true or false. - * user: The ID of the user. + * user: The username of the user. """ authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth From f19b3381702010dee99c7e441c8731a783cb9ca9 Mon Sep 17 00:00:00 2001 From: Bertrand Marron Date: Wed, 24 Jun 2015 17:54:18 +0200 Subject: [PATCH 43/97] Shorten long lines to resolve pylint issues --- common/djangoapps/enrollment/views.py | 50 ++++++++++++++++++--------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index bb9d4faf2c..a8943d7c3b 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -90,15 +90,19 @@ class EnrollmentView(APIView, ApiKeyPermissionMixIn): * course_id: The unique identifier for the course. - * enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created. + * enrollment_start: The date and time that users can begin enrolling in the course. + If null, enrollment opens immediately when the course is created. - * enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends. + * enrollment_end: The date and time after which users cannot enroll for the course. + If null, the enrollment period never ends. - * course_start: The date and time at which the course opens. If null, the course opens immediately when created. + * course_start: The date and time at which the course opens. + If null, the course opens immediately when created. * course_end: The date and time at which the course closes. If null, the course never ends. - * course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes: + * course_modes: An array of data about the enrollment modes supported for the course. + Each enrollment mode collection includes: * slug: The short name for the enrollment mode. * name: The full name of the enrollment mode. @@ -181,20 +185,24 @@ class EnrollmentCourseDetailView(APIView): **Response Values** - A collection of course enrollments for the user, or for the newly created enrollment. Each course enrollment contains: + A collection of course enrollments for the user, or for the newly created enrollment. + Each course enrollment contains: * course_id: The unique identifier of the course. - * enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created. + * enrollment_start: The date and time that users can begin enrolling in the course. + If null, enrollment opens immediately when the course is created. - * enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends. + * enrollment_end: The date and time after which users cannot enroll for the course. + If null, the enrollment period never ends. - * course_start: The date and time at which the course opens. If null, the course opens immediately when created. + * course_start: The date and time at which the course opens. + If null, the course opens immediately when created. * course_end: The date and time at which the course closes. If null, the course never ends. * course_modes: An array containing details about the enrollment modes supported for the course. - If the request uses the parameter include_expired=1, the array also includes expired enrollment modes. + If the request uses the parameter include_expired=1, the array also includes expired enrollment modes. Each enrollment mode collection includes: @@ -203,7 +211,8 @@ class EnrollmentCourseDetailView(APIView): * min_price: The minimum price for which a user can enroll in this mode. * suggested_prices: A list of suggested prices for this enrollment mode. * currency: The currency of the listed prices. - * expiration_datetime: The date and time after which users cannot enroll in the course in this mode. + * expiration_datetime: The date and time after which users cannot enroll in the course + in this mode. * description: A description of this mode. * invite_only: Whether students must be invited to enroll in the course; true or false. @@ -266,7 +275,8 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): **Post Parameters** - * user: The username of the currently logged in user. Optional. You cannot use the command to enroll a different user. + * user: The username of the currently logged in user. Optional. + You cannot use the command to enroll a different user. * mode: The Course Mode for the enrollment. Individual users cannot upgrade their enrollment mode from 'honor'. Only server-to-server requests can enroll with other modes. Optional. @@ -283,7 +293,8 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): **Response Values** - A collection of course enrollments for the user, or for the newly created enrollment. Each course enrollment contains: + A collection of course enrollments for the user, or for the newly created enrollment. + Each course enrollment contains: * created: The date the user account was created. @@ -295,22 +306,27 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): * course_id: The unique identifier for the course. - * enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created. + * enrollment_start: The date and time that users can begin enrolling in the course. + If null, enrollment opens immediately when the course is created. - * enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends. + * enrollment_end: The date and time after which users cannot enroll for the course. + If null, the enrollment period never ends. - * course_start: The date and time at which the course opens. If null, the course opens immediately when created. + * course_start: The date and time at which the course opens. + If null, the course opens immediately when created. * course_end: The date and time at which the course closes. If null, the course never ends. - * course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes: + * course_modes: An array of data about the enrollment modes supported for the course. + Each enrollment mode collection includes: * slug: The short name for the enrollment mode. * name: The full name of the enrollment mode. * min_price: The minimum price for which a user can enroll in this mode. * suggested_prices: A list of suggested prices for this enrollment mode. * currency: The currency of the listed prices. - * expiration_datetime: The date and time after which users cannot enroll in the course in this mode. + * expiration_datetime: The date and time after which users cannot enroll in the course + in this mode. * description: A description of this mode. From 5e86a64729e435d025d618b4871eb22a7c8cbed7 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 23 Jun 2015 12:55:35 -0700 Subject: [PATCH 44/97] User info cookie * Add a new cookie for user information * Make marketing cookie names configurable. * Handle URL reversal when URLs don't exist (in Studio) * Move cookie code from student/helpers.py into its own module. --- cms/envs/aws.py | 6 + cms/envs/common.py | 5 +- common/djangoapps/student/cookies.py | 130 ++++++++++++++++++ common/djangoapps/student/helpers.py | 43 +----- .../student/tests/test_create_account.py | 3 +- common/djangoapps/student/tests/test_login.py | 37 +++++ common/djangoapps/student/views.py | 17 +-- .../djangoapps/third_party_auth/pipeline.py | 7 +- .../djangoapps/third_party_auth/settings.py | 2 +- .../third_party_auth/tests/specs/base.py | 18 ++- lms/envs/aws.py | 6 + lms/envs/common.py | 5 +- .../djangoapps/user_api/tests/test_views.py | 3 +- openedx/core/djangoapps/user_api/views.py | 6 +- 14 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 common/djangoapps/student/cookies.py diff --git a/cms/envs/aws.py b/cms/envs/aws.py index b14c726e40..85396530fe 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -153,6 +153,12 @@ if ENV_TOKENS.get('SESSION_COOKIE_NAME', None): # NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str() SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME')) +# Set the names of cookies shared with the marketing site +# These have the same cookie domain as the session, which in production +# usually includes subdomains. +EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME', EDXMKTG_LOGGED_IN_COOKIE_NAME) +EDXMKTG_USER_INFO_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_USER_INFO_COOKIE_NAME', EDXMKTG_USER_INFO_COOKIE_NAME) + #Email overrides DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL) diff --git a/cms/envs/common.py b/cms/envs/common.py index 084eb4103c..f7485589cf 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -764,7 +764,10 @@ INSTALLED_APPS = ( ################# EDX MARKETING SITE ################################## -EDXMKTG_COOKIE_NAME = 'edxloggedin' +EDXMKTG_LOGGED_IN_COOKIE_NAME = 'edxloggedin' +EDXMKTG_USER_INFO_COOKIE_NAME = 'edx-user-info' +EDXMKTG_USER_INFO_COOKIE_VERSION = 1 + MKTG_URLS = {} MKTG_URL_LINK_MAP = { diff --git a/common/djangoapps/student/cookies.py b/common/djangoapps/student/cookies.py new file mode 100644 index 0000000000..81a7b9a6c9 --- /dev/null +++ b/common/djangoapps/student/cookies.py @@ -0,0 +1,130 @@ +""" +Utility functions for setting "logged in" cookies used by subdomains. +""" + +import time +import json + +from django.utils.http import cookie_date +from django.conf import settings +from django.core.urlresolvers import reverse, NoReverseMatch + + +def set_logged_in_cookies(request, response, user): + """ + Set cookies indicating that the user is logged in. + + Some installations have an external marketing site configured + that displays a different UI when the user is logged in + (e.g. a link to the student dashboard instead of to the login page) + + Currently, two cookies are set: + + * EDXMKTG_LOGGED_IN_COOKIE_NAME: Set to 'true' if the user is logged in. + * EDXMKTG_USER_INFO_COOKIE_VERSION: JSON-encoded dictionary with user information (see below). + + The user info cookie has the following format: + { + "version": 1, + "username": "test-user", + "email": "test-user@example.com", + "header_urls": { + "account_settings": "https://example.com/account/settings", + "learner_profile": "https://example.com/u/test-user", + "logout": "https://example.com/logout" + } + } + + Arguments: + request (HttpRequest): The request to the view, used to calculate + the cookie's expiration date based on the session expiration date. + response (HttpResponse): The response on which the cookie will be set. + user (User): The currently logged in user. + + Returns: + HttpResponse + + """ + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = cookie_date(expires_time) + + cookie_settings = { + 'max_age': max_age, + 'expires': expires, + 'domain': settings.SESSION_COOKIE_DOMAIN, + 'path': '/', + 'secure': None, + 'httponly': None, + } + + # Backwards compatibility: set the cookie indicating that the user + # is logged in. This is just a boolean value, so it's not very useful. + # In the future, we should be able to replace this with the "user info" + # cookie set below. + response.set_cookie(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, 'true', **cookie_settings) + + # Set a cookie with user info. This can be used by external sites + # to customize content based on user information. Currently, + # we include information that's used to customize the "account" + # links in the header of subdomain sites (such as the marketing site). + header_urls = {'logout': reverse('logout')} + + # Unfortunately, this app is currently used by both the LMS and Studio login pages. + # If we're in Studio, we won't be able to reverse the account/profile URLs. + # To handle this, we don't add the URLs if we can't reverse them. + # External sites will need to have fallback mechanisms to handle this case + # (most likely just hiding the links). + try: + header_urls['account_settings'] = reverse('account_settings') + header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username}) + except NoReverseMatch: + pass + + # Convert relative URL paths to absolute URIs + for url_name, url_path in header_urls.iteritems(): + header_urls[url_name] = request.build_absolute_uri(url_path) + + user_info = { + 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, + 'username': user.username, + 'email': user.email, + 'header_urls': header_urls, + } + + response.set_cookie( + settings.EDXMKTG_USER_INFO_COOKIE_NAME, + json.dumps(user_info), + **cookie_settings + ) + + return response + + +def delete_logged_in_cookies(response): + """ + Delete cookies indicating that the user is logged in. + + Arguments: + response (HttpResponse): The response sent to the client. + + Returns: + HttpResponse + + """ + for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]: + response.delete_cookie(cookie_name, path='/', domain=settings.SESSION_COOKIE_DOMAIN) + + return response + + +def is_logged_in_cookie_set(request): + """Check whether the request has logged in cookies set. """ + return ( + settings.EDXMKTG_LOGGED_IN_COOKIE_NAME in request.COOKIES and + settings.EDXMKTG_USER_INFO_COOKIE_NAME in request.COOKIES + ) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 5efa8f7cec..9ddfea424c 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -1,54 +1,19 @@ """Helpers for the student app. """ import time from datetime import datetime +import urllib + from pytz import UTC + from django.utils.http import cookie_date from django.conf import settings from django.core.urlresolvers import reverse, NoReverseMatch + import third_party_auth -import urllib from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=F0401 from course_modes.models import CourseMode -def set_logged_in_cookie(request, response): - """Set a cookie indicating that the user is logged in. - - Some installations have an external marketing site configured - that displays a different UI when the user is logged in - (e.g. a link to the student dashboard instead of to the login page) - - Arguments: - request (HttpRequest): The request to the view, used to calculate - the cookie's expiration date based on the session expiration date. - response (HttpResponse): The response on which the cookie will be set. - - Returns: - HttpResponse - - """ - if request.session.get_expire_at_browser_close(): - max_age = None - expires = None - else: - max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) - - response.set_cookie( - settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age, - expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, - path='/', secure=None, httponly=None, - ) - - return response - - -def is_logged_in_cookie_set(request): - """Check whether the request has the logged in cookie set. """ - return settings.EDXMKTG_COOKIE_NAME in request.COOKIES - - # Enumeration of per-course verification statuses # we display on the student dashboard. VERIFY_STATUS_NEED_TO_VERIFY = "verify_need_to_verify" diff --git a/common/djangoapps/student/tests/test_create_account.py b/common/djangoapps/student/tests/test_create_account.py index f21e19bf47..e5318208ef 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/common/djangoapps/student/tests/test_create_account.py @@ -86,7 +86,8 @@ class TestCreateAccount(TestCase): def test_marketing_cookie(self): response = self.client.post(self.url, self.params) self.assertEqual(response.status_code, 200) - self.assertIn(settings.EDXMKTG_COOKIE_NAME, self.client.cookies) + self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies) + self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies) @unittest.skipUnless( "microsite_configuration.middleware.MicrositeMiddleware" in settings.MIDDLEWARE_CLASSES, diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 2c648be6ed..1f4a8bcb06 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -158,6 +158,43 @@ class LoginTest(TestCase): self.assertEqual(response.status_code, 302) self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test']) + def test_login_user_info_cookie(self): + response, _ = self._login_response('test@edx.org', 'test_password') + self._assert_response(response, success=True) + + # Verify the format of the "user info" cookie set on login + cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME] + user_info = json.loads(cookie.value) + + # Check that the version is set + self.assertEqual(user_info["version"], settings.EDXMKTG_USER_INFO_COOKIE_VERSION) + + # Check that the username and email are set + self.assertEqual(user_info["username"], self.user.username) + self.assertEqual(user_info["email"], self.user.email) + + # Check that the URLs are absolute + for url in user_info["header_urls"].values(): + self.assertIn("http://testserver/", url) + + def test_logout_deletes_mktg_cookies(self): + response, _ = self._login_response('test@edx.org', 'test_password') + self._assert_response(response, success=True) + + # Check that the marketing site cookies have been set + self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies) + self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies) + + # Log out + logout_url = reverse('logout') + response = self.client.post(logout_url) + + # Check that the marketing site cookies have been deleted + # (cookies are deleted by setting an expiration date in 1970) + for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]: + cookie = self.client.cookies[cookie_name] + self.assertIn("01-Jan-1970", cookie.get('expires')) + @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_logout_logging_no_pii(self): response, _ = self._login_response('test@edx.org', 'test_password') diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 5cc906ad23..ed1e486a89 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -106,9 +106,10 @@ from util.password_policy_validators import ( import third_party_auth from third_party_auth import pipeline, provider from student.helpers import ( - set_logged_in_cookie, check_verify_status_by_course, + check_verify_status_by_course, auth_pipeline_urls, get_next_url_for_login_page ) +from student.cookies import set_logged_in_cookies, delete_logged_in_cookies from student.models import anonymous_id_for_user from xmodule.error_module import ErrorDescriptor from shoppingcart.models import DonationConfiguration, CourseRegistrationCode @@ -1064,7 +1065,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un # Ensure that the external marketing site can # detect that the user is logged in. - return set_logged_in_cookie(request, response) + return set_logged_in_cookies(request, response, user) if settings.FEATURES['SQUELCH_PII_IN_LOGS']: AUDIT_LOG.warning(u"Login failed - Account not active for user.id: {0}, resending activation".format(user.id)) @@ -1128,10 +1129,8 @@ def logout_user(request): else: target = '/' response = redirect(target) - response.delete_cookie( - settings.EDXMKTG_COOKIE_NAME, - path='/', domain=settings.SESSION_COOKIE_DOMAIN, - ) + + delete_logged_in_cookies(response) return response @@ -1549,6 +1548,8 @@ def create_account_with_params(request, params): new_user.save() AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email)) + return new_user + @csrf_exempt def create_account(request, post_override=None): @@ -1559,7 +1560,7 @@ def create_account(request, post_override=None): warnings.warn("Please use RegistrationView instead.", DeprecationWarning) try: - create_account_with_params(request, post_override or request.POST) + user = create_account_with_params(request, post_override or request.POST) except AccountValidationError as exc: return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400) except ValidationError as exc: @@ -1584,7 +1585,7 @@ def create_account(request, post_override=None): 'success': True, 'redirect_url': redirect_url, }) - set_logged_in_cookie(request, response) + set_logged_in_cookies(request, response, user) return response diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 4d4ba9ba99..7c0ad27c08 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -61,7 +61,6 @@ import random import string # pylint: disable-msg=deprecated-module from collections import OrderedDict import urllib -from ipware.ip import get_ip import analytics from eventtracking import tracker @@ -534,7 +533,7 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia @partial.partial -def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs): +def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs): """This pipeline step sets the "logged in" cookie for authenticated users. Some installations have a marketing site front-end separate from @@ -566,7 +565,7 @@ def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None # Check that the cookie isn't already set. # This ensures that we allow the user to continue to the next # pipeline step once he/she has the cookie set by this step. - has_cookie = student.helpers.is_logged_in_cookie_set(request) + has_cookie = student.cookies.is_logged_in_cookie_set(request) if not has_cookie: try: redirect_url = get_complete_url(backend.name) @@ -577,7 +576,7 @@ def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None pass else: response = redirect(redirect_url) - return student.helpers.set_logged_in_cookie(request, response) + return student.cookies.set_logged_in_cookies(request, response, user) @partial.partial diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index 12b362759c..e9468b7ce8 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -111,7 +111,7 @@ def _set_global_settings(django_settings): 'social.pipeline.social_auth.associate_user', 'social.pipeline.social_auth.load_extra_data', 'social.pipeline.user.user_details', - 'third_party_auth.pipeline.set_logged_in_cookie', + 'third_party_auth.pipeline.set_logged_in_cookies', 'third_party_auth.pipeline.login_analytics', ) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 0db38295e1..25e060c099 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -372,11 +372,15 @@ class IntegrationTest(testutil.TestCase, test.TestCase): response["Location"], pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name) ) - self.assertEqual(response.cookies[django_settings.EDXMKTG_COOKIE_NAME].value, 'true') + self.assertEqual(response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value, 'true') + self.assertIn(django_settings.EDXMKTG_USER_INFO_COOKIE_NAME, response.cookies) - def set_logged_in_cookie(self, request): + def set_logged_in_cookies(self, request): """Simulate setting the marketing site cookie on the request. """ - request.COOKIES[django_settings.EDXMKTG_COOKIE_NAME] = 'true' + request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = 'true' + request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps({ + 'version': django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION, + }) # Actual tests, executed once per child. @@ -434,7 +438,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): )) # Set the cookie and try again - self.set_logged_in_cookie(request) + self.set_logged_in_cookies(request) # Fire off the auth pipeline to link. self.assert_redirect_to_dashboard_looks_correct(actions.do_complete( @@ -456,7 +460,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assert_social_auth_exists_for_user(user, strategy) # We're already logged in, so simulate that the cookie is set correctly - self.set_logged_in_cookie(request) + self.set_logged_in_cookies(request) # Instrument the pipeline to get to the dashboard with the full # expected state. @@ -582,7 +586,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): )) # Set the cookie and try again - self.set_logged_in_cookie(request) + self.set_logged_in_cookies(request) self.assert_redirect_to_dashboard_looks_correct( actions.do_complete(request.backend, social_views._do_login, user=user)) @@ -683,7 +687,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): )) # Set the cookie and try again - self.set_logged_in_cookie(request) + self.set_logged_in_cookies(request) self.assert_redirect_to_dashboard_looks_correct( actions.do_complete(strategy.request.backend, social_views._do_login, user=created_user)) # Now the user has been redirected to the dashboard. Their third party account should now be linked. diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 1f113305a6..58fab6cd8c 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -155,6 +155,12 @@ SESSION_COOKIE_HTTPONLY = ENV_TOKENS.get('SESSION_COOKIE_HTTPONLY', True) REGISTRATION_EXTRA_FIELDS = ENV_TOKENS.get('REGISTRATION_EXTRA_FIELDS', REGISTRATION_EXTRA_FIELDS) SESSION_COOKIE_SECURE = ENV_TOKENS.get('SESSION_COOKIE_SECURE', SESSION_COOKIE_SECURE) +# Set the names of cookies shared with the marketing site +# These have the same cookie domain as the session, which in production +# usually includes subdomains. +EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME', EDXMKTG_LOGGED_IN_COOKIE_NAME) +EDXMKTG_USER_INFO_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_USER_INFO_COOKIE_NAME', EDXMKTG_USER_INFO_COOKIE_NAME) + CMS_BASE = ENV_TOKENS.get('CMS_BASE', 'studio.edx.org') # allow for environments to specify what cookie name our login subsystem should use diff --git a/lms/envs/common.py b/lms/envs/common.py index 1aa40c9cf5..bfefcd10d5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1916,7 +1916,10 @@ CSRF_COOKIE_AGE = 60 * 60 * 24 * 7 * 52 ######################### MARKETING SITE ############################### -EDXMKTG_COOKIE_NAME = 'edxloggedin' +EDXMKTG_LOGGED_IN_COOKIE_NAME = 'edxloggedin' +EDXMKTG_USER_INFO_COOKIE_NAME = 'edx-user-info' +EDXMKTG_USER_INFO_COOKIE_VERSION = 1 + MKTG_URLS = {} MKTG_URL_LINK_MAP = { 'ABOUT': 'about', diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 34f3b780a6..6034a9cc2a 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1264,7 +1264,8 @@ class RegistrationViewTest(ApiTestCase): "honor_code": "true", }) self.assertHttpOK(response) - self.assertIn(settings.EDXMKTG_COOKIE_NAME, self.client.cookies) + self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies) + self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies) user = User.objects.get(username=self.USERNAME) account_settings = get_account_settings(user) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index e357cd97cb..79a119378e 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -25,7 +25,7 @@ import third_party_auth from django_comment_common.models import Role from edxmako.shortcuts import marketing_link from student.views import create_account_with_params -from student.helpers import set_logged_in_cookie +from student.cookies import set_logged_in_cookies from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser from util.json_request import JsonResponse from .preferences.api import update_email_opt_in @@ -295,7 +295,7 @@ class RegistrationView(APIView): data["terms_of_service"] = data["honor_code"] try: - create_account_with_params(request, data) + user = create_account_with_params(request, data) except ValidationError as err: # Should only get non-field errors from this function assert NON_FIELD_ERRORS not in err.message_dict @@ -307,7 +307,7 @@ class RegistrationView(APIView): return JsonResponse(errors, status=400) response = JsonResponse({"success": True}) - set_logged_in_cookie(request, response) + set_logged_in_cookies(request, response, user) return response def _add_email_field(self, form_desc, required=True): From a5fc4e6d6c9e4f0de694ad20f8bf354ea5ed940a Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 24 Jun 2015 10:26:36 -0400 Subject: [PATCH 45/97] Fix flaky test. TNL-2494 --- common/test/acceptance/pages/lms/teams.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/test/acceptance/pages/lms/teams.py b/common/test/acceptance/pages/lms/teams.py index f031f7ee1b..79fe5419e7 100644 --- a/common/test/acceptance/pages/lms/teams.py +++ b/common/test/acceptance/pages/lms/teams.py @@ -18,4 +18,9 @@ class TeamsPage(CoursePage): def get_body_text(self): """ Returns the current dummy text. This will be changed once there is more content on the page. """ - return self.q(css='.page-content-main').text[0] + main_page_content_css = '.page-content-main' + self.wait_for( + lambda: len(self.q(css=main_page_content_css).text) == 1, + description="Body text is present" + ) + return self.q(css=main_page_content_css).text[0] From e6c7a571d174da7d7410ea59f378a3f1c9c3ae2c Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Wed, 24 Jun 2015 17:44:14 -0400 Subject: [PATCH 46/97] Update URL when switching between Team tabs. --- lms/static/js/components/tabbed/views/tabbed_view.js | 4 +++- lms/static/js/spec/components/tabbed/tabbed_view_spec.js | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lms/static/js/components/tabbed/views/tabbed_view.js b/lms/static/js/components/tabbed/views/tabbed_view.js index a3c4dfd8c5..77bc1b4210 100644 --- a/lms/static/js/components/tabbed/views/tabbed_view.js +++ b/lms/static/js/components/tabbed/views/tabbed_view.js @@ -41,12 +41,14 @@ }, setActiveTab: function (index) { + var tab = this.tabs[index], + view = tab.view; this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false'); this.$('a[data-index='+index+']').addClass('is-active').attr('aria-selected', 'true'); - var view = this.tabs[index].view; view.render(); this.$('.page-content-main').html(view.$el.html()); this.$('.sr-is-focusable').focus(); + this.router.navigate(tab.url, {replace: true}); }, switchTab: function (event) { diff --git a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js b/lms/static/js/spec/components/tabbed/tabbed_view_spec.js index 69a18e045e..b9b5fb31fb 100644 --- a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js +++ b/lms/static/js/spec/components/tabbed/tabbed_view_spec.js @@ -72,6 +72,11 @@ expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'false'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true'); }); + + it('updates the page URL on tab switches without adding to browser history', function () { + view.$('.nav-item[data-index=1]').click(); + expect(Backbone.history.navigate).toHaveBeenCalledWith('test 2', {replace: true}); + }); }); } ); From 75d194e2798ca1f3cc8a28f451fd54c101e37312 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 23 Jun 2015 08:15:19 -0700 Subject: [PATCH 47/97] Update credit eligibility * Update credit eligibility when a user satisfies an eligibility requirement. * Refactor set_credit_requirement_status arguments. * Use django-simple-history to track requirement status changes. * Refactor progress page by moving credit into a helper function and adding comments. --- lms/djangoapps/courseware/views.py | 82 ++++++--- lms/djangoapps/verify_student/views.py | 28 +-- lms/templates/courseware/progress.html | 2 +- openedx/core/djangoapps/credit/api.py | 90 +++++++--- ...010_add_creditrequirementstatus_history.py | 169 ++++++++++++++++++ openedx/core/djangoapps/credit/models.py | 67 ++++++- .../core/djangoapps/credit/tests/test_api.py | 144 ++++++++++++--- 7 files changed, 489 insertions(+), 93 deletions(-) create mode 100644 openedx/core/djangoapps/credit/migrations/0010_add_creditrequirementstatus_history.py diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 2c66444ab9..13968d0e98 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -1066,27 +1066,6 @@ def _progress(request, course_key, student_id): # checking certificate generation configuration show_generate_cert_btn = certs_api.cert_generation_enabled(course_key) - credit_course_requirements = None - is_course_credit = settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key) - if is_course_credit: - requirement_statuses = get_credit_requirement_status(course_key, student.username) - if any(requirement['status'] == 'failed' for requirement in requirement_statuses): - eligibility_status = "not_eligible" - elif is_user_eligible_for_credit(student.username, course_key): - eligibility_status = "eligible" - else: - eligibility_status = "partial_eligible" - - paired_requirements = {} - for requirement in requirement_statuses: - namespace = requirement.pop("namespace") - paired_requirements.setdefault(namespace, []).append(requirement) - - credit_course_requirements = { - 'eligibility_status': eligibility_status, - 'requirements': OrderedDict(sorted(paired_requirements.items(), reverse=True)) - } - context = { 'course': course, 'courseware_summary': courseware_summary, @@ -1096,8 +1075,7 @@ def _progress(request, course_key, student_id): 'student': student, 'passed': is_course_passed(course, grade_summary), 'show_generate_cert_btn': show_generate_cert_btn, - 'credit_course_requirements': credit_course_requirements, - 'is_credit_course': is_course_credit, + 'credit_course_requirements': _credit_course_requirements(course_key, student), } if show_generate_cert_btn: @@ -1124,6 +1102,64 @@ def _progress(request, course_key, student_id): return response +def _credit_course_requirements(course_key, student): + """Return information about which credit requirements a user has satisfied. + + Arguments: + course_key (CourseKey): Identifier for the course. + student (User): Currently logged in user. + + Returns: dict + + """ + # If credit eligibility is not enabled or this is not a credit course, + # short-circuit and return `None`. This indicates that credit requirements + # should NOT be displayed on the progress page. + if not (settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key)): + return None + + # Retrieve the status of the user for each eligibility requirement in the course. + # For each requirement, the user's status is either "satisfied", "failed", or None. + # In this context, `None` means that we don't know the user's status, either because + # the user hasn't done something (for example, submitting photos for verification) + # or we're waiting on more information (for example, a response from the photo + # verification service). + requirement_statuses = get_credit_requirement_status(course_key, student.username) + + # If the user has been marked as "eligible", then they are *always* eligible + # unless someone manually intervenes. This could lead to some strange behavior + # if the requirements change post-launch. For example, if the user was marked as eligible + # for credit, then a new requirement was added, the user will see that they're eligible + # AND that one of the requirements is still pending. + # We're assuming here that (a) we can mitigate this by properly training course teams, + # and (b) it's a better user experience to allow students who were at one time + # marked as eligible to continue to be eligible. + # If we need to, we can always manually move students back to ineligible by + # deleting CreditEligibility records in the database. + if is_user_eligible_for_credit(student.username, course_key): + eligibility_status = "eligible" + + # If the user has *failed* any requirements (for example, if a photo verification is denied), + # then the user is NOT eligible for credit. + elif any(requirement['status'] == 'failed' for requirement in requirement_statuses): + eligibility_status = "not_eligible" + + # Otherwise, the user may be eligible for credit, but the user has not + # yet completed all the requirements. + else: + eligibility_status = "partial_eligible" + + paired_requirements = {} + for requirement in requirement_statuses: + namespace = requirement.pop("namespace") + paired_requirements.setdefault(namespace, []).append(requirement) + + return { + 'eligibility_status': eligibility_status, + 'requirements': OrderedDict(sorted(paired_requirements.items(), reverse=True)) + } + + @login_required @ensure_valid_course_key def submission_history(request, course_id, student_username, location): diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 38b5d5160f..173c7152d2 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -38,7 +38,7 @@ from microsite_configuration import microsite from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError -from openedx.core.djangoapps.credit.api import get_credit_requirement, set_credit_requirement_status +from openedx.core.djangoapps.credit.api import set_credit_requirement_status from student.models import CourseEnrollment from shoppingcart.models import Order, CertificateItem from shoppingcart.processors import ( @@ -897,19 +897,19 @@ def _set_user_requirement_status(attempt, namespace, status, reason=None): log.error("Unable to find checkpoint for user with id %d", attempt.user.id) if checkpoint is not None: - course_key = checkpoint.course_id - credit_requirement = get_credit_requirement( - course_key, namespace, checkpoint.checkpoint_location - ) - if credit_requirement is not None: - try: - set_credit_requirement_status( - attempt.user.username, credit_requirement, status, reason - ) - except Exception: # pylint: disable=broad-except - # Catch exception if unable to add credit requirement - # status for user - log.error("Unable to add Credit requirement status for user with id %d", attempt.user.id) + try: + set_credit_requirement_status( + attempt.user.username, + checkpoint.course_id, + namespace, + checkpoint.checkpoint_location, + status=status, + reason=reason, + ) + except Exception: # pylint: disable=broad-except + # Catch exception if unable to add credit requirement + # status for user + log.error("Unable to add Credit requirement status for user with id %d", attempt.user.id) @require_POST diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index d2dbdafc69..5ceac475e1 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -103,7 +103,7 @@ from django.utils.http import urlquote_plus %endif - % if is_credit_course: + % if credit_course_requirements:
diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index bb567d7ea1..bfe556fb30 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -274,12 +274,12 @@ def create_credit_request(course_key, provider_id, username): # Retrieve the final grade from the eligibility table try: - final_grade = CreditRequirementStatus.objects.filter( + final_grade = CreditRequirementStatus.objects.get( username=username, requirement__namespace="grade", requirement__name="grade", status="satisfied" - ).latest().reason["final_grade"] + ).reason["final_grade"] except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError): log.exception( "Could not retrieve final grade from the credit eligibility table " @@ -410,7 +410,7 @@ def get_credit_requests_for_user(username): return CreditRequest.credit_requests_for_user(username) -def get_credit_requirement_status(course_key, username): +def get_credit_requirement_status(course_key, username, namespace=None, name=None): """ Retrieve the user's status for each credit requirement in the course. Args: @@ -447,7 +447,7 @@ def get_credit_requirement_status(course_key, username): Returns: list of requirement statuses """ - requirements = CreditRequirement.get_course_requirements(course_key) + requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name) requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username) requirement_statuses = dict((o.requirement, o) for o in requirement_statuses) statuses = [] @@ -511,36 +511,80 @@ def get_credit_requirement(course_key, namespace, name): } if requirement else None -def set_credit_requirement_status(username, requirement, status="satisfied", reason=None): - """Update Credit Requirement Status for given username and requirement - if exists else add new. +def set_credit_requirement_status(username, course_key, req_namespace, req_name, status="satisfied", reason=None): + """ + Update the user's requirement status. + + This will record whether the user satisfied or failed a particular requirement + in a course. If the user has satisfied all requirements, the user will be marked + as eligible for credit in the course. Args: - username(str): Username of the user - requirement(dict): requirement dict - status(str): Status of the requirement - reason(dict): Reason of the status + username (str): Username of the user + course_key (CourseKey): Identifier for the course associated with the requirement. + req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification") + req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock) + + Keyword Arguments: + status (str): Status of the requirement (either "satisfied" or "failed") + reason (dict): Reason of the status Example: >>> set_credit_requirement_status( - "staff", - { - "course_key": "course-v1-edX-DemoX-1T2015" - "namespace": "reverification", - "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", - }, - "satisfied", - {} + "staff", + CourseKey.from_string("course-v1-edX-DemoX-1T2015"), + "reverification", + "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", + status="satisfied", + reason={} ) """ - credit_requirement = CreditRequirement.get_course_requirement( - requirement['course_key'], requirement['namespace'], requirement['name'] - ) + # Check if we're already eligible for credit. + # If so, short-circuit this process. + if CreditEligibility.is_user_eligible_for_credit(course_key, username): + return + + # Retrieve all credit requirements for the course + # We retrieve all of them to avoid making a second query later when + # we need to check whether all requirements have been satisfied. + reqs = CreditRequirement.get_course_requirements(course_key) + + # Find the requirement we're trying to set + req_to_update = next(( + req for req in reqs + if req.namespace == req_namespace + and req.name == req_name + ), None) + + # If we can't find the requirement, then the most likely explanation + # is that there was a lag updating the credit requirements after the course + # was published. We *could* attempt to create the requirement here, + # but that could cause serious performance issues if many users attempt to + # lock the row at the same time. + # Instead, we skip updating the requirement and log an error. + if req_to_update is None: + log.error( + ( + u'Could not update credit requirement in course "%s" ' + u'with namespace "%s" and name "%s" ' + u'because the requirement does not exist. ' + u'The user "%s" should have had his/her status updated to "%s".' + ), + unicode(course_key), req_namespace, req_name, username, status + ) + return + + # Update the requirement status CreditRequirementStatus.add_or_update_requirement_status( - username, credit_requirement, status, reason + username, req_to_update, status=status, reason=reason ) + # If we're marking this requirement as "satisfied", there's a chance + # that the user has met all eligibility requirements. + if status == "satisfied": + CreditEligibility.update_eligibility(reqs, username, course_key) + def _get_requirements_to_disable(old_requirements, new_requirements): """ diff --git a/openedx/core/djangoapps/credit/migrations/0010_add_creditrequirementstatus_history.py b/openedx/core/djangoapps/credit/migrations/0010_add_creditrequirementstatus_history.py new file mode 100644 index 0000000000..777e86782d --- /dev/null +++ b/openedx/core/djangoapps/credit/migrations/0010_add_creditrequirementstatus_history.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'HistoricalCreditRequirementStatus' + db.create_table('credit_historicalcreditrequirementstatus', ( + ('id', self.gf('django.db.models.fields.IntegerField')(db_index=True, blank=True)), + ('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)), + ('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)), + ('username', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('status', self.gf('django.db.models.fields.CharField')(max_length=32)), + ('reason', self.gf('jsonfield.fields.JSONField')(default={})), + ('requirement', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'+', null=True, on_delete=models.DO_NOTHING, to=orm['credit.CreditRequirement'])), + (u'history_id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + (u'history_date', self.gf('django.db.models.fields.DateTimeField')()), + (u'history_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name=u'+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])), + (u'history_type', self.gf('django.db.models.fields.CharField')(max_length=1)), + )) + db.send_create_signal('credit', ['HistoricalCreditRequirementStatus']) + + # Adding unique constraint on 'CreditRequirementStatus', fields ['username', 'requirement'] + db.create_unique('credit_creditrequirementstatus', ['username', 'requirement_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'CreditRequirementStatus', fields ['username', 'requirement'] + db.delete_unique('credit_creditrequirementstatus', ['username', 'requirement_id']) + + # Deleting model 'HistoricalCreditRequirementStatus' + db.delete_table('credit_historicalcreditrequirementstatus') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'credit.creditcourse': { + 'Meta': {'object_name': 'CreditCourse'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'providers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['credit.CreditProvider']", 'symmetrical': 'False'}) + }, + 'credit.crediteligibility': { + 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.creditprovider': { + 'Meta': {'object_name': 'CreditProvider'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'eligibility_duration': ('django.db.models.fields.PositiveIntegerField', [], {'default': '31556970'}), + 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}) + }, + 'credit.creditrequest': { + 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}) + }, + 'credit.creditrequirement': { + 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'criteria': ('jsonfield.fields.JSONField', [], {}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'credit.creditrequirementstatus': { + 'Meta': {'unique_together': "(('username', 'requirement'),)", 'object_name': 'CreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequest': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequirementstatus': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + } + } + + complete_apps = ['credit'] \ No newline at end of file diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index d107ceee77..4d953f0294 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -6,10 +6,10 @@ Credit courses allow students to receive university credit for successful completion of a course on EdX """ +from collections import defaultdict import logging -from django.db import models -from django.db import transaction +from django.db import models, transaction, IntegrityError from django.core.validators import RegexValidator from simple_history.models import HistoricalRecords @@ -168,8 +168,11 @@ class CreditRequirement(TimeStampedModel): course=credit_course, namespace=requirement["namespace"], name=requirement["name"], - display_name=requirement["display_name"], - defaults={"criteria": requirement["criteria"], "active": True} + defaults={ + "display_name": requirement["display_name"], + "criteria": requirement["criteria"], + "active": True + } ) if not created: credit_requirement.criteria = requirement["criteria"] @@ -179,20 +182,29 @@ class CreditRequirement(TimeStampedModel): return credit_requirement, created @classmethod - def get_course_requirements(cls, course_key, namespace=None): + def get_course_requirements(cls, course_key, namespace=None, name=None): """ Get credit requirements of a given course. Args: - course_key(CourseKey): The identifier for a course - namespace(str): Namespace of credit course requirements + course_key (CourseKey): The identifier for a course + + Keyword Arguments + namespace (str): Optionally filter credit requirements by namespace. + name (str): Optionally filter credit requirements by name. Returns: QuerySet of CreditRequirement model + """ requirements = CreditRequirement.objects.filter(course__course_key=course_key, active=True) - if namespace: + + if namespace is not None: requirements = requirements.filter(namespace=namespace) + + if name is not None: + requirements = requirements.filter(name=name) + return requirements @classmethod @@ -261,8 +273,11 @@ class CreditRequirementStatus(TimeStampedModel): # the grade to users later and to send the information to credit providers. reason = JSONField(default={}) + # Maintain a history of requirement status updates for auditing purposes + history = HistoricalRecords() + class Meta(object): # pylint: disable=missing-docstring - get_latest_by = "created" + unique_together = ('username', 'requirement') @classmethod def get_statuses(cls, requirements, username): @@ -324,6 +339,40 @@ class CreditEligibility(TimeStampedModel): """ return cls.objects.filter(username=username).select_related('course').prefetch_related('course__providers') + @classmethod + def update_eligibility(cls, requirements, username, course_key): + """ + Update the user's credit eligibility for a course. + + A user is eligible for credit when the user has satisfied + all requirements for credit in the course. + + Arguments: + requirements (Queryset): Queryset of `CreditRequirement`s to check. + username (str): Identifier of the user being updated. + course_key (CourseKey): Identifier of the course. + + """ + # Check all requirements for the course to determine if the user + # is eligible. We need to check all the *requirements* + # (not just the *statuses*) in case the user doesn't yet have + # a status for a particular requirement. + status_by_req = defaultdict(lambda: False) + for status in CreditRequirementStatus.get_statuses(requirements, username): + status_by_req[status.requirement.id] = status.status + + is_eligible = all(status_by_req[req.id] == "satisfied" for req in requirements) + + # If we're eligible, then mark the user as being eligible for credit. + if is_eligible: + try: + CreditEligibility.objects.create( + username=username, + course=CreditCourse.objects.get(course_key=course_key), + ) + except IntegrityError: + pass + @classmethod def is_user_eligible_for_credit(cls, course_key, username): """Check if the given user is eligible for the provided credit course diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index dd2f3cbd83..14b5b7acba 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -30,11 +30,6 @@ from openedx.core.djangoapps.credit.models import ( CreditRequirementStatus, CreditEligibility ) -from openedx.core.djangoapps.credit.api import ( - set_credit_requirements, - set_credit_requirement_status, - get_credit_requirement -) from student.models import CourseEnrollment from student.views import _create_credit_availability_message from student.tests.factories import UserFactory @@ -240,7 +235,7 @@ class CreditRequirementApiTests(CreditApiTestBase): } } ] - requirement = get_credit_requirement(self.course_key, "grade", "grade") + requirement = api.get_credit_requirement(self.course_key, "grade", "grade") self.assertIsNone(requirement) expected_requirement = { @@ -252,8 +247,8 @@ class CreditRequirementApiTests(CreditApiTestBase): "min_grade": 0.8 } } - set_credit_requirements(self.course_key, requirements) - requirement = get_credit_requirement(self.course_key, "grade", "grade") + api.set_credit_requirements(self.course_key, requirements) + requirement = api.get_credit_requirement(self.course_key, "grade", "grade") self.assertIsNotNone(requirement) self.assertEqual(requirement, expected_requirement) @@ -276,25 +271,128 @@ class CreditRequirementApiTests(CreditApiTestBase): } ] - set_credit_requirements(self.course_key, requirements) - course_requirements = CreditRequirement.get_course_requirements(self.course_key) + api.set_credit_requirements(self.course_key, requirements) + course_requirements = api.get_credit_requirements(self.course_key) self.assertEqual(len(course_requirements), 2) - requirement = get_credit_requirement(self.course_key, "grade", "grade") - set_credit_requirement_status("staff", requirement, 'satisfied', {}) - course_requirement = CreditRequirement.get_course_requirement( - requirement['course_key'], requirement['namespace'], requirement['name'] - ) - status = CreditRequirementStatus.objects.get(username="staff", requirement=course_requirement) - self.assertEqual(status.requirement.namespace, requirement['namespace']) - self.assertEqual(status.status, "satisfied") + # Initially, the status should be None + req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") + self.assertEqual(req_status[0]["status"], None) - set_credit_requirement_status( - "staff", requirement, 'failed', {'failure_reason': "requirements not satisfied"} + # Set the requirement to "satisfied" and check that it's actually set + api.set_credit_requirement_status("staff", self.course_key, "grade", "grade") + req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") + self.assertEqual(req_status[0]["status"], "satisfied") + + # Set the requirement to "failed" and check that it's actually set + api.set_credit_requirement_status("staff", self.course_key, "grade", "grade", status="failed") + req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") + self.assertEqual(req_status[0]["status"], "failed") + + def test_satisfy_all_requirements(self): + # Configure a course with two credit requirements + self.add_credit_course() + requirements = [ + { + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": { + "min_grade": 0.8 + } + }, + { + "namespace": "reverification", + "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", + "display_name": "Assessment 1", + "criteria": {} + } + ] + api.set_credit_requirements(self.course_key, requirements) + + # Satisfy one of the requirements, but not the other + with self.assertNumQueries(7): + api.set_credit_requirement_status( + "bob", + self.course_key, + requirements[0]["namespace"], + requirements[0]["name"] + ) + + # The user should not be eligible (because only one requirement is satisfied) + self.assertFalse(api.is_user_eligible_for_credit("bob", self.course_key)) + + # Satisfy the other requirement + with self.assertNumQueries(10): + api.set_credit_requirement_status( + "bob", + self.course_key, + requirements[1]["namespace"], + requirements[1]["name"] + ) + + # Now the user should be eligible + self.assertTrue(api.is_user_eligible_for_credit("bob", self.course_key)) + + # The user should remain eligible even if the requirement status is later changed + api.set_credit_requirement_status( + "bob", + self.course_key, + requirements[0]["namespace"], + requirements[0]["name"], + status="failed" ) - status = CreditRequirementStatus.objects.get(username="staff", requirement=course_requirement) - self.assertEqual(status.requirement.namespace, requirement['namespace']) - self.assertEqual(status.status, "failed") + self.assertTrue(api.is_user_eligible_for_credit("bob", self.course_key)) + + def test_set_credit_requirement_status_req_not_configured(self): + # Configure a credit course with no requirements + self.add_credit_course() + + # A user satisfies a requirement. This could potentially + # happen if there's a lag when the requirements are updated + # after the course is published. + api.set_credit_requirement_status("bob", self.course_key, "grade", "grade") + + # Since the requirement hasn't been published yet, it won't show + # up in the list of requirements. + req_status = api.get_credit_requirement_status(self.course_key, "bob", namespace="grade", name="grade") + self.assertEqual(req_status, []) + + # Now add the requirements, simulating what happens when a course is published. + requirements = [ + { + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": { + "min_grade": 0.8 + } + }, + { + "namespace": "reverification", + "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", + "display_name": "Assessment 1", + "criteria": {} + } + ] + api.set_credit_requirements(self.course_key, requirements) + + # The user should not have satisfied the requirements, since they weren't + # in effect when the user completed the requirement + req_status = api.get_credit_requirement_status(self.course_key, "bob") + self.assertEqual(len(req_status), 2) + self.assertEqual(req_status[0]["status"], None) + self.assertEqual(req_status[0]["status"], None) + + # The user should *not* have satisfied the reverification requirement + req_status = api.get_credit_requirement_status( + self.course_key, + "bob", + namespace=requirements[1]["namespace"], + name=requirements[1]["name"] + ) + self.assertEqual(len(req_status), 1) + self.assertEqual(req_status[0]["status"], None) @ddt.ddt From 34863c76c1c466ede91bbe0462a2609c0cc977b0 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 24 Jun 2015 14:07:20 -0700 Subject: [PATCH 48/97] Make the user info cookie secure --- common/djangoapps/student/cookies.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/cookies.py b/common/djangoapps/student/cookies.py index 81a7b9a6c9..c595157706 100644 --- a/common/djangoapps/student/cookies.py +++ b/common/djangoapps/student/cookies.py @@ -58,7 +58,6 @@ def set_logged_in_cookies(request, response, user): 'expires': expires, 'domain': settings.SESSION_COOKIE_DOMAIN, 'path': '/', - 'secure': None, 'httponly': None, } @@ -66,7 +65,7 @@ def set_logged_in_cookies(request, response, user): # is logged in. This is just a boolean value, so it's not very useful. # In the future, we should be able to replace this with the "user info" # cookie set below. - response.set_cookie(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, 'true', **cookie_settings) + response.set_cookie(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, 'true', secure=None, **cookie_settings) # Set a cookie with user info. This can be used by external sites # to customize content based on user information. Currently, @@ -96,9 +95,21 @@ def set_logged_in_cookies(request, response, user): 'header_urls': header_urls, } + # In production, TLS should be enabled so that this cookie is encrypted + # when we send it. We also need to set "secure" to True so that the browser + # will transmit it only over secure connections. + # + # In non-production environments (acceptance tests, devstack, and sandboxes), + # we still want to set this cookie. However, we do NOT want to set it to "secure" + # because the browser won't send it back to us. This can cause an infinite redirect + # loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine + # whether it needs to set the cookie or continue to the next pipeline stage. + user_info_cookie_is_secure = request.is_secure() + response.set_cookie( settings.EDXMKTG_USER_INFO_COOKIE_NAME, json.dumps(user_info), + secure=user_info_cookie_is_secure, **cookie_settings ) From dac018f4598f1f21ba1f37a05b07f8ccc5334e2e Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Tue, 23 Jun 2015 15:30:19 -0400 Subject: [PATCH 49/97] Adding dependency for edx-user-state-client --- .../courseware/user_state_client.py | 2 +- lms/djangoapps/xblock_user_state/__init__.py | 0 lms/djangoapps/xblock_user_state/interface.py | 255 ------------------ requirements/edx/github.txt | 1 + 4 files changed, 2 insertions(+), 256 deletions(-) delete mode 100644 lms/djangoapps/xblock_user_state/__init__.py delete mode 100644 lms/djangoapps/xblock_user_state/interface.py diff --git a/lms/djangoapps/courseware/user_state_client.py b/lms/djangoapps/courseware/user_state_client.py index a60284cfbd..bb18d1d7ab 100644 --- a/lms/djangoapps/courseware/user_state_client.py +++ b/lms/djangoapps/courseware/user_state_client.py @@ -13,7 +13,7 @@ except ImportError: from django.contrib.auth.models import User from xblock.fields import Scope, ScopeBase -from xblock_user_state.interface import XBlockUserStateClient +from edx_user_state_client.interface import XBlockUserStateClient from courseware.models import StudentModule, StudentModuleHistory from contracts import contract, new_contract from opaque_keys.edx.keys import UsageKey diff --git a/lms/djangoapps/xblock_user_state/__init__.py b/lms/djangoapps/xblock_user_state/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/xblock_user_state/interface.py b/lms/djangoapps/xblock_user_state/interface.py deleted file mode 100644 index 2c7254d00f..0000000000 --- a/lms/djangoapps/xblock_user_state/interface.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -A baseclass for a generic client for accessing XBlock Scope.user_state field data. -""" - -from abc import abstractmethod - -from contracts import contract, new_contract, ContractsMeta -from opaque_keys.edx.keys import UsageKey -from xblock.fields import Scope, ScopeBase - -new_contract('UsageKey', UsageKey) - - -class XBlockUserStateClient(object): - """ - First stab at an interface for accessing XBlock User State. This will have - use StudentModule as a backing store in the default case. - - Scope/Goals: - 1. Mediate access to all student-specific state stored by XBlocks. - a. This includes "preferences" and "user_info" (i.e. UserScope.ONE) - b. This includes XBlock Asides. - c. This may later include user_state_summary (i.e. UserScope.ALL). - d. This may include group state in the future. - e. This may include other key types + UserScope.ONE (e.g. Definition) - 2. Assume network service semantics. - At some point, this will probably be calling out to an external service. - Even if it doesn't, we want to be able to implement circuit breakers, so - that a failure in StudentModule doesn't bring down the whole site. - This also implies that the client is running as a user, and whatever is - backing it is smart enough to do authorization checks. - 3. This does not yet cover export-related functionality. - - Open Questions: - 1. Is it sufficient to just send the block_key in and extract course + - version info from it? - 2. Do we want to use the username as the identifier? Privacy implications? - Ease of debugging? - 3. Would a get_many_by_type() be useful? - """ - - __metaclass__ = ContractsMeta - - class ServiceUnavailable(Exception): - """ - This error is raised if the service backing this client is currently unavailable. - """ - pass - - class PermissionDenied(Exception): - """ - This error is raised if the caller is not allowed to access the requested data. - """ - pass - - class DoesNotExist(Exception): - """ - This error is raised if the caller has requested data that does not exist. - """ - pass - - @contract( - username="basestring", - block_key=UsageKey, - scope=ScopeBase, - fields="seq(basestring)|set(basestring)|None", - returns="dict(basestring: *)" - ) - def get(self, username, block_key, scope=Scope.user_state, fields=None): - """ - Retrieve the stored XBlock state for a single xblock usage. - - Arguments: - username: The name of the user whose state should be retrieved - block_key (UsageKey): The UsageKey identifying which xblock state to load. - scope (Scope): The scope to load data from - fields: A list of field values to retrieve. If None, retrieve all stored fields. - - Returns - dict: A dictionary mapping field names to values - """ - return next(self.get_many(username, [block_key], scope, fields=fields))[1] - - @contract( - username="basestring", - block_key=UsageKey, - state="dict(basestring: *)", - scope=ScopeBase, - returns=None, - ) - def set(self, username, block_key, state, scope=Scope.user_state): - """ - Set fields for a particular XBlock. - - Arguments: - username: The name of the user whose state should be retrieved - block_key (UsageKey): The UsageKey identifying which xblock state to load. - state (dict): A dictionary mapping field names to values - scope (Scope): The scope to store data to - """ - self.set_many(username, {block_key: state}, scope) - - @contract( - username="basestring", - block_key=UsageKey, - scope=ScopeBase, - fields="seq(basestring)|set(basestring)|None", - returns=None, - ) - def delete(self, username, block_key, scope=Scope.user_state, fields=None): - """ - Delete the stored XBlock state for a single xblock usage. - - Arguments: - username: The name of the user whose state should be deleted - block_key (UsageKey): The UsageKey identifying which xblock state to delete. - scope (Scope): The scope to delete data from - fields: A list of fields to delete. If None, delete all stored fields. - """ - return self.delete_many(username, [block_key], scope, fields=fields) - - @contract( - username="basestring", - block_key=UsageKey, - scope=ScopeBase, - fields="seq(basestring)|set(basestring)|None", - returns="dict(basestring: datetime)", - ) - def get_mod_date(self, username, block_key, scope=Scope.user_state, fields=None): - """ - Get the last modification date for fields from the specified blocks. - - Arguments: - username: The name of the user whose state should queried - block_key (UsageKey): The UsageKey identifying which xblock modification dates to retrieve. - scope (Scope): The scope to retrieve from. - fields: A list of fields to query. If None, query all fields. - Specific implementations are free to return the same modification date - for all fields, if they don't store changes individually per field. - Implementations may omit fields for which data has not been stored. - - Returns: list a dict of {field_name: modified_date} for each selected field. - """ - results = self.get_mod_date_many(username, [block_key], scope, fields=fields) - return { - field: date for (_, field, date) in results - } - - @contract( - username="basestring", - block_keys="seq(UsageKey)|set(UsageKey)", - scope=ScopeBase, - fields="seq(basestring)|set(basestring)|None", - ) - @abstractmethod - def get_many(self, username, block_keys, scope=Scope.user_state, fields=None): - """ - Retrieve the stored XBlock state for a single xblock usage. - - Arguments: - username: The name of the user whose state should be retrieved - block_keys ([UsageKey]): A list of UsageKeys identifying which xblock states to load. - scope (Scope): The scope to load data from - fields: A list of field values to retrieve. If None, retrieve all stored fields. - - Yields: - (UsageKey, field_state) tuples for each specified UsageKey in block_keys. - field_state is a dict mapping field names to values. - """ - raise NotImplementedError() - - @contract( - username="basestring", - block_keys_to_state="dict(UsageKey: dict(basestring: *))", - scope=ScopeBase, - returns=None, - ) - @abstractmethod - def set_many(self, username, block_keys_to_state, scope=Scope.user_state): - """ - Set fields for a particular XBlock. - - Arguments: - username: The name of the user whose state should be retrieved - block_keys_to_state (dict): A dict mapping UsageKeys to state dicts. - Each state dict maps field names to values. These state dicts - are overlaid over the stored state. To delete fields, use - :meth:`delete` or :meth:`delete_many`. - scope (Scope): The scope to load data from - """ - raise NotImplementedError() - - @contract( - username="basestring", - block_keys="seq(UsageKey)|set(UsageKey)", - scope=ScopeBase, - fields="seq(basestring)|set(basestring)|None", - returns=None, - ) - @abstractmethod - def delete_many(self, username, block_keys, scope=Scope.user_state, fields=None): - """ - Delete the stored XBlock state for a many xblock usages. - - Arguments: - username: The name of the user whose state should be deleted - block_key (UsageKey): The UsageKey identifying which xblock state to delete. - scope (Scope): The scope to delete data from - fields: A list of fields to delete. If None, delete all stored fields. - """ - raise NotImplementedError() - - @contract( - username="basestring", - block_keys="seq(UsageKey)|set(UsageKey)", - scope=ScopeBase, - fields="seq(basestring)|set(basestring)|None", - ) - @abstractmethod - def get_mod_date_many(self, username, block_keys, scope=Scope.user_state, fields=None): - """ - Get the last modification date for fields from the specified blocks. - - Arguments: - username: The name of the user whose state should be queried - block_key (UsageKey): The UsageKey identifying which xblock modification dates to retrieve. - scope (Scope): The scope to retrieve from. - fields: A list of fields to query. If None, delete all stored fields. - Specific implementations are free to return the same modification date - for all fields, if they don't store changes individually per field. - Implementations may omit fields for which data has not been stored. - - Yields: tuples of (block, field_name, modified_date) for each selected field. - """ - raise NotImplementedError() - - def get_history(self, username, block_key, scope=Scope.user_state): - """We don't guarantee that history for many blocks will be fast.""" - raise NotImplementedError() - - def iter_all_for_block(self, block_key, scope=Scope.user_state, batch_size=None): - """ - You get no ordering guarantees. Fetching will happen in batch_size - increments. If you're using this method, you should be running in an - async task. - """ - raise NotImplementedError() - - def iter_all_for_course(self, course_key, block_type=None, scope=Scope.user_state, batch_size=None): - """ - You get no ordering guarantees. Fetching will happen in batch_size - increments. If you're using this method, you should be running in an - async task. - """ - raise NotImplementedError() diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 3db9e7883e..5bd5507d26 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -53,6 +53,7 @@ git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive -e git+https://github.com/edx/edx-reverification-block.git@a286e89c73e1b788e35ac5b08a54b71a9fa63cfd#egg=edx-reverification-block git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0 +-e git+https://github.com/edx/edx-user-state-client.git@64a8b603f42669bb7fdca03d364d4e8d3d6ad67d#egg=edx-user-state-client # Third Party XBlocks -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga From 84f03a16a61af8003db7cc3084caeee1bcf3de25 Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Thu, 25 Jun 2015 15:39:51 +0500 Subject: [PATCH 50/97] Reverification flow asks for permission to use the webcam twice In-course Reverification checkpoint ask for the camera access twice on Chrome. ECOM-1739 --- lms/static/js/verify_student/views/incourse_reverify_view.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lms/static/js/verify_student/views/incourse_reverify_view.js b/lms/static/js/verify_student/views/incourse_reverify_view.js index c61bf9ddbb..1196816625 100644 --- a/lms/static/js/verify_student/views/incourse_reverify_view.js +++ b/lms/static/js/verify_student/views/incourse_reverify_view.js @@ -37,7 +37,6 @@ this.listenTo( this.model, 'sync', _.bind( this.handleSubmitPhotoSuccess, this )); this.listenTo( this.model, 'error', _.bind( this.handleSubmissionError, this )); - this.render(); }, render: function() { From aa76d9482aeae10800feae312bd5656090f4faf5 Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Fri, 12 Jun 2015 15:19:33 -0400 Subject: [PATCH 51/97] Update Bok Choy to use optimized static assets TNL-2465 --- cms/envs/bok_choy.py | 29 +++++++++--- cms/envs/test_static_optimized.py | 45 +++++++++++++++++++ cms/static/build.js | 6 ++- .../verify_student/tests/test_views.py | 2 +- lms/envs/test_static_optimized.py | 16 +++++++ pavelib/utils/test/suites/acceptance_suite.py | 2 +- pavelib/utils/test/suites/bokchoy_suite.py | 11 ++--- pavelib/utils/test/suites/suite.py | 10 +++++ 8 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 cms/envs/test_static_optimized.py create mode 100644 lms/envs/test_static_optimized.py diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py index dcd120abf1..29f9accd97 100644 --- a/cms/envs/bok_choy.py +++ b/cms/envs/bok_choy.py @@ -1,5 +1,13 @@ """ -Settings for bok choy tests +Settings for Bok Choy tests that are used for running CMS and LMS. + +Bok Choy uses two different settings files: +1. test_static_optimized is used when invoking collectstatic +2. bok_choy is used when running CMS and LMS + +Note: it isn't possible to have a single settings file, because Django doesn't +support both generating static assets to a directory and also serving static +from the same directory. """ import os @@ -44,8 +52,20 @@ update_module_store_settings( default_store=os.environ.get('DEFAULT_STORE', 'draft'), ) -# Enable django-pipeline and staticfiles -STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath() +############################ STATIC FILES ############################# + +# Enable debug so that static assets are served by Django +DEBUG = True + +# Serve static files at /static directly from the staticfiles directory under test root +# Note: optimized files for testing are generated with settings from test_static_optimized +STATIC_URL = "/static/" +STATICFILES_FINDERS = ( + 'staticfiles.finders.FileSystemFinder', +) +STATICFILES_DIRS = ( + (TEST_ROOT / "staticfiles").abspath(), +) # Silence noisy logs import logging @@ -80,9 +100,6 @@ FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings ########################### Entrance Exams ################################# FEATURES['ENTRANCE_EXAMS'] = True -# Unfortunately, we need to use debug mode to serve staticfiles -DEBUG = True - # Point the URL used to test YouTube availability to our stub YouTube server YOUTUBE_PORT = 9080 YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT) diff --git a/cms/envs/test_static_optimized.py b/cms/envs/test_static_optimized.py new file mode 100644 index 0000000000..c2b333b547 --- /dev/null +++ b/cms/envs/test_static_optimized.py @@ -0,0 +1,45 @@ +""" +Settings used when generating static assets for use in tests. + +For example, Bok Choy uses two different settings files: +1. test_static_optimized is used when invoking collectstatic +2. bok_choy is used when running CMS and LMS + +Note: it isn't possible to have a single settings file, because Django doesn't +support both generating static assets to a directory and also serving static +from the same directory. +""" + +import os +from path import path # pylint: disable=no-name-in-module + +# Pylint gets confused by path.py instances, which report themselves as class +# objects. As a result, pylint applies the wrong regex in validating names, +# and throws spurious errors. Therefore, we disable invalid-name checking. +# pylint: disable=invalid-name + + +########################## Prod-like settings ################################### +# These should be as close as possible to the settings we use in production. +# As in prod, we read in environment and auth variables from JSON files. +# Unlike in prod, we use the JSON files stored in this repo. +# This is a convenience for ensuring (a) that we can consistently find the files +# and (b) that the files are the same in Jenkins as in local dev. +os.environ['SERVICE_VARIANT'] = 'bok_choy' +os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname() # pylint: disable=no-value-for-parameter + +from .aws import * # pylint: disable=wildcard-import, unused-wildcard-import + +######################### Testing overrides #################################### + +# Redirects to the test_root folder within the repo +TEST_ROOT = CONFIG_ROOT.dirname().dirname() / "test_root" # pylint: disable=no-value-for-parameter +LOG_DIR = (TEST_ROOT / "log").abspath() + +# Stores the static files under test root so that they don't overwrite existing static assets +STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath() + +# Disables uglify when tests are running (used by build.js). +# 1. Uglify is by far the slowest part of the build process +# 2. Having full source code makes debugging tests easier for developers +os.environ['REQUIRE_BUILD_PROFILE_OPTIMIZE'] = 'none' diff --git a/cms/static/build.js b/cms/static/build.js index bb890bee5c..340ce02456 100644 --- a/cms/static/build.js +++ b/cms/static/build.js @@ -19,6 +19,10 @@ })); }; + + var jsOptimize = process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE !== undefined ? + process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE : 'uglify2'; + return { /** * List the modules that will be optimized. All their immediate and deep @@ -143,7 +147,7 @@ * mode to minify the code. Only available if REQUIRE_ENVIRONMENT is "rhino" (the default). * - "none": No minification will be done. */ - optimize: 'uglify2', + optimize: jsOptimize, /** * Sets the logging level. It is a number: * TRACE: 0, diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index dc6620da0c..c97593fe84 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -1965,7 +1965,7 @@ class TestEmailMessageWithCustomICRVBlock(ModuleStoreTestCase): "We could not verify your identity for the {assessment} assessment " "in the {course_name} course. You have used " "{used_attempts} out of {allowed_attempts} attempts to " - "verify your identity.".format( + "verify your identity".format( course_name=self.course.display_name_with_default, assessment=self.assessment, used_attempts=1, diff --git a/lms/envs/test_static_optimized.py b/lms/envs/test_static_optimized.py new file mode 100644 index 0000000000..61c99b6590 --- /dev/null +++ b/lms/envs/test_static_optimized.py @@ -0,0 +1,16 @@ +""" +Settings used when generating static assets for use in tests. + +Bok Choy uses two different settings files: +1. test_static_optimized is used when invoking collectstatic +2. bok_choy is used when running CMS and LMS + +Note: it isn't possible to have a single settings file, because Django doesn't +support both generating static assets to a directory and also serving static +from the same directory. + +""" + +# TODO: update the Bok Choy tests to run with optimized static assets (as is done in Studio) + +from .bok_choy import * # pylint: disable=wildcard-import, unused-wildcard-import diff --git a/pavelib/utils/test/suites/acceptance_suite.py b/pavelib/utils/test/suites/acceptance_suite.py index f9f8897b1b..52bf492909 100644 --- a/pavelib/utils/test/suites/acceptance_suite.py +++ b/pavelib/utils/test/suites/acceptance_suite.py @@ -91,7 +91,7 @@ class AcceptanceTestSuite(TestSuite): def __enter__(self): super(AcceptanceTestSuite, self).__enter__() - if not self.skip_clean: + if not (self.fasttest or self.skip_clean): test_utils.clean_test_files() if not self.fasttest: diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index 356d7f1711..1301368436 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -57,7 +57,7 @@ class BokChoyTestSuite(TestSuite): self.report_dir.makedirs_p() test_utils.clean_reports_dir() - if not self.skip_clean: + if not (self.fasttest or self.skip_clean): test_utils.clean_test_files() msg = colorize('green', "Checking for mongo, memchache, and mysql...") @@ -85,16 +85,13 @@ class BokChoyTestSuite(TestSuite): def prepare_bokchoy_run(self): """ - Sets up and starts servers for bok-choy run. This includes any stubbed servers. + Sets up and starts servers for a Bok Choy run. If --fasttest is not + specified then static assets are collected """ sh("{}/scripts/reset-test-db.sh".format(Env.REPO_ROOT)) if not self.fasttest: - # Process assets and set up database for bok-choy tests - # Reset the database - - # Collect static assets - sh("paver update_assets --settings=bok_choy") + self.generate_optimized_static_assets() # Clear any test data already in Mongo or MySQLand invalidate # the cache diff --git a/pavelib/utils/test/suites/suite.py b/pavelib/utils/test/suites/suite.py index 82647c9624..bfe83d0766 100644 --- a/pavelib/utils/test/suites/suite.py +++ b/pavelib/utils/test/suites/suite.py @@ -3,6 +3,8 @@ A class used for defining and running test suites """ import sys import subprocess +from paver.easy import sh + from pavelib.utils.process import kill_process try: @@ -57,6 +59,14 @@ class TestSuite(object): """ return None + def generate_optimized_static_assets(self): + """ + Collect static assets using test_static_optimized.py which generates + optimized files to a dedicated test static root. + """ + print colorize('green', "Generating optimized static assets...") + sh("paver update_assets --settings=test_static_optimized") + def run_test(self): """ Runs a self.cmd in a subprocess and waits for it to finish. From 923edee15f9d9334b23693d1091c658aa4283be6 Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 22 Jun 2015 13:02:44 -0400 Subject: [PATCH 52/97] Fix flaky test_import_timestamp test. TNL-2386 --- common/test/acceptance/pages/studio/import_export.py | 8 +++++++- common/test/acceptance/tests/studio/test_import_export.py | 6 ++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/common/test/acceptance/pages/studio/import_export.py b/common/test/acceptance/pages/studio/import_export.py index 15859af15e..1d8587ebf0 100644 --- a/common/test/acceptance/pages/studio/import_export.py +++ b/common/test/acceptance/pages/studio/import_export.py @@ -239,10 +239,16 @@ class ImportMixin(object): def is_timestamp_visible(self): """ - Checks if the UTC timestamp of the last successfull import is visible + Checks if the UTC timestamp of the last successful import is visible """ return self.q(css='.item-progresspoint-success-date').visible + def wait_for_timestamp_visible(self): + """ + Wait for the timestamp of the last successful import to be visible. + """ + EmptyPromise(self.is_timestamp_visible, 'Timestamp Visible', timeout=30).fulfill() + def wait_for_filename_error(self): """ Wait for the upload field to display an error. diff --git a/common/test/acceptance/tests/studio/test_import_export.py b/common/test/acceptance/tests/studio/test_import_export.py index 67af0cd00e..beadbafb68 100644 --- a/common/test/acceptance/tests/studio/test_import_export.py +++ b/common/test/acceptance/tests/studio/test_import_export.py @@ -4,7 +4,6 @@ Acceptance tests for the Import and Export pages from abc import abstractmethod from bok_choy.promise import EmptyPromise from datetime import datetime -from flaky import flaky from .base_studio_test import StudioLibraryTest, StudioCourseTest from ...fixtures.course import XBlockFixtureDesc @@ -186,7 +185,6 @@ class ImportTestMixin(object): self.import_page.upload_tarball(self.tarball_name) self.import_page.wait_for_upload() - @flaky # TODO: fix this. See TNL-2386 def test_import_timestamp(self): """ Scenario: I perform a course / library import @@ -200,13 +198,13 @@ class ImportTestMixin(object): utc_now = datetime.utcnow() import_date, import_time = self.import_page.timestamp - self.assertTrue(self.import_page.is_timestamp_visible()) + self.import_page.wait_for_timestamp_visible() self.assertEqual(utc_now.strftime('%m/%d/%Y'), import_date) self.assertEqual(utc_now.strftime('%H:%M'), import_time) self.import_page.visit() self.import_page.wait_for_tasks(completed=True) - self.assertTrue(self.import_page.is_timestamp_visible()) + self.import_page.wait_for_timestamp_visible() def test_landing_url(self): """ From f7262a211eeb8163dd9f3fb59732a136ad655d2e Mon Sep 17 00:00:00 2001 From: Matt Drayer Date: Thu, 25 Jun 2015 10:56:59 -0400 Subject: [PATCH 53/97] mattdrayer/SOL-449: Add flaky decorator to bok choy test --- .../acceptance/tests/studio/test_studio_settings_details.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/test/acceptance/tests/studio/test_studio_settings_details.py b/common/test/acceptance/tests/studio/test_studio_settings_details.py index f81e1cae82..76538422f0 100644 --- a/common/test/acceptance/tests/studio/test_studio_settings_details.py +++ b/common/test/acceptance/tests/studio/test_studio_settings_details.py @@ -1,6 +1,7 @@ """ Acceptance tests for Studio's Settings Details pages """ +from flaky import flaky from unittest import skip from .base_studio_test import StudioCourseTest @@ -40,6 +41,7 @@ class SettingsMilestonesTest(StudioCourseTest): self.assertTrue(self.settings_detail.pre_requisite_course_options) + @flaky # TODO: fix this. SOL-449 def test_prerequisite_course_save_successfully(self): """ Scenario: Selecting course from Pre-Requisite course drop down save the selected course as pre-requisite From 8548d5570043fbb008cc76a4dd0b1a3e82590fe5 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 25 Jun 2015 10:53:36 -0700 Subject: [PATCH 54/97] Gracefully handle credit provider keys with unicode type --- openedx/core/djangoapps/credit/signature.py | 16 +++++++- .../djangoapps/credit/tests/test_signature.py | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/credit/tests/test_signature.py diff --git a/openedx/core/djangoapps/credit/signature.py b/openedx/core/djangoapps/credit/signature.py index 799d56d27f..27f0c7cc97 100644 --- a/openedx/core/djangoapps/credit/signature.py +++ b/openedx/core/djangoapps/credit/signature.py @@ -16,17 +16,30 @@ we receive from the credit provider. """ +import logging import hashlib import hmac from django.conf import settings +log = logging.getLogger(__name__) + + def get_shared_secret_key(provider_id): """ Retrieve the shared secret key for a particular credit provider. """ - return getattr(settings, "CREDIT_PROVIDER_SECRET_KEYS", {}).get(provider_id) + secret = getattr(settings, "CREDIT_PROVIDER_SECRET_KEYS", {}).get(provider_id) + + if isinstance(secret, unicode): + try: + secret = str(secret) + except UnicodeEncodeError: + secret = None + log.error(u'Shared secret key for credit provider "%s" contains non-ASCII unicode.', provider_id) + + return secret def signature(params, shared_secret): @@ -35,6 +48,7 @@ def signature(params, shared_secret): Arguments: params (dict): Parameters to sign. Ignores the "signature" key if present. + shared_secret (str): The shared secret string. Returns: str: The 32-character signature. diff --git a/openedx/core/djangoapps/credit/tests/test_signature.py b/openedx/core/djangoapps/credit/tests/test_signature.py new file mode 100644 index 0000000000..3b40014703 --- /dev/null +++ b/openedx/core/djangoapps/credit/tests/test_signature.py @@ -0,0 +1,37 @@ +""" +Tests for digital signatures used to validate messages to/from credit providers. +""" + +from django.test import TestCase +from django.test.utils import override_settings + + +from openedx.core.djangoapps.credit import signature + + +class SignatureTest(TestCase): + """ + Tests for digital signatures. + """ + + @override_settings(CREDIT_PROVIDER_SECRET_KEYS={ + "asu": u'abcd1234' + }) + def test_unicode_secret_key(self): + # Test a key that has type `unicode` but consists of ASCII characters + # (This can happen, for example, when loading the key from a JSON configuration file) + # When retrieving the shared secret, the type should be converted to `str` + key = signature.get_shared_secret_key("asu") + sig = signature.signature({}, key) + self.assertEqual(sig, "7d70a26b834d9881cc14466eceac8d39188fc5ef5ffad9ab281a8327c2c0d093") + + @override_settings(CREDIT_PROVIDER_SECRET_KEYS={ + "asu": u'\u4567' + }) + def test_non_ascii_unicode_secret_key(self): + # Test a key that contains non-ASCII unicode characters + # This should return `None` and log an error; the caller + # is then responsible for logging the appropriate errors + # so we can fix the misconfiguration. + key = signature.get_shared_secret_key("asu") + self.assertIs(key, None) From 324d4785c13965214c9410e1b61a0f276d7d370e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 19 Jun 2015 12:04:45 -0400 Subject: [PATCH 55/97] Include stack traces when counting calls in unit tests --- .../xmodule/modulestore/tests/factories.py | 165 +++++++++++++++--- .../tests/test_field_override_performance.py | 2 +- 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index cea8c2ebc4..ba5c1a9598 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -2,12 +2,15 @@ Factories for use in tests of XBlocks. """ +import functools import inspect import pprint -import threading -from uuid import uuid4 -from decorator import contextmanager import pymongo.message +import threading +import traceback +from collections import defaultdict +from decorator import contextmanager +from uuid import uuid4 from factory import Factory, Sequence, lazy_attribute_sequence, lazy_attribute from factory.containers import CyclicDefinitionError @@ -320,47 +323,157 @@ def check_number_of_calls(object_with_method, method_name, maximum_calls, minimu return check_sum_of_calls(object_with_method, [method_name], maximum_calls, minimum_calls) +class StackTraceCounter(object): + """ + A class that counts unique stack traces underneath a particular stack frame. + """ + def __init__(self, stack_depth, include_arguments=True): + """ + Arguments: + stack_depth (int): The number of stack frames above this constructor to capture. + include_arguments (bool): Whether to store the arguments that are passed + when capturing a stack trace. + """ + self.include_arguments = include_arguments + self._top_of_stack = traceback.extract_stack(limit=stack_depth)[0] + + if self.include_arguments: + self._stacks = defaultdict(lambda: defaultdict(int)) + else: + self._stacks = defaultdict(int) + + def capture_stack(self, args, kwargs): + """ + Record the stack frames starting at the caller of this method, and + ending at the top of the stack as defined by the ``stack_depth``. + + Arguments: + args: The positional arguments to capture at this stack frame + kwargs: The keyword arguments to capture at this stack frame + """ + stack = traceback.extract_stack()[:-2] + + if self._top_of_stack in stack: + stack = stack[stack.index(self._top_of_stack):] + + if self.include_arguments: + safe_args = [] + for arg in args: + try: + safe_args.append(repr(arg)) + except Exception as exc: + safe_args.append(' Date: Wed, 17 Jun 2015 18:33:40 -0400 Subject: [PATCH 56/97] Pass XBlock parents down to their children when constructing them, for caching --- common/lib/xmodule/xmodule/error_module.py | 15 +++- .../xmodule/xmodule/modulestore/mongo/base.py | 32 ++++--- .../xmodule/modulestore/mongo/draft.py | 6 +- .../split_mongo/caching_descriptor_system.py | 1 + .../xmodule/modulestore/tests/factories.py | 3 + common/lib/xmodule/xmodule/modulestore/xml.py | 4 +- .../xmodule/xmodule/tests/test_conditional.py | 12 ++- .../xmodule/tests/test_error_module.py | 22 ++--- .../lib/xmodule/xmodule/tests/xml/__init__.py | 2 +- common/lib/xmodule/xmodule/x_module.py | 86 +++++++++++-------- .../tests/test_field_override_performance.py | 24 +++--- .../course_structure_api/v0/views.py | 7 +- .../courseware/tests/test_module_render.py | 15 ++-- requirements/edx/github.txt | 2 +- 14 files changed, 145 insertions(+), 86 deletions(-) diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 066a1ad38b..11c0c28720 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -80,7 +80,18 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): return u'' @classmethod - def _construct(cls, system, contents, error_msg, location): + def _construct(cls, system, contents, error_msg, location, for_parent=None): + """ + Build a new ErrorDescriptor. using ``system``. + + Arguments: + system (:class:`DescriptorSystem`): The :class:`DescriptorSystem` used + to construct the XBlock that had an error. + contents (unicode): An encoding of the content of the xblock that had an error. + error_msg (unicode): A message describing the error. + location (:class:`UsageKey`): The usage key of the XBlock that had an error. + for_parent (:class:`XBlock`): Optional. The parent of this error block. + """ if error_msg is None: # this string is not marked for translation because we don't have @@ -110,6 +121,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): # real scope keys ScopeIds(None, 'error', location, location), field_data, + for_parent=for_parent, ) def get_context(self): @@ -139,6 +151,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): str(descriptor), error_msg, location=descriptor.location, + for_parent=descriptor.get_parent() if descriptor.has_cached_parent else None ) @classmethod diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index 9a4ba7c6f1..d7ef5d542b 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -223,7 +223,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): self.course_id = course_key self.cached_metadata = cached_metadata - def load_item(self, location): + def load_item(self, location, for_parent=None): # pylint: disable=method-hidden """ Return an XModule instance for the specified location """ @@ -292,7 +292,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): field_data = KvsFieldData(kvs) scope_ids = ScopeIds(None, category, location, location) - module = self.construct_xblock_from_class(class_, scope_ids, field_data) + module = self.construct_xblock_from_class(class_, scope_ids, field_data, for_parent=for_parent) if self.cached_metadata is not None: # parent container pointers don't differentiate between draft and non-draft # so when we do the lookup, we should do so with a non-draft location @@ -883,7 +883,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo apply_cached_metadata=bool, using_descriptor_system="None|CachingDescriptorSystem" ) - def _load_item(self, course_key, item, data_cache, apply_cached_metadata=True, using_descriptor_system=None): + def _load_item(self, course_key, item, data_cache, + apply_cached_metadata=True, using_descriptor_system=None, for_parent=None): """ Load an XModuleDescriptor from item, using the children stored in data_cache @@ -898,6 +899,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo purposes. using_descriptor_system (CachingDescriptorSystem): The existing CachingDescriptorSystem to add data to, and to load the XBlocks from. + for_parent (:class:`XBlock`): The parent of the XBlock being loaded. """ course_key = self.fill_in_run(course_key) location = Location._from_deprecated_son(item['location'], course_key.run) @@ -942,9 +944,9 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo system.module_data.update(data_cache) system.cached_metadata.update(cached_metadata) - return system.load_item(location) + return system.load_item(location, for_parent=for_parent) - def _load_items(self, course_key, items, depth=0, using_descriptor_system=None): + def _load_items(self, course_key, items, depth=0, using_descriptor_system=None, for_parent=None): """ Load a list of xmodules from the data in items, with children cached up to specified depth @@ -960,7 +962,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo item, data_cache, using_descriptor_system=using_descriptor_system, - apply_cached_metadata=self._should_apply_cached_metadata(item, depth) + apply_cached_metadata=self._should_apply_cached_metadata(item, depth), + for_parent=for_parent, ) for item in items ] @@ -1078,7 +1081,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo except ItemNotFoundError: return False - def get_item(self, usage_key, depth=0, using_descriptor_system=None): + def get_item(self, usage_key, depth=0, using_descriptor_system=None, for_parent=None, **kwargs): """ Returns an XModuleDescriptor instance for the item at location. @@ -1101,7 +1104,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo usage_key.course_key, [item], depth, - using_descriptor_system=using_descriptor_system + using_descriptor_system=using_descriptor_system, + for_parent=for_parent, )[0] return module @@ -1293,6 +1297,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo # so we use the location for both. ScopeIds(None, block_type, location, location), dbmodel, + for_parent=kwargs.get('for_parent'), ) if fields is not None: for key, value in fields.iteritems(): @@ -1341,11 +1346,16 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo block_id: a unique identifier for the new item. If not supplied, a new identifier will be generated """ - xblock = self.create_item(user_id, parent_usage_key.course_key, block_type, block_id=block_id, **kwargs) # attach to parent if given - if 'detached' not in xblock._class_tags: - parent = self.get_item(parent_usage_key) + parent = None + if parent_usage_key is not None: + parent = self.get_item(parent_usage_key) + kwargs.setdefault('for_parent', parent) + + xblock = self.create_item(user_id, parent_usage_key.course_key, block_type, block_id=block_id, **kwargs) + + if parent is not None and 'detached' not in xblock._class_tags: # Originally added to support entrance exams (settings.FEATURES.get('ENTRANCE_EXAMS')) if kwargs.get('position') is None: parent.children.append(xblock.location) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py index 814058d145..8c181b2639 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py @@ -82,12 +82,14 @@ class DraftModuleStore(MongoModuleStore): """ def get_published(): return wrap_draft(super(DraftModuleStore, self).get_item( - usage_key, depth=depth, using_descriptor_system=using_descriptor_system + usage_key, depth=depth, using_descriptor_system=using_descriptor_system, + for_parent=kwargs.get('for_parent'), )) def get_draft(): return wrap_draft(super(DraftModuleStore, self).get_item( - as_draft(usage_key), depth=depth, using_descriptor_system=using_descriptor_system + as_draft(usage_key), depth=depth, using_descriptor_system=using_descriptor_system, + for_parent=kwargs.get('for_parent') )) # return the published version if ModuleStoreEnum.RevisionOption.published_only is requested diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py index dff8b0d853..f8f1dbe54e 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -227,6 +227,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): class_, ScopeIds(None, block_key.type, definition_id, block_locator), field_data, + for_parent=kwargs.get('for_parent') ) except Exception: # pylint: disable=broad-except log.warning("Failed to load descriptor", exc_info=True) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index ba5c1a9598..abfc1f9664 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -351,6 +351,8 @@ class StackTraceCounter(object): args: The positional arguments to capture at this stack frame kwargs: The keyword arguments to capture at this stack frame """ + # pylint: disable=broad-except + stack = traceback.extract_stack()[:-2] if self._top_of_stack in stack: @@ -419,6 +421,7 @@ class StackTraceCounter(object): """ stacks = StackTraceCounter(stack_depth, include_arguments) + # pylint: disable=missing-docstring @functools.wraps(func) def capture(*args, **kwargs): stacks.capture_stack(args, kwargs) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 78ef1043d3..8950380615 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -250,9 +250,9 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # TODO (vshnayder): we are somewhat architecturally confused in the loading code: # load_item should actually be get_instance, because it expects the course-specific # policy to be loaded. For now, just add the course_id here... - def load_item(usage_key): + def load_item(usage_key, for_parent=None): """Return the XBlock for the specified location""" - return xmlstore.get_item(usage_key) + return xmlstore.get_item(usage_key, for_parent=for_parent) resources_fs = OSFS(xmlstore.data_dir / course_dir) diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 4dc087fd5e..d392de1829 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -79,10 +79,14 @@ class ConditionalFactory(object): child_descriptor.render = lambda view, context=None: descriptor_system.render(child_descriptor, view, context) child_descriptor.location = source_location.replace(category='html', name='child') - descriptor_system.load_item = { - child_descriptor.location: child_descriptor, - source_location: source_descriptor - }.get + def load_item(usage_id, for_parent=None): # pylint: disable=unused-argument + """Test-only implementation of load_item that simply returns static xblocks.""" + return { + child_descriptor.location: child_descriptor, + source_location: source_descriptor + }.get(usage_id) + + descriptor_system.load_item = load_item system.descriptor_runtime = descriptor_system diff --git a/common/lib/xmodule/xmodule/tests/test_error_module.py b/common/lib/xmodule/xmodule/tests/test_error_module.py index 1b12017fbd..a59cc56452 100644 --- a/common/lib/xmodule/xmodule/tests/test_error_module.py +++ b/common/lib/xmodule/xmodule/tests/test_error_module.py @@ -9,7 +9,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location from xmodule.x_module import XModuleDescriptor, XModule, STUDENT_VIEW from mock import MagicMock, Mock, patch from xblock.runtime import Runtime, IdReader -from xblock.field_data import FieldData +from xblock.field_data import DictFieldData from xblock.fields import ScopeIds from xblock.test.tools import unabc @@ -43,10 +43,11 @@ class TestErrorModule(SetupTestErrorModules): self.assertIn(repr(self.valid_xml), context_repr) def test_error_module_from_descriptor(self): - descriptor = MagicMock([XModuleDescriptor], - runtime=self.system, - location=self.location, - _field_data=self.valid_xml) + descriptor = MagicMock( + spec=XModuleDescriptor, + runtime=self.system, + location=self.location, + ) error_descriptor = ErrorDescriptor.from_descriptor( descriptor, self.error_msg) @@ -81,10 +82,11 @@ class TestNonStaffErrorModule(SetupTestErrorModules): self.assertNotIn(repr(self.valid_xml), context_repr) def test_error_module_from_descriptor(self): - descriptor = MagicMock([XModuleDescriptor], - runtime=self.system, - location=self.location, - _field_data=self.valid_xml) + descriptor = MagicMock( + spec=XModuleDescriptor, + runtime=self.system, + location=self.location, + ) error_descriptor = NonStaffErrorDescriptor.from_descriptor( descriptor, self.error_msg) @@ -122,7 +124,7 @@ class TestErrorModuleConstruction(unittest.TestCase): def setUp(self): # pylint: disable=abstract-class-instantiated super(TestErrorModuleConstruction, self).setUp() - field_data = Mock(spec=FieldData) + field_data = DictFieldData({}) self.descriptor = BrokenDescriptor( TestRuntime(Mock(spec=IdReader), field_data), field_data, diff --git a/common/lib/xmodule/xmodule/tests/xml/__init__.py b/common/lib/xmodule/xmodule/tests/xml/__init__.py index c6ec34785c..f4342b45b6 100644 --- a/common/lib/xmodule/xmodule/tests/xml/__init__.py +++ b/common/lib/xmodule/xmodule/tests/xml/__init__.py @@ -49,7 +49,7 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable self._descriptors[descriptor.location.to_deprecated_string()] = descriptor return descriptor - def load_item(self, location): # pylint: disable=method-hidden + def load_item(self, location, for_parent=None): # pylint: disable=method-hidden, unused-argument """Return the descriptor loaded for `location`""" return self._descriptors[location.to_deprecated_string()] diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9c809b1fad..da90b47d7a 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -295,7 +295,6 @@ class XModuleMixin(XModuleFields, XBlockMixin): def __init__(self, *args, **kwargs): self.xmodule_runtime = None - self._child_instances = None super(XModuleMixin, self).__init__(*args, **kwargs) @@ -424,39 +423,45 @@ class XModuleMixin(XModuleFields, XBlockMixin): else: return [self.display_name_with_default] - def get_children(self, usage_key_filter=None): + def get_children(self, usage_id_filter=None, usage_key_filter=None): # pylint: disable=arguments-differ """Returns a list of XBlock instances for the children of this module""" - if not self.has_children: - return [] + # Be backwards compatible with callers using usage_key_filter + if usage_id_filter is None and usage_key_filter is not None: + usage_id_filter = usage_key_filter - if self._child_instances is None: - self._child_instances = [] # pylint: disable=attribute-defined-outside-init - for child_loc in self.children: - # Skip if it doesn't satisfy the filter function - if usage_key_filter and not usage_key_filter(child_loc): - continue - try: - child = self.runtime.get_block(child_loc) - if child is None: - continue + return [ + child + for child + in super(XModuleMixin, self).get_children(usage_id_filter) + if child is not None + ] - child.runtime.export_fs = self.runtime.export_fs - except ItemNotFoundError: - log.warning(u'Unable to load item {loc}, skipping'.format(loc=child_loc)) - dog_stats_api.increment( - "xmodule.item_not_found_error", - tags=[ - u"course_id:{}".format(child_loc.course_key), - u"block_type:{}".format(child_loc.block_type), - u"parent_block_type:{}".format(self.location.block_type), - ] - ) - continue - self._child_instances.append(child) + def get_child(self, usage_id): + """ + Return the child XBlock identified by ``usage_id``, or ``None`` if there + is an error while retrieving the block. + """ + try: + child = super(XModuleMixin, self).get_child(usage_id) + except ItemNotFoundError: + log.warning(u'Unable to load item %s, skipping', usage_id) + dog_stats_api.increment( + "xmodule.item_not_found_error", + tags=[ + u"course_id:{}".format(usage_id.course_key), + u"block_type:{}".format(usage_id.block_type), + u"parent_block_type:{}".format(self.location.block_type), + ] + ) + return None - return self._child_instances + if child is None: + return None + + child.runtime.export_fs = self.runtime.export_fs + return child def get_required_module_descriptors(self): """Returns a list of XModuleDescriptor instances upon which this module depends, but are @@ -573,7 +578,7 @@ class XModuleMixin(XModuleFields, XBlockMixin): self.scope_ids = self.scope_ids._replace(user_id=user_id) # Clear out any cached instantiated children. - self._child_instances = None + self.clear_child_cache() # Clear out any cached field data scoped to the old user. for field in self.fields.values(): @@ -767,7 +772,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me # Set the descriptor first so that we can proxy to it self.descriptor = descriptor - self._loaded_children = None self._runtime = None super(XModule, self).__init__(*args, **kwargs) self.runtime.xmodule_instance = self @@ -820,6 +824,19 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me response_data = self.handle_ajax(suffix, request_post) return Response(response_data, content_type='application/json') + def get_child(self, usage_id): + if usage_id in self._child_cache: + return self._child_cache[usage_id] + + # Take advantage of the children cache that the descriptor might have + child_descriptor = self.descriptor.get_child(usage_id) + child_block = None + if child_descriptor is not None: + child_block = self.system.get_module(child_descriptor) + + self._child_cache[usage_id] = child_block + return child_block + def get_child_descriptors(self): """ Returns the descriptors of the child modules @@ -1103,6 +1120,7 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): descriptor=self, scope_ids=self.scope_ids, field_data=self._field_data, + for_parent=self.get_parent() if self.has_cached_parent else None ) self.xmodule_runtime.xmodule_instance.save() except Exception: # pylint: disable=broad-except @@ -1340,9 +1358,9 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p else: self.get_policy = lambda u: {} - def get_block(self, usage_id): + def get_block(self, usage_id, for_parent=None): """See documentation for `xblock.runtime:Runtime.get_block`""" - return self.load_item(usage_id) + return self.load_item(usage_id, for_parent=for_parent) def get_field_provenance(self, xblock, field): """ @@ -1681,8 +1699,8 @@ class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # pylin assert self.xmodule_instance is not None return self.handler_url(self.xmodule_instance, 'xmodule_handler', '', '').rstrip('/?') - def get_block(self, block_id): - return self.get_module(self.descriptor_runtime.get_block(block_id)) + def get_block(self, block_id, for_parent=None): + return self.get_module(self.descriptor_runtime.get_block(block_id, for_parent=for_parent)) def resource_url(self, resource): raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls") diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index ad14b6908c..3b98b3b46b 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -173,18 +173,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): TEST_DATA = { # (providers, course_width, enable_ccx): # of sql queries, # of mongo queries, # of xblocks - ('no_overrides', 1, True): (26, 7, 19), - ('no_overrides', 2, True): (134, 7, 131), - ('no_overrides', 3, True): (594, 7, 537), - ('ccx', 1, True): (26, 7, 47), - ('ccx', 2, True): (134, 7, 455), - ('ccx', 3, True): (594, 7, 2037), - ('no_overrides', 1, False): (26, 7, 19), - ('no_overrides', 2, False): (134, 7, 131), - ('no_overrides', 3, False): (594, 7, 537), - ('ccx', 1, False): (26, 7, 19), - ('ccx', 2, False): (134, 7, 131), - ('ccx', 3, False): (594, 7, 537), + ('no_overrides', 1, True): (26, 7, 14), + ('no_overrides', 2, True): (134, 7, 85), + ('no_overrides', 3, True): (594, 7, 336), + ('ccx', 1, True): (26, 7, 14), + ('ccx', 2, True): (134, 7, 85), + ('ccx', 3, True): (594, 7, 336), + ('no_overrides', 1, False): (26, 7, 14), + ('no_overrides', 2, False): (134, 7, 85), + ('no_overrides', 3, False): (594, 7, 336), + ('ccx', 1, False): (26, 7, 14), + ('ccx', 2, False): (134, 7, 85), + ('ccx', 3, False): (594, 7, 336), } diff --git a/lms/djangoapps/course_structure_api/v0/views.py b/lms/djangoapps/course_structure_api/v0/views.py index 2d31995751..08f8f56df9 100644 --- a/lms/djangoapps/course_structure_api/v0/views.py +++ b/lms/djangoapps/course_structure_api/v0/views.py @@ -560,7 +560,12 @@ class CourseBlocksAndNavigation(ListAPIView): ) # verify the user has access to this block - if not has_access(request_info.request.user, 'load', block_info.block, course_key=request_info.course.id): + if (block_info.block is None or not has_access( + request_info.request.user, + 'load', + block_info.block, + course_key=request_info.course.id + )): return # add the block's value to the result diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 408f763178..3459f4eb46 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -610,11 +610,11 @@ class TestTOC(ModuleStoreTestCase): # Split makes 6 queries to load the course to depth 2: # - load the structure # - load 5 definitions - # Split makes 6 queries to render the toc: + # Split makes 5 queries to render the toc: # - it loads the active version at the start of the bulk operation - # - it loads 5 definitions, because it instantiates the a CourseModule and 4 VideoModules + # - it loads 4 definitions, because it instantiates 4 VideoModules # each of which access a Scope.content field in __init__ - @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 6, 0, 6)) + @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 6, 0, 5)) @ddt.unpack def test_toc_toy_from_chapter(self, default_ms, setup_finds, setup_sends, toc_finds): with self.store.default_store(default_ms): @@ -634,9 +634,10 @@ class TestTOC(ModuleStoreTestCase): 'format': '', 'due': None, 'active': False}], 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) + course = self.store.get_course(self.toy_course.id, depth=2) with check_mongo_calls(toc_finds): actual = render.toc_for_course( - self.request, self.toy_course, self.chapter, None, self.field_data_cache + self.request, course, self.chapter, None, self.field_data_cache ) for toc_section in expected: self.assertIn(toc_section, actual) @@ -648,11 +649,11 @@ class TestTOC(ModuleStoreTestCase): # Split makes 6 queries to load the course to depth 2: # - load the structure # - load 5 definitions - # Split makes 2 queries to render the toc: + # Split makes 5 queries to render the toc: # - it loads the active version at the start of the bulk operation - # - it loads 5 definitions, because it instantiates the a CourseModule and 4 VideoModules + # - it loads 4 definitions, because it instantiates 4 VideoModules # each of which access a Scope.content field in __init__ - @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 6, 0, 6)) + @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 6, 0, 5)) @ddt.unpack def test_toc_toy_from_section(self, default_ms, setup_finds, setup_sends, toc_finds): with self.store.default_store(default_ms): diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index d7fb1bbc04..3db27e48d0 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -32,7 +32,7 @@ git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c -e git+https://github.com/jazkarta/ccx-keys.git@e6b03704b1bb97c1d2f31301ecb4e3a687c536ea#egg=ccx-keys # Our libraries: --e git+https://github.com/edx/XBlock.git@e1831fa86bff778ffe1308e00d8ed51b26f7c047#egg=XBlock +-e git+https://github.com/cpennington/XBlock.git@64f6bdaf6c987cdea4798d8b1a1fa8e471c20a21#egg=XBlock -e git+https://github.com/edx/codejail.git@6b17c33a89bef0ac510926b1d7fea2748b73aadd#egg=codejail -e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool -e git+https://github.com/edx/event-tracking.git@0.2.0#egg=event-tracking From 9ed127ec8ae8af12c86189d79e8e8b0a3a8040af Mon Sep 17 00:00:00 2001 From: Alison Hodges Date: Thu, 25 Jun 2015 14:14:07 -0400 Subject: [PATCH 57/97] Fixes DOC-1715, video doc is now in the correct RTD project --- docs/config.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.ini b/docs/config.ini index 01ce1e72ef..e1f5999618 100644 --- a/docs/config.ini +++ b/docs/config.ini @@ -39,7 +39,7 @@ content_libraries = creating_content/libraries.html content_groups = cohorts/cohorted_courseware.html group_configurations = content_experiments/content_experiments_configure.html#set-up-group-configurations-in-edx-studio container = developing_course/course_components.html#components-that-contain-other-components -video = index.html +video = video/video_uploads.html certificates = building_course/creating_course_certificates.html # below are the language directory names for the different locales From 8c505e6b36c9e25b17cfe4035aba4a481cd53ec6 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Wed, 24 Jun 2015 13:57:47 -0400 Subject: [PATCH 58/97] MA-879 Fix bug in course_start_datetime_text --- .../xmodule/xmodule/course_metadata_utils.py | 11 ++++---- .../tests/test_course_metadata_utils.py | 27 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_metadata_utils.py b/common/lib/xmodule/xmodule/course_metadata_utils.py index 07e6d384cb..832be14d1a 100644 --- a/common/lib/xmodule/xmodule/course_metadata_utils.py +++ b/common/lib/xmodule/xmodule/course_metadata_utils.py @@ -157,12 +157,13 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget try: # from_json either returns a Date, returns None, or raises a ValueError parsed_advertised_start = Date().from_json(advertised_start) + if parsed_advertised_start is not None: + # In the Django implementation of strftime_localized, if + # the year is <1900, _datetime_to_string will raise a ValueError. + return _datetime_to_string(parsed_advertised_start, format_string, strftime_localized) except ValueError: - parsed_advertised_start = None - return ( - _datetime_to_string(parsed_advertised_start, format_string, strftime_localized) if parsed_advertised_start - else advertised_start.title() - ) + pass + return advertised_start.title() elif start_date != DEFAULT_START_DATE: return _datetime_to_string(start_date, format_string, strftime_localized) else: diff --git a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py index 60317d02fd..aaafca9454 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py +++ b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py @@ -101,12 +101,13 @@ class CourseMetadataUtilsTestCase(TestCase): Returns (str): format_string + " " + str(date_time) """ if format_string in ['DATE_TIME', 'TIME', 'SHORT_DATE', 'LONG_DATE']: - return format_string + " " + str(date_time) + return format_string + " " + date_time.strftime("%Y-%m-%d %H:%M:%S") else: raise ValueError("Invalid format string :" + format_string) test_datetime = datetime(1945, 02, 06, 04, 20, 00, tzinfo=UTC()) advertised_start_parsable = "2038-01-19 03:14:07" + advertised_start_bad_date = "215-01-01 10:10:10" advertised_start_unparsable = "This coming fall" FunctionTest = namedtuple('FunctionTest', 'function scenarios') # pylint: disable=invalid-name @@ -114,10 +115,12 @@ class CourseMetadataUtilsTestCase(TestCase): function_tests = [ FunctionTest(clean_course_key, [ + # Test with a Mongo course and '=' as padding. TestScenario( (self.demo_course.id, '='), "course_MVSFQL2EMVWW6WBOGEXUMYLMNRPTEMBRGQ======" ), + # Test with a Split course and '~' as padding. TestScenario( (self.html_course.id, '~'), "course_MNXXK4TTMUWXMMJ2KVXGS5TFOJZWS5DZLAVUGUZNGIYDGK2ZGIYDSNQ~" @@ -128,7 +131,9 @@ class CourseMetadataUtilsTestCase(TestCase): TestScenario((self.html_course.location,), self.html_course.location.name), ]), FunctionTest(display_name_with_default, [ + # Test course with no display name. TestScenario((self.demo_course,), "Empty"), + # Test course with a display name that contains characters that need escaping. TestScenario((self.html_course,), "Intro to <html>"), ]), FunctionTest(number_for_course_location, [ @@ -150,18 +155,34 @@ class CourseMetadataUtilsTestCase(TestCase): TestScenario((DEFAULT_START_DATE, None), True), ]), FunctionTest(course_start_datetime_text, [ + # Test parsable advertised start date. + # Expect start datetime to be parsed and formatted back into a string. TestScenario( (DEFAULT_START_DATE, advertised_start_parsable, 'DATE_TIME', ugettext, mock_strftime_localized), mock_strftime_localized(Date().from_json(advertised_start_parsable), 'DATE_TIME') + " UTC" ), + # Test un-parsable advertised start date. + # Expect date parsing to throw a ValueError, and the advertised + # start to be returned in Title Case. TestScenario( (test_datetime, advertised_start_unparsable, 'DATE_TIME', ugettext, mock_strftime_localized), advertised_start_unparsable.title() ), + # Test parsable advertised start date from before January 1, 1900. + # Expect mock_strftime_localized to throw a ValueError, and the + # advertised start to be returned in Title Case. + TestScenario( + (test_datetime, advertised_start_bad_date, 'DATE_TIME', ugettext, mock_strftime_localized), + advertised_start_bad_date.title() + ), + # Test without advertised start date, but with a set start datetime. + # Expect formatted datetime to be returned. TestScenario( (test_datetime, None, 'SHORT_DATE', ugettext, mock_strftime_localized), mock_strftime_localized(test_datetime, 'SHORT_DATE') ), + # Test without advertised start date and with default start datetime. + # Expect TBD to be returned. TestScenario( (DEFAULT_START_DATE, None, 'SHORT_DATE', ugettext, mock_strftime_localized), # Translators: TBD stands for 'To Be Determined' and is used when a course @@ -170,10 +191,14 @@ class CourseMetadataUtilsTestCase(TestCase): ) ]), FunctionTest(course_end_datetime_text, [ + # Test with a set end datetime. + # Expect formatted datetime to be returned. TestScenario( (test_datetime, 'TIME', mock_strftime_localized), mock_strftime_localized(test_datetime, 'TIME') + " UTC" ), + # Test with default end datetime. + # Expect empty string to be returned. TestScenario( (None, 'TIME', mock_strftime_localized), "" From 0442e70072c91ec33b80d87fb66c31eda6bd9cc1 Mon Sep 17 00:00:00 2001 From: Muhammad Shoaib Date: Fri, 26 Jun 2015 00:35:24 +0500 Subject: [PATCH 59/97] PHX-40 added the sorting of the most popular coupon codes used. --- lms/djangoapps/shoppingcart/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index d4a007d028..083cc602a9 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1419,7 +1419,7 @@ class CouponRedemption(models.Model): """ return cls.objects.filter(order__status='purchased', coupon__course_id=course_id).values( 'coupon__code', 'coupon__percentage_discount' - ).annotate(coupon__used_count=Count('coupon__code')) + ).annotate(coupon__used_count=Count('coupon__code')).order_by('-coupon__used_count') @classmethod def get_total_coupon_code_purchases(cls, course_id): From 24af22ca8848f4097206f07cc164b9ca7c00eb40 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 23 Jun 2015 18:01:03 -0700 Subject: [PATCH 60/97] Remove restrictions on library creation in Studio --- cms/djangoapps/contentstore/tests/test_libraries.py | 8 ++++---- cms/djangoapps/contentstore/views/course.py | 1 + cms/djangoapps/contentstore/views/library.py | 5 +---- .../contentstore/views/tests/test_library.py | 6 +++--- cms/templates/index.html | 12 ++++++------ 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 691af12c39..c28be47d45 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -523,13 +523,13 @@ class TestLibraryAccess(SignalDisconnectTestMixin, LibraryTestCase): self.client.logout() self._assert_cannot_create_library(expected_code=302) # 302 redirect to login expected - # Now create a non-staff user with no permissions: + # Now check that logged-in users without CourseCreator role can still create libraries self._login_as_non_staff_user(logout_first=False) self.assertFalse(CourseCreatorRole().has_user(self.non_staff_user)) - - # Now check that logged-in users without any permissions cannot create libraries with patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}): - self._assert_cannot_create_library() + lib_key2 = self._create_library(library="lib2", display_name="Test Library 2") + library2 = modulestore().get_library(lib_key2) + self.assertIsNotNone(library2) @ddt.data( CourseInstructorRole, diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index becfcfffdf..d94be16f5b 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -458,6 +458,7 @@ def course_listing(request): 'in_process_course_actions': in_process_course_actions, 'libraries_enabled': LIBRARIES_ENABLED, 'libraries': [format_library_for_view(lib) for lib in libraries], + 'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active, 'user': request.user, 'request_course_creator_url': reverse('contentstore.views.request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 12987bfe0b..7d0fcf63ba 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -30,7 +30,7 @@ from .component import get_component_templates, CONTAINER_TEMPLATES from student.auth import ( STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access ) -from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, LibraryUserRole +from student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole from student import auth from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest @@ -115,9 +115,6 @@ def _create_library(request): """ Helper method for creating a new library. """ - if not auth.has_access(request.user, CourseCreatorRole()): - log.exception(u"User %s tried to create a library without permission", request.user.username) - raise PermissionDenied() display_name = None try: display_name = request.json['display_name'] diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index a36ad45015..9e1abbbdf0 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -87,8 +87,8 @@ class UnitTestLibraries(ModuleStoreTestCase): @patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}) def test_lib_create_permission(self): """ - Users who aren't given course creator roles shouldn't be able to create - libraries either. + Users who are not given course creator roles should still be able to + create libraries. """ self.client.logout() ns_user, password = self.create_non_staff_user() @@ -97,7 +97,7 @@ class UnitTestLibraries(ModuleStoreTestCase): response = self.client.ajax_post(LIBRARY_REST_URL, { 'org': 'org', 'library': 'lib', 'display_name': "New Library", }) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) @ddt.data( {}, diff --git a/cms/templates/index.html b/cms/templates/index.html index ec93fe8b92..c4ca6e0685 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -24,13 +24,13 @@ % if course_creator_status=='granted': ${_("New Course")} - % if libraries_enabled: - - ${_("New Library")} - % endif % elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''): ${_("Email staff to create course")} % endif + % if show_new_library_button: + + ${_("New Library")} + % endif @@ -449,7 +449,7 @@
- %if course_creator_status == "granted": + % if show_new_library_button:

${_('Create Your First Library')}

@@ -464,7 +464,7 @@
- %endif + % endif
%endif From 62742952a2e4889b05bb1a5882c3fec2c739adf6 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 25 Jun 2015 13:04:45 -0700 Subject: [PATCH 61/97] Make credit provider callback CSRF exempt --- openedx/core/djangoapps/credit/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openedx/core/djangoapps/credit/views.py b/openedx/core/djangoapps/credit/views.py index 976477616d..8956580b20 100644 --- a/openedx/core/djangoapps/credit/views.py +++ b/openedx/core/djangoapps/credit/views.py @@ -13,6 +13,7 @@ from django.http import ( Http404 ) from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt from django.conf import settings from opaque_keys.edx.keys import CourseKey @@ -127,6 +128,7 @@ def create_credit_request(request, provider_id): @require_POST +@csrf_exempt def credit_provider_callback(request, provider_id): """ Callback end-point used by credit providers to approve or reject From da982341e725744d7922bc2ad13af2a538e2dd6a Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 25 Jun 2015 13:18:30 -0700 Subject: [PATCH 62/97] Accept string-encoded timestamps --- common/djangoapps/util/date_utils.py | 2 +- openedx/core/djangoapps/credit/tests/test_views.py | 9 +++++++++ openedx/core/djangoapps/credit/views.py | 6 ++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/util/date_utils.py b/common/djangoapps/util/date_utils.py index fde0bfee2c..e9c4f905d3 100644 --- a/common/djangoapps/util/date_utils.py +++ b/common/djangoapps/util/date_utils.py @@ -89,7 +89,7 @@ def from_timestamp(timestamp): If the timestamp cannot be converted, returns None instead. """ try: - return datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + return datetime.utcfromtimestamp(int(timestamp)).replace(tzinfo=UTC) except (ValueError, TypeError): return None diff --git a/openedx/core/djangoapps/credit/tests/test_views.py b/openedx/core/djangoapps/credit/tests/test_views.py index d454009ace..0ca8bc7f88 100644 --- a/openedx/core/djangoapps/credit/tests/test_views.py +++ b/openedx/core/djangoapps/credit/tests/test_views.py @@ -190,6 +190,15 @@ class CreditProviderViewTests(UrlResetMixin, TestCase): response = self._credit_provider_callback(request_uuid, "approved", timestamp=timestamp) self.assertEqual(response.status_code, 403) + def test_credit_provider_callback_handles_string_timestamp(self): + request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) + + # Simulate a callback from the credit provider with a timestamp + # encoded as a string instead of an integer. + timestamp = str(to_timestamp(datetime.datetime.now(pytz.UTC))) + response = self._credit_provider_callback(request_uuid, "approved", timestamp=timestamp) + self.assertEqual(response.status_code, 200) + def test_credit_provider_callback_is_idempotent(self): request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) diff --git a/openedx/core/djangoapps/credit/views.py b/openedx/core/djangoapps/credit/views.py index 8956580b20..9e4506f361 100644 --- a/openedx/core/djangoapps/credit/views.py +++ b/openedx/core/djangoapps/credit/views.py @@ -152,8 +152,9 @@ def credit_provider_callback(request, provider_id): * status (string): Either "approved" or "rejected". - * timestamp (int): The datetime at which the POST request was made, represented + * timestamp (int or string): The datetime at which the POST request was made, represented as the number of seconds since January 1, 1970 00:00:00 UTC. + If the timestamp is a string, it will be converted to an integer. * signature (string): A digital signature of the request parameters, created using a secret key shared with the credit provider. @@ -259,7 +260,8 @@ def _validate_timestamp(timestamp_value, provider_id): Check that the timestamp of the request is recent. Arguments: - timestamp (int): Number of seconds since Jan. 1, 1970 UTC. + timestamp (int or string): Number of seconds since Jan. 1, 1970 UTC. + If specified as a string, it will be converted to an integer. provider_id (unicode): Identifier for the credit provider. Returns: From 0e2865e420b3f25d7292239e930ff9eb7df3083f Mon Sep 17 00:00:00 2001 From: Jolyon Bloomfield Date: Fri, 3 Apr 2015 14:24:53 -0400 Subject: [PATCH 63/97] Making background pictures printable --- lms/static/sass/base/_reset.scss | 3 ++- lms/static/sass/course/_info.scss | 8 ++++++++ lms/static/sass/course/_tabs.scss | 1 + lms/static/sass/course/courseware/_courseware.scss | 2 ++ lms/static/sass/course/layout/_courseware_header.scss | 4 ++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lms/static/sass/base/_reset.scss b/lms/static/sass/base/_reset.scss index bc39b2e7b5..78a8cb4393 100644 --- a/lms/static/sass/base/_reset.scss +++ b/lms/static/sass/base/_reset.scss @@ -84,7 +84,8 @@ td { vertical-align: top; } .clearfix { *zoom: 1; } @media print { - * { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } + * { background: transparent; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } + html, body { background: transparent !important; } a, a:visited { text-decoration: underline; } abbr[title]:after { content: " (" attr(title) ")"; } .ir a:after { content: ""; } diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index 692a6dcabe..0f10e4e7c1 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -236,6 +236,14 @@ div.info-wrapper { } } } + + @media print { + background: transparent !important; + } + } + + @media print { + background: transparent !important; } @media print { diff --git a/lms/static/sass/course/_tabs.scss b/lms/static/sass/course/_tabs.scss index d4a659833a..f16fc1df3d 100644 --- a/lms/static/sass/course/_tabs.scss +++ b/lms/static/sass/course/_tabs.scss @@ -12,5 +12,6 @@ div.static_tab_wrapper { @media print { border: 0; + background: transparent !important; } } \ No newline at end of file diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index 476d3f6e1f..9548715201 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -295,6 +295,7 @@ div.course-wrapper { @media print { padding: 0 2mm; + background: transparent !important; } } @@ -333,6 +334,7 @@ div.course-wrapper { @media print { border: 0; + background: transparent !important; } } diff --git a/lms/static/sass/course/layout/_courseware_header.scss b/lms/static/sass/course/layout/_courseware_header.scss index bc523891e7..4b01542e8c 100644 --- a/lms/static/sass/course/layout/_courseware_header.scss +++ b/lms/static/sass/course/layout/_courseware_header.scss @@ -193,4 +193,8 @@ header.global.slim { font-weight: bold; letter-spacing: 0; } + + @media print { + background: transparent !important; + } } From a25f91da691c8ce37eb2854cc6ebee73d848cdd3 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 25 Jun 2015 14:00:27 -0400 Subject: [PATCH 64/97] Fix XBlock inheritance ordering. XBlockMixin and derivatives should always come after XBlock and derivatives --- common/lib/xmodule/xmodule/course_module.py | 2 +- .../lib/xmodule/xmodule/modulestore/tests/test_mongo.py | 2 +- .../xmodule/modulestore/tests/test_xml_importer.py | 2 +- common/lib/xmodule/xmodule/video_module/video_module.py | 4 ++-- common/lib/xmodule/xmodule/video_module/video_xfields.py | 2 +- common/lib/xmodule/xmodule/x_module.py | 8 ++++---- requirements/edx/github.txt | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 832791ba59..5a5066c344 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -919,7 +919,7 @@ class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-me """ -class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): +class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): """ The descriptor for the course XModule """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 4996449c4f..8d8f28b0d8 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -60,7 +60,7 @@ DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor' RENDER_TEMPLATE = lambda t_n, d, ctx=None, nsp='main': '' -class ReferenceTestXBlock(XBlock, XModuleMixin): +class ReferenceTestXBlock(XModuleMixin): """ Test xblock type to test the reference field types """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml_importer.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml_importer.py index b2b9558cf7..c39bbf4534 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml_importer.py @@ -101,7 +101,7 @@ def render_to_template_mock(*args): pass -class StubXBlock(XBlock, XModuleMixin, InheritanceMixin): +class StubXBlock(XModuleMixin, InheritanceMixin): """ Stub XBlock used for testing. """ diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 72c8c82aba..e6c79e51b4 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -86,7 +86,7 @@ log = logging.getLogger(__name__) _ = lambda text: text -class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule): +class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule, LicenseMixin): """ XML source example:
%endif -
diff --git a/common/lib/xmodule/xmodule/js/fixtures/matlabinput_problem.html b/common/lib/xmodule/xmodule/js/fixtures/matlabinput_problem.html new file mode 100644 index 0000000000..5dad82c727 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/matlabinput_problem.html @@ -0,0 +1,43 @@ +
+
+
+ +

+

+
+
+ + +
+ + processing + + + + +

processing

+
+ + + +
+ Submitted. As soon as a response is returned, this message will be replaced by that feedback. +
+
+ +
+ +
+ +
+ + +
+
+
+
+ + + +
+
diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee index 892864f631..4e5b59e161 100644 --- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee @@ -628,3 +628,31 @@ describe 'Problem', -> it 'check_save_waitfor should return false', -> $(@problem.inputs[0]).data('waitfor', ->) expect(@problem.check_save_waitfor()).toEqual(false) + + describe 'Submitting an xqueue-graded problem', -> + matlabinput_html = readFixtures('matlabinput_problem.html') + + beforeEach -> + spyOn($, 'postWithPrefix').andCallFake (url, callback) -> + callback html: matlabinput_html + jasmine.Clock.useMock() + @problem = new Problem($('.xblock-student_view')) + spyOn(@problem, 'poll').andCallThrough() + @problem.render(matlabinput_html) + + it 'check that we stop polling after a fixed amount of time', -> + expect(@problem.poll).not.toHaveBeenCalled() + jasmine.Clock.tick(1) + time_steps = [1000, 2000, 4000, 8000, 16000, 32000] + num_calls = 1 + for time_step in time_steps + do (time_step) => + jasmine.Clock.tick(time_step) + expect(@problem.poll.callCount).toEqual(num_calls) + num_calls += 1 + + # jump the next step and verify that we are not still continuing to poll + jasmine.Clock.tick(64000) + expect(@problem.poll.callCount).toEqual(6) + + expect($('.capa_alert').text()).toEqual("The grading process is still running. Refresh the page to see updates.") diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 139654e5e5..12bb66e020 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -98,19 +98,11 @@ class @Problem if @num_queued_items > 0 if window.queuePollerID # Only one poller 'thread' per Problem window.clearTimeout(window.queuePollerID) - queuelen = @get_queuelen() - window.queuePollerID = window.setTimeout(@poll, queuelen*10) + window.queuePollerID = window.setTimeout( + => @poll(1000), + 1000) - # Retrieves the minimum queue length of all queued items - get_queuelen: => - minlen = Infinity - @queued_items.each (index, qitem) -> - len = parseInt($.text(qitem)) - if len < minlen - minlen = len - return minlen - - poll: => + poll: (prev_timeout) => $.postWithPrefix "#{@url}/problem_get", (response) => # If queueing status changed, then render @new_queued_items = $(response.html).find(".xqueue") @@ -125,8 +117,16 @@ class @Problem @forceUpdate response delete window.queuePollerID else - # TODO: Some logic to dynamically adjust polling rate based on queuelen - window.queuePollerID = window.setTimeout(@poll, 1000) + new_timeout = prev_timeout * 2 + # if the timeout is greather than 1 minute + if new_timeout >= 60000 + delete window.queuePollerID + @gentle_alert gettext("The grading process is still running. Refresh the page to see updates.") + else + window.queuePollerID = window.setTimeout( + => @poll(new_timeout), + new_timeout + ) # Use this if you want to make an ajax call on the input type object From caca3e1bdf1c5790a260ac10fb2c6ed6f7f68337 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sun, 3 May 2015 19:13:44 -0700 Subject: [PATCH 81/97] SAML2 third_party_auth provider(s) - PR 8018 --- common/djangoapps/student/views.py | 9 +- .../djangoapps/third_party_auth/pipeline.py | 67 +++++---- .../djangoapps/third_party_auth/provider.py | 127 +++++++++++++++++- common/djangoapps/third_party_auth/saml.py | 21 +++ .../third_party_auth/tests/specs/base.py | 4 +- .../third_party_auth/tests/test_pipeline.py | 2 +- .../tests/test_pipeline_integration.py | 30 +++-- .../third_party_auth/tests/test_provider.py | 31 +++-- common/djangoapps/third_party_auth/urls.py | 3 +- common/djangoapps/third_party_auth/views.py | 20 +++ common/lib/safe_lxml/safe_lxml/etree.py | 2 +- common/lib/xmodule/xmodule/x_module.py | 1 + .../student_account/test/test_views.py | 2 +- lms/djangoapps/student_account/views.py | 6 +- lms/envs/aws.py | 19 +++ .../_dashboard_third_party_error.html | 2 +- .../student_profile/third_party_auth.html | 2 +- openedx/core/djangoapps/user_api/views.py | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/github.txt | 3 + requirements/system/ubuntu/apt-packages.txt | 2 + 21 files changed, 283 insertions(+), 74 deletions(-) create mode 100644 common/djangoapps/third_party_auth/saml.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index bd2e9bc58d..19f576b56a 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -424,7 +424,7 @@ def register_user(request, extra_context=None): # selected provider. if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) + current_provider = provider.Registry.get_from_pipeline(running_pipeline) overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) overrides['running_pipeline'] = running_pipeline overrides['selected_provider'] = current_provider.NAME @@ -952,10 +952,11 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un running_pipeline = pipeline.get(request) username = running_pipeline['kwargs'].get('username') backend_name = running_pipeline['backend'] - requested_provider = provider.Registry.get_by_backend_name(backend_name) + third_party_uid = running_pipeline['kwargs']['uid'] + requested_provider = provider.Registry.get_from_pipeline(running_pipeline) try: - user = pipeline.get_authenticated_user(username, backend_name) + user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid) third_party_auth_successful = True except User.DoesNotExist: AUDIT_LOG.warning( @@ -1509,7 +1510,7 @@ def create_account_with_params(request, params): provider_name = None if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) + current_provider = provider.Registry.get_from_pipeline(running_pipeline) provider_name = current_provider.NAME analytics.track( diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 7c0ad27c08..e5c489a6fe 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -196,9 +196,11 @@ class ProviderUserState(object): lms/templates/dashboard.html. """ - def __init__(self, enabled_provider, user, state): + def __init__(self, enabled_provider, user, association_id=None): + # UserSocialAuth row ID + self.association_id = association_id # Boolean. Whether the user has an account associated with the provider - self.has_account = state + self.has_account = association_id is not None # provider.BaseProvider child. Callers must verify that the provider is # enabled. self.provider = enabled_provider @@ -215,7 +217,7 @@ def get(request): return request.session.get('partial_pipeline') -def get_authenticated_user(username, backend_name): +def get_authenticated_user(auth_provider, username, uid): """Gets a saved user authenticated by a particular backend. Between pipeline steps User objects are not saved. We need to reconstitute @@ -224,26 +226,26 @@ def get_authenticated_user(username, backend_name): authenticate(). Args: + auth_provider: the third_party_auth provider in use for the current pipeline. username: string. Username of user to get. - backend_name: string. The name of the third-party auth backend from - the running pipeline. + uid: string. The user ID according to the third party. Returns: User if user is found and has a social auth from the passed - backend_name. + provider. Raises: User.DoesNotExist: if no user matching user is found, or the matching user has no social auth associated with the given backend. AssertionError: if the user is not authenticated. """ - user = models.DjangoStorage.user.user_model().objects.get(username=username) - match = models.DjangoStorage.user.get_social_auth_for_user(user, provider=backend_name) + match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.BACKEND_CLASS.name, uid=uid) - if not match: + if not match or match.user.username != username: raise User.DoesNotExist - user.backend = provider.Registry.get_by_backend_name(backend_name).get_authentication_backend() + user = match.user + user.backend = auth_provider.get_authentication_backend() return user @@ -257,10 +259,12 @@ def _get_enabled_provider_by_name(provider_name): return enabled_provider -def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None): +def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, + extra_params=None, url_params=None): """Creates a URL to hook into social auth endpoints.""" - kwargs = {'backend': backend_name} - url = reverse(view_name, kwargs=kwargs) + url_params = url_params or {} + url_params['backend'] = backend_name + url = reverse(view_name, kwargs=url_params) query_params = OrderedDict() if auth_entry: @@ -269,6 +273,9 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None): if redirect_url: query_params[AUTH_REDIRECT_KEY] = redirect_url + if extra_params: + query_params.update(extra_params) + return u"{url}?{params}".format( url=url, params=urllib.urlencode(query_params) @@ -288,29 +295,32 @@ def get_complete_url(backend_name): Raises: ValueError: if no provider is enabled with the given backend_name. """ - enabled_provider = provider.Registry.get_by_backend_name(backend_name) - - if not enabled_provider: + if not any(provider.Registry.get_enabled_by_backend_name(backend_name)): raise ValueError('Provider with backend %s not enabled' % backend_name) return _get_url('social:complete', backend_name) -def get_disconnect_url(provider_name): +def get_disconnect_url(provider_name, association_id): """Gets URL for the endpoint that starts the disconnect pipeline. Args: provider_name: string. Name of the provider.BaseProvider child you want to disconnect from. + association_id: int. Optional ID of a specific row in the UserSocialAuth + table to disconnect (useful if multiple providers use a common backend) Returns: String. URL that starts the disconnection pipeline. Raises: - ValueError: if no provider is enabled with the given backend_name. + ValueError: if no provider is enabled with the given name. """ - enabled_provider = _get_enabled_provider_by_name(provider_name) - return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name) + backend_name = _get_enabled_provider_by_name(provider_name).BACKEND_CLASS.name + if association_id: + return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id}) + else: + return _get_url('social:disconnect', backend_name) def get_login_url(provider_name, auth_entry, redirect_url=None): @@ -340,6 +350,7 @@ def get_login_url(provider_name, auth_entry, redirect_url=None): enabled_provider.BACKEND_CLASS.name, auth_entry=auth_entry, redirect_url=redirect_url, + extra_params=enabled_provider.get_url_params(), ) @@ -355,7 +366,7 @@ def get_duplicate_provider(messages): unfortunately not in a reusable constant. Returns: - provider.BaseProvider child instance. The provider of the duplicate + string name of the python-social-auth backend that has the duplicate account, or None if there is no duplicate (and hence no error). """ social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')] @@ -364,7 +375,8 @@ def get_duplicate_provider(messages): return assert len(social_auth_messages) == 1 - return provider.Registry.get_by_backend_name(social_auth_messages[0].extra_tags.split()[1]) + backend_name = social_auth_messages[0].extra_tags.split()[1] + return backend_name def get_provider_user_states(user): @@ -378,13 +390,16 @@ def get_provider_user_states(user): each enabled provider. """ states = [] - found_user_backends = [ - social_auth.provider for social_auth in models.DjangoStorage.user.get_social_auth_for_user(user) - ] + found_user_auths = list(models.DjangoStorage.user.get_social_auth_for_user(user)) for enabled_provider in provider.Registry.enabled(): + association_id = None + for auth in found_user_auths: + if enabled_provider.match_social_auth(auth): + association_id = auth.id + break states.append( - ProviderUserState(enabled_provider, user, enabled_provider.BACKEND_CLASS.name in found_user_backends) + ProviderUserState(enabled_provider, user, association_id) ) return states diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 9f0809d42a..155748fa28 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -5,6 +5,8 @@ invoke the Django armature. """ from social.backends import google, linkedin, facebook +from social.backends.saml import OID_EDU_PERSON_PRINCIPAL_NAME +from .saml import SAMLAuthBackend _DEFAULT_ICON_CLASS = 'fa-signin' @@ -109,6 +111,21 @@ class BaseProvider(object): for key, value in cls.SETTINGS.iteritems(): setattr(settings, key, value) + @classmethod + def get_url_params(cls): + """ Get a dict of GET parameters to append to login links for this provider """ + return {} + + @classmethod + def is_active_for_pipeline(cls, pipeline): + """ Is this provider being used for the specified pipeline? """ + return cls.BACKEND_CLASS.name == pipeline['backend'] + + @classmethod + def match_social_auth(cls, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + return cls.BACKEND_CLASS.name == social_auth.provider + class GoogleOauth2(BaseProvider): """Provider for Google's Oauth2 auth system.""" @@ -146,6 +163,78 @@ class FacebookOauth2(BaseProvider): } +class SAMLProviderMixin(object): + """ Base class for SAML/Shibboleth providers """ + BACKEND_CLASS = SAMLAuthBackend + ICON_CLASS = 'fa-university' + + @classmethod + def get_url_params(cls): + """ Get a dict of GET parameters to append to login links for this provider """ + return {'idp': cls.IDP["id"]} + + @classmethod + def is_active_for_pipeline(cls, pipeline): + """ Is this provider being used for the specified pipeline? """ + if cls.BACKEND_CLASS.name == pipeline['backend']: + idp_name = pipeline['kwargs']['response']['idp_name'] + return cls.IDP["id"] == idp_name + return False + + @classmethod + def match_social_auth(cls, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + prefix = cls.IDP["id"] + ":" + return cls.BACKEND_CLASS.name == social_auth.provider and social_auth.uid.startswith(prefix) + + +class TestShibAProvider(SAMLProviderMixin, BaseProvider): + """ Provider for testshib.org public Shibboleth test server. """ + NAME = 'TestShib A' + IDP = { + "id": "testshiba", # Required slug + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", + "attr_email": OID_EDU_PERSON_PRINCIPAL_NAME, + "x509cert": """ + MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV + MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD + VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4 + MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI + EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl + c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B + AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C + yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe + 3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT + NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614 + kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH + gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G + A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86 + 9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl + bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo + aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN + BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL + I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo + 93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4 + /SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj + Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr + 8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA== + """ + } + + +class TestShibBProvider(SAMLProviderMixin, BaseProvider): + """ Provider for testshib.org public Shibboleth test server. """ + NAME = 'TestShib B' + IDP = { + "id": "testshibB", # Required slug + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://IDP.TESTSHIB.ORG/idp/profile/SAML2/Redirect/SSO", + "attr_email": OID_EDU_PERSON_PRINCIPAL_NAME, + "x509cert": TestShibAProvider.IDP["x509cert"], + } + + class Registry(object): """Singleton registry of third-party auth providers. @@ -211,13 +300,39 @@ class Registry(object): return cls._ENABLED.get(provider_name) @classmethod - def get_by_backend_name(cls, backend_name): - """Gets provider (or None) by backend name. + def get_from_pipeline(cls, running_pipeline): + """Gets the provider that is being used for the specified pipeline (or None). Args: - backend_name: string. The python-social-auth - backends.base.BaseAuth.name (for example, 'google-oauth2') to - try and get a provider for. + running_pipeline: The python-social-auth pipeline being used to + authenticate a user. + + Returns: + A provider class (a subclass of BaseProvider) or None. + + Raises: + RuntimeError: if the registry has not been configured. + """ + cls._check_configured() + for enabled in cls._ENABLED.values(): + if enabled.is_active_for_pipeline(running_pipeline): + return enabled + + @classmethod + def get_enabled_by_backend_name(cls, backend_name): + """Generator returning all enabled providers that use the specified + backend. + + Example: + >>> list(get_enabled_by_backend_name("tpa-saml")) + [TestShibAProvider, TestShibBProvider] + + Args: + backend_name: The name of a python-social-auth backend used by + one or more providers. + + Yields: + Provider classes (subclasses of BaseProvider). Raises: RuntimeError: if the registry has not been configured. @@ -225,7 +340,7 @@ class Registry(object): cls._check_configured() for enabled in cls._ENABLED.values(): if enabled.BACKEND_CLASS.name == backend_name: - return enabled + yield enabled @classmethod def _reset(cls): diff --git a/common/djangoapps/third_party_auth/saml.py b/common/djangoapps/third_party_auth/saml.py new file mode 100644 index 0000000000..78106f7080 --- /dev/null +++ b/common/djangoapps/third_party_auth/saml.py @@ -0,0 +1,21 @@ +""" +Slightly customized python-social-auth backend for SAML 2.0 support +""" + +from social.backends.saml import SAMLIdentityProvider, SAMLAuth + + +class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method + """ + Customized version of SAMLAuth that gets the list of IdPs from third_party_auth's list of + enabled providers. + """ + name = "tpa-saml" + + def get_idp(self, idp_name): + """ Given the name of an IdP, get a SAMLIdentityProvider instance """ + from .provider import Registry # Import here to avoid circular import + for provider in Registry.enabled(): + if issubclass(provider.BACKEND_CLASS, SAMLAuth) and provider.IDP["id"] == idp_name: + return SAMLIdentityProvider(idp_name, **provider.IDP) + raise KeyError("SAML IdP {} not found.".format(idp_name)) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 25e060c099..ea90c8d659 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -115,12 +115,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase): """Asserts the user's account settings page context is in the expected state. If duplicate is True, we expect context['duplicate_provider'] to contain - the duplicate provider object. If linked is passed, we conditionally + the duplicate provider backend name. If linked is passed, we conditionally check that the provider is included in context['auth']['providers'] and its connected state is correct. """ if duplicate: - self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME) + self.assertEqual(context['duplicate_provider'], self.PROVIDER_CLASS.BACKEND_CLASS.name) else: self.assertIsNone(context['duplicate_provider']) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline.py b/common/djangoapps/third_party_auth/tests/test_pipeline.py index 66c11d9043..462f24e4b2 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline.py @@ -38,5 +38,5 @@ class ProviderUserStateTestCase(testutil.TestCase): """Tests ProviderUserState behavior.""" def test_get_unlink_form_name(self): - state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), False) + state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), 1000) self.assertEqual(provider.GoogleOauth2.NAME + '_unlink_form', state.get_unlink_form_name()) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 8d1f3b7019..e6181fac61 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -41,16 +41,16 @@ class GetAuthenticatedUserTestCase(TestCase): def test_raises_does_not_exist_if_user_missing(self): with self.assertRaises(models.User.DoesNotExist): - pipeline.get_authenticated_user('new_' + self.user.username, 'backend') + pipeline.get_authenticated_user(self.enabled_provider, 'new_' + self.user.username, 'user@example.com') def test_raises_does_not_exist_if_user_found_but_no_association(self): backend_name = 'backend' self.assertIsNotNone(self.get_by_username(self.user.username)) - self.assertIsNone(provider.Registry.get_by_backend_name(backend_name)) + self.assertFalse(any(provider.Registry.get_enabled_by_backend_name(backend_name))) with self.assertRaises(models.User.DoesNotExist): - pipeline.get_authenticated_user(self.user.username, 'backend') + pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'user@example.com') def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self): self.assertIsNotNone(self.get_by_username(self.user.username)) @@ -58,11 +58,11 @@ class GetAuthenticatedUserTestCase(TestCase): self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name) with self.assertRaises(models.User.DoesNotExist): - pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name) + pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid') def test_returns_user_with_is_authenticated_and_backend_set_if_match(self): social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name) - user = pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name) + user = pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid') self.assertEqual(self.user, user) self.assertEqual(self.enabled_provider.get_authentication_backend(), user.backend) @@ -93,8 +93,9 @@ class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase): def test_states_for_enabled_providers_user_has_accounts_associated_with(self): provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) - social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name) - social_models.DjangoStorage.user.create_social_auth( + user_social_auth_google = social_models.DjangoStorage.user.create_social_auth( + self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name) + user_social_auth_linkedin = social_models.DjangoStorage.user.create_social_auth( self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name) states = pipeline.get_provider_user_states(self.user) @@ -106,10 +107,12 @@ class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase): self.assertTrue(google_state.has_account) self.assertEqual(provider.GoogleOauth2, google_state.provider) self.assertEqual(self.user, google_state.user) + self.assertEqual(user_social_auth_google.id, google_state.association_id) self.assertTrue(linkedin_state.has_account) self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) self.assertEqual(self.user, linkedin_state.user) + self.assertEqual(user_social_auth_linkedin.id, linkedin_state.association_id) def test_states_for_enabled_providers_user_has_no_account_associated_with(self): provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) @@ -155,13 +158,16 @@ class UrlFormationTestCase(TestCase): self.assertIsNone(provider.Registry.get(provider_name)) with self.assertRaises(ValueError): - pipeline.get_disconnect_url(provider_name) + pipeline.get_disconnect_url(provider_name, 1000) def test_disconnect_url_returns_expected_format(self): - disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME) - - self.assertTrue(disconnect_url.startswith('/auth/disconnect')) - self.assertIn(self.enabled_provider.BACKEND_CLASS.name, disconnect_url) + disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME, 1000) + disconnect_url = disconnect_url.rstrip('?') + self.assertEqual( + disconnect_url, + '/auth/disconnect/{backend}/{association_id}/'.format( + backend=self.enabled_provider.BACKEND_CLASS.name, association_id=1000) + ) def test_login_url_raises_value_error_if_provider_not_enabled(self): provider_name = 'not_enabled' diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py index 20120d7329..a1de2943bd 100644 --- a/common/djangoapps/third_party_auth/tests/test_provider.py +++ b/common/djangoapps/third_party_auth/tests/test_provider.py @@ -1,5 +1,6 @@ """Unit tests for provider.py.""" +from mock import Mock from third_party_auth import provider from third_party_auth.tests import testutil @@ -67,16 +68,22 @@ class RegistryTest(testutil.TestCase): provider.Registry.configure_once([]) self.assertIsNone(provider.Registry.get(provider.LinkedInOauth2.NAME)) - def test_get_by_backend_name_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.get_by_backend_name('') - - def test_get_by_backend_name_returns_enabled_provider(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - self.assertIs( - provider.GoogleOauth2, - provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) - - def test_get_by_backend_name_returns_none_if_provider_not_enabled(self): + def test_get_from_pipeline_returns_none_if_provider_not_enabled(self): provider.Registry.configure_once([]) - self.assertIsNone(provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) + self.assertIsNone(provider.Registry.get_from_pipeline(Mock())) + + def test_get_enabled_by_backend_name_raises_runtime_error_if_not_configured(self): + with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): + provider.Registry.get_enabled_by_backend_name('').next() + + def test_get_enabled_by_backend_name_returns_enabled_provider(self): + provider.Registry.configure_once([provider.GoogleOauth2.NAME]) + found = list(provider.Registry.get_enabled_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) + self.assertEqual(found, [provider.GoogleOauth2]) + + def test_get_enabled_by_backend_name_returns_none_if_provider_not_enabled(self): + provider.Registry.configure_once([]) + self.assertEqual( + [], + list(provider.Registry.get_enabled_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) + ) diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index b020e775b5..5d366b2da3 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -2,10 +2,11 @@ from django.conf.urls import include, patterns, url -from .views import inactive_user_view +from .views import inactive_user_view, saml_metadata_view urlpatterns = patterns( '', url(r'^auth/inactive', inactive_user_view), + url(r'^auth/saml/metadata.xml', saml_metadata_view), url(r'^auth/', include('social.apps.django_app.urls', namespace='social')), ) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index 5ae69db526..8f0c6bc3ba 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -1,7 +1,11 @@ """ Extra views required for SSO """ +from django.conf import settings +from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import redirect +from social.apps.django_app.utils import load_strategy, load_backend def inactive_user_view(request): @@ -13,3 +17,19 @@ def inactive_user_view(request): # in a course. Otherwise, just redirect them to the dashboard, which displays a message # about activating their account. return redirect(request.GET.get('next', 'dashboard')) + + +def saml_metadata_view(request): + """ + Get the Service Provider metadata for this edx-platform instance. + You must send this XML to any Shibboleth Identity Provider that you wish to use. + """ + complete_url = reverse('social:complete', args=("tpa-saml", )) + if settings.APPEND_SLASH and not complete_url.endswith('/'): + complete_url = complete_url + '/' # Required for consistency + saml_backend = load_backend(load_strategy(request), "tpa-saml", redirect_uri=complete_url) + metadata, errors = saml_backend.generate_metadata_xml() + + if not errors: + return HttpResponse(content=metadata, content_type='text/xml') + return HttpResponseServerError(content=', '.join(errors)) diff --git a/common/lib/safe_lxml/safe_lxml/etree.py b/common/lib/safe_lxml/safe_lxml/etree.py index 83052b22b6..97bc0b7547 100644 --- a/common/lib/safe_lxml/safe_lxml/etree.py +++ b/common/lib/safe_lxml/safe_lxml/etree.py @@ -9,7 +9,7 @@ For processing xml always prefer this over using lxml.etree directly. from lxml.etree import * # pylint: disable=wildcard-import, unused-wildcard-import from lxml.etree import XMLParser as _XMLParser -from lxml.etree import _ElementTree # pylint: disable=unused-import +from lxml.etree import _Element, _ElementTree # pylint: disable=unused-import, no-name-in-module # This should be imported after lxml.etree so that it overrides the following attributes. from defusedxml.lxml import parse, fromstring, XML diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 5f6ed8f8a0..4cb4ccfd40 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1754,6 +1754,7 @@ class CombinedSystem(object): integrate it into a larger whole. """ + context = context or {} if view_name in PREVIEW_VIEWS: block = self._get_student_block(block) diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 5c46647dfa..80cebeae70 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -432,7 +432,7 @@ class AccountSettingsViewTest(TestCase): context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) ) - self.assertEqual(context['duplicate_provider'].BACKEND_CLASS.name, 'facebook') + self.assertEqual(context['duplicate_provider'], 'facebook') self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') self.assertEqual(context['auth']['providers'][1]['name'], 'Google') diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index dc178285ff..284472c263 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -189,9 +189,7 @@ def _third_party_auth_context(request, redirect_to): running_pipeline = pipeline.get(request) if running_pipeline is not None: - current_provider = third_party_auth.provider.Registry.get_by_backend_name( - running_pipeline.get('backend') - ) + current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) context["currentProvider"] = current_provider.NAME context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.BACKEND_CLASS.name) @@ -382,7 +380,7 @@ def account_settings_context(request): ), # If the user is connected, sending a POST request to this url removes the connection # information for this provider from their edX account. - 'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME), + 'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME, state.association_id), } for state in auth_states] return context diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a4ae2dfb9b..07dd1474a6 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -541,6 +541,25 @@ THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) # The reduced session expiry time during the third party login pipeline. (Value in seconds) SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) +##### SAML configuration for third_party_auth ##### + +if 'SOCIAL_AUTH_TPA_SAML_SP_ENTITY_ID' in ENV_TOKENS: + SOCIAL_AUTH_TPA_SAML_SP_ENTITY_ID = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_ENTITY_ID') + SOCIAL_AUTH_TPA_SAML_SP_NAMEID_FORMAT = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_NAMEID_FORMAT', 'unspecified') + SOCIAL_AUTH_TPA_SAML_SP_EXTRA = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_EXTRA', {}) + SOCIAL_AUTH_TPA_SAML_ORG_INFO = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_ORG_INFO') + SOCIAL_AUTH_TPA_SAML_TECHNICAL_CONTACT = ENV_TOKENS.get( + 'SOCIAL_AUTH_TPA_SAML_TECHNICAL_CONTACT', + {"givenName": "Technical Support", "emailAddress": TECH_SUPPORT_EMAIL} + ) + SOCIAL_AUTH_TPA_SAML_SUPPORT_CONTACT = ENV_TOKENS.get( + 'SOCIAL_AUTH_TPA_SAML_SUPPORT_CONTACT', + {"givenName": "Support", "emailAddress": TECH_SUPPORT_EMAIL} + ) + SOCIAL_AUTH_TPA_SAML_SECURITY_CONFIG = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SECURITY_CONFIG', {}) + SOCIAL_AUTH_TPA_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_PUBLIC_CERT') + SOCIAL_AUTH_TPA_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_PRIVATE_KEY') + ##### OAUTH2 Provider ############## if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER'] diff --git a/lms/templates/dashboard/_dashboard_third_party_error.html b/lms/templates/dashboard/_dashboard_third_party_error.html index 99ba0ae4fb..a7958b9481 100644 --- a/lms/templates/dashboard/_dashboard_third_party_error.html +++ b/lms/templates/dashboard/_dashboard_third_party_error.html @@ -5,7 +5,7 @@

${_("Could Not Link Accounts")}

## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment. -

${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name='{duplicate_provider}'.format(duplicate_provider=duplicate_provider.NAME), platform_name=platform_name)}

+

${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}

diff --git a/lms/templates/student_profile/third_party_auth.html b/lms/templates/student_profile/third_party_auth.html index 051ecd19b2..28f0cb6fbf 100644 --- a/lms/templates/student_profile/third_party_auth.html +++ b/lms/templates/student_profile/third_party_auth.html @@ -22,7 +22,7 @@ from third_party_auth import pipeline ${state.provider.NAME} % if state.has_account: diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 79a119378e..25ac391124 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -720,7 +720,7 @@ class RegistrationView(APIView): if third_party_auth.is_enabled(): running_pipeline = third_party_auth.pipeline.get(request) if running_pipeline: - current_provider = third_party_auth.provider.Registry.get_by_backend_name(running_pipeline.get('backend')) + current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) # Override username / email / full name field_overrides = current_provider.get_register_form_data( diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 5ce6c3a4f0..e57c1b4d6d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -69,7 +69,7 @@ pyparsing==2.0.1 python-memcached==1.48 python-openid==2.2.5 python-dateutil==2.1 -python-social-auth==0.2.7 +# python-social-auth==0.2.7 was here but is temporarily moved to github.txt pytz==2015.2 pysrt==0.4.7 PyYAML==3.10 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 1f44442252..9c2613b53a 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -30,6 +30,9 @@ git+https://github.com/pmitros/pyfs.git@96e1922348bfe6d99201b9512a9ed946c87b7e0b git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c4aee6e76b9abe61cc808 # custom opaque-key implementations for ccx -e git+https://github.com/jazkarta/ccx-keys.git@e6b03704b1bb97c1d2f31301ecb4e3a687c536ea#egg=ccx-keys +# For SAML Support (To be moved to PyPi installation in base.txt once our changes are merged): +-e git+https://github.com/open-craft/python-saml.git@9602b8133056d8c3caa7c3038761147df3d4b257#egg=python-saml +-e git+https://github.com/open-craft/python-social-auth.git@17def186d4bb7165f9c37037936997ef39ae2f29#egg=python-social-auth # Our libraries: -e git+https://github.com/edx/XBlock.git@74fdc5a361f48e5596acf3846ca3790a33a05253#egg=XBlock diff --git a/requirements/system/ubuntu/apt-packages.txt b/requirements/system/ubuntu/apt-packages.txt index 6f130107da..1cf5e09eb3 100644 --- a/requirements/system/ubuntu/apt-packages.txt +++ b/requirements/system/ubuntu/apt-packages.txt @@ -36,3 +36,5 @@ mysql-client virtualenvwrapper libgeos-ruby1.8 lynx-cur +libxmlsec1-dev +swig From b4904adc1eb2234ee026c29376b5bde4d379d3ef Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 13 May 2015 23:53:25 -0700 Subject: [PATCH 82/97] Use ConfigurationModels for third_party_auth, new metadata fetching - PR 8155 --- common/djangoapps/student/helpers.py | 5 +- .../tests/test_login_registration_forms.py | 14 +- common/djangoapps/student/views.py | 8 +- common/djangoapps/third_party_auth/admin.py | 66 +++ common/djangoapps/third_party_auth/dummy.py | 17 +- .../third_party_auth/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/saml.py | 146 +++++++ .../migrations/0001_initial.py | 181 ++++++++ .../migrations/0002_convert_settings.py | 141 +++++++ .../third_party_auth/migrations/__init__.py | 0 common/djangoapps/third_party_auth/models.py | 385 ++++++++++++++++++ .../djangoapps/third_party_auth/pipeline.py | 32 +- .../djangoapps/third_party_auth/provider.py | 352 ++-------------- common/djangoapps/third_party_auth/saml.py | 40 +- .../djangoapps/third_party_auth/settings.py | 90 +--- .../djangoapps/third_party_auth/strategy.py | 34 ++ .../third_party_auth/tests/specs/base.py | 68 ++-- .../tests/specs/test_google.py | 13 +- .../tests/specs/test_linkedin.py | 13 +- .../third_party_auth/tests/test_pipeline.py | 7 +- .../tests/test_pipeline_integration.py | 93 +++-- .../third_party_auth/tests/test_provider.py | 115 +++--- .../third_party_auth/tests/test_settings.py | 47 +-- .../tests/test_settings_integration.py | 27 -- .../third_party_auth/tests/testutil.py | 78 +++- .../third_party_auth/tests/utils.py | 8 +- common/djangoapps/third_party_auth/views.py | 5 +- .../pages/lms/login_and_register.py | 2 +- .../tests/lms/test_account_settings.py | 9 +- common/test/acceptance/tests/lms/test_lms.py | 4 +- common/test/db_fixtures/third_party_auth.json | 47 +++ .../student_account/test/test_views.py | 22 +- lms/djangoapps/student_account/views.py | 23 +- lms/envs/aws.py | 36 +- lms/envs/bok_choy.auth.json | 11 - lms/envs/bok_choy.env.json | 8 +- lms/envs/common.py | 4 - lms/envs/devstack.py | 4 + lms/envs/test.py | 19 +- lms/startup.py | 2 +- .../account_settings_factory_spec.js | 2 + .../js/spec/student_account/login_spec.js | 6 +- .../js/spec/student_account/register_spec.js | 6 +- .../views/account_settings_factory.js | 2 +- lms/static/sass/multicourse/_account.scss | 12 +- lms/static/sass/views/_login-register.scss | 6 +- lms/templates/login.html | 2 +- lms/templates/register.html | 2 +- .../student_account/login.underscore | 2 +- .../student_account/register.underscore | 2 +- .../student_profile/third_party_auth.html | 6 +- .../djangoapps/user_api/tests/test_views.py | 5 +- requirements/edx/github.txt | 2 +- 54 files changed, 1468 insertions(+), 763 deletions(-) create mode 100644 common/djangoapps/third_party_auth/admin.py create mode 100644 common/djangoapps/third_party_auth/management/__init__.py create mode 100644 common/djangoapps/third_party_auth/management/commands/__init__.py create mode 100644 common/djangoapps/third_party_auth/management/commands/saml.py create mode 100644 common/djangoapps/third_party_auth/migrations/0001_initial.py create mode 100644 common/djangoapps/third_party_auth/migrations/0002_convert_settings.py create mode 100644 common/djangoapps/third_party_auth/migrations/__init__.py create mode 100644 common/djangoapps/third_party_auth/models.py create mode 100644 common/djangoapps/third_party_auth/strategy.py delete mode 100644 common/djangoapps/third_party_auth/tests/test_settings_integration.py create mode 100644 common/test/db_fixtures/third_party_auth.json diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 9ddfea424c..06abbe7706 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -196,8 +196,9 @@ def auth_pipeline_urls(auth_entry, redirect_url=None): return {} return { - provider.NAME: third_party_auth.pipeline.get_login_url(provider.NAME, auth_entry, redirect_url=redirect_url) - for provider in third_party_auth.provider.Registry.enabled() + provider.provider_id: third_party_auth.pipeline.get_login_url( + provider.provider_id, auth_entry, redirect_url=redirect_url + ) for provider in third_party_auth.provider.Registry.enabled() } diff --git a/common/djangoapps/student/tests/test_login_registration_forms.py b/common/djangoapps/student/tests/test_login_registration_forms.py index 3ebafb2358..3fa383b0a5 100644 --- a/common/djangoapps/student/tests/test_login_registration_forms.py +++ b/common/djangoapps/student/tests/test_login_registration_forms.py @@ -11,12 +11,12 @@ from django.core.urlresolvers import reverse from util.testing import UrlResetMixin from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import CourseModeFactory +from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -# This relies on third party auth being enabled and configured -# in the test settings. See the setting `THIRD_PARTY_AUTH` -# and the feature flag `ENABLE_THIRD_PARTY_AUTH` +# This relies on third party auth being enabled in the test +# settings with the feature flag `ENABLE_THIRD_PARTY_AUTH` THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"] THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"] @@ -40,7 +40,7 @@ def _finish_auth_url(params): @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): +class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """Test rendering of the login form. """ @patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False}) def setUp(self): @@ -50,6 +50,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): self.course = CourseFactory.create() self.course_id = unicode(self.course.id) self.courseware_url = reverse("courseware", args=[self.course_id]) + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @ddt.data(THIRD_PARTY_AUTH_PROVIDERS) @@ -148,7 +150,7 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase): +class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """Test rendering of the registration form. """ @patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False}) def setUp(self): @@ -157,6 +159,8 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase): self.url = reverse("register_user") self.course = CourseFactory.create() self.course_id = unicode(self.course.id) + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @ddt.data(*THIRD_PARTY_AUTH_PROVIDERS) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 19f576b56a..6a8d226421 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -427,7 +427,7 @@ def register_user(request, extra_context=None): current_provider = provider.Registry.get_from_pipeline(running_pipeline) overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) overrides['running_pipeline'] = running_pipeline - overrides['selected_provider'] = current_provider.NAME + overrides['selected_provider'] = current_provider.name context.update(overrides) return render_to_response('register.html', context) @@ -964,12 +964,12 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un username=username, backend_name=backend_name)) return HttpResponse( _("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format( - platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME + platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name ) + "

" + _("Use your {platform_name} username and password to log into {platform_name} below, " "and then link your {platform_name} account with {provider_name} from your dashboard.").format( - platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME + platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name ) + "

" + _("If you don't have an {platform_name} account yet, click Register Now at the top of the page.").format( @@ -1511,7 +1511,7 @@ def create_account_with_params(request, params): if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) current_provider = provider.Registry.get_from_pipeline(running_pipeline) - provider_name = current_provider.NAME + provider_name = current_provider.name analytics.track( user.id, diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py new file mode 100644 index 0000000000..d36ca9dd41 --- /dev/null +++ b/common/djangoapps/third_party_auth/admin.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +""" +Admin site configuration for third party authentication +""" + +from django.contrib import admin + +from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin +from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData + +admin.site.register(OAuth2ProviderConfig, KeyedConfigurationModelAdmin) + + +class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): + """ Django Admin class for SAMLProviderConfig """ + def get_list_display(self, request): + """ Don't show every single field in the admin change list """ + return ( + 'name', 'enabled', 'backend_name', 'entity_id', 'metadata_source', + 'has_data', 'icon_class', 'change_date', 'changed_by', 'edit_link' + ) + + def has_data(self, inst): + """ Do we have cached metadata for this SAML provider? """ + if not inst.is_active: + return None # N/A + data = SAMLProviderData.current(inst.entity_id) + return bool(data and data.is_valid()) + has_data.short_description = u'Metadata Ready' + has_data.boolean = True + +admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin) + + +class SAMLConfigurationAdmin(ConfigurationModelAdmin): + """ Django Admin class for SAMLConfiguration """ + def get_list_display(self, request): + """ Shorten the public/private keys in the change view """ + return ( + 'change_date', 'changed_by', 'enabled', 'entity_id', + 'org_info_str', 'key_summary', + ) + + def key_summary(self, inst): + """ Short summary of the key pairs configured """ + if not inst.public_key or not inst.private_key: + return u'Key pair incomplete/missing' + pub1, pub2 = inst.public_key[0:10], inst.public_key[-10:] + priv1, priv2 = inst.private_key[0:10], inst.private_key[-10:] + return u'Public: {}…{}
Private: {}…{}'.format(pub1, pub2, priv1, priv2) + key_summary.allow_tags = True + +admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin) + + +class SAMLProviderDataAdmin(admin.ModelAdmin): + """ Django Admin class for SAMLProviderData """ + list_display = ('entity_id', 'is_valid', 'fetched_at', 'expires_at', 'sso_url') + readonly_fields = ('is_valid', ) + + def get_readonly_fields(self, request, obj=None): + if obj: # editing an existing object + return self.model._meta.get_all_field_names() # pylint: disable=protected-access + return self.readonly_fields + +admin.site.register(SAMLProviderData, SAMLProviderDataAdmin) diff --git a/common/djangoapps/third_party_auth/dummy.py b/common/djangoapps/third_party_auth/dummy.py index 4c2aa2dc4f..6bd8f58c4b 100644 --- a/common/djangoapps/third_party_auth/dummy.py +++ b/common/djangoapps/third_party_auth/dummy.py @@ -1,13 +1,11 @@ """ -DummyProvider: A fake Third Party Auth provider for testing & development purposes. +DummyBackend: A fake Third Party Auth provider for testing & development purposes. """ -from social.backends.base import BaseAuth +from social.backends.oauth import BaseOAuth2 from social.exceptions import AuthFailed -from .provider import BaseProvider - -class DummyBackend(BaseAuth): # pylint: disable=abstract-method +class DummyBackend(BaseOAuth2): # pylint: disable=abstract-method """ python-social-auth backend that doesn't actually go to any third party site """ @@ -47,12 +45,3 @@ class DummyBackend(BaseAuth): # pylint: disable=abstract-method kwargs.update({'response': response, 'backend': self}) return self.strategy.authenticate(*args, **kwargs) - - -class DummyProvider(BaseProvider): - """ Dummy Provider for testing and development """ - - BACKEND_CLASS = DummyBackend - ICON_CLASS = 'fa-cube' - NAME = 'Dummy' - SETTINGS = {} diff --git a/common/djangoapps/third_party_auth/management/__init__.py b/common/djangoapps/third_party_auth/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/management/commands/__init__.py b/common/djangoapps/third_party_auth/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/management/commands/saml.py b/common/djangoapps/third_party_auth/management/commands/saml.py new file mode 100644 index 0000000000..9ef5c4dd27 --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/saml.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +""" +Management commands for third_party_auth +""" +import datetime +import dateutil.parser +from django.core.management.base import BaseCommand, CommandError +from lxml import etree +import requests +from onelogin.saml2.utils import OneLogin_Saml2_Utils +from third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData + +#pylint: disable=superfluous-parens,no-member + + +class MetadataParseError(Exception): + """ An error occurred while parsing the SAML metadata from an IdP """ + pass + + +class Command(BaseCommand): + """ manage.py commands to manage SAML/Shibboleth SSO """ + help = '''Configure/maintain/update SAML-based SSO''' + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("saml requires one argument: pull") + + if not SAMLConfiguration.is_enabled(): + self.stdout.write("Warning: SAML support is disabled via SAMLConfiguration.\n") + + subcommand = args[0] + + if subcommand == "pull": + self.cmd_pull() + else: + raise CommandError("Unknown argment: {}".format(subcommand)) + + @staticmethod + def tag_name(tag_name): + """ Get the namespaced-qualified name for an XML tag """ + return '{urn:oasis:names:tc:SAML:2.0:metadata}' + tag_name + + def cmd_pull(self): + """ Fetch the metadata for each provider and update the DB """ + # First make a list of all the metadata XML URLs: + url_map = {} + for idp_slug in SAMLProviderConfig.key_values('idp_slug', flat=True): + config = SAMLProviderConfig.current(idp_slug) + if not config.enabled: + continue + url = config.metadata_source + if url not in url_map: + url_map[url] = [] + if config.entity_id not in url_map[url]: + url_map[url].append(config.entity_id) + # Now fetch the metadata: + for url, entity_ids in url_map.items(): + try: + self.stdout.write("\n→ Fetching {}\n".format(url)) + if not url.lower().startswith('https'): + self.stdout.write("→ WARNING: This URL is not secure! It should use HTTPS.\n") + response = requests.get(url, verify=True) # May raise HTTPError or SSLError + response.raise_for_status() # May raise an HTTPError + + try: + parser = etree.XMLParser(remove_comments=True) + xml = etree.fromstring(response.text, parser) + except etree.XMLSyntaxError: + raise + # TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that + + for entity_id in entity_ids: + self.stdout.write("→ Processing IdP with entityID {}\n".format(entity_id)) + public_key, sso_url, expires_at = self._parse_metadata_xml(xml, entity_id) + self._update_data(entity_id, public_key, sso_url, expires_at) + except Exception as err: # pylint: disable=broad-except + self.stderr.write("→ ERROR: {}\n\n".format(err.message)) + + @classmethod + def _parse_metadata_xml(cls, xml, entity_id): + """ + Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of + (public_key, sso_url, expires_at) for the specified entityID. + + Raises MetadataParseError if anything is wrong. + """ + if xml.tag == cls.tag_name('EntityDescriptor'): + entity_desc = xml + else: + if xml.tag != cls.tag_name('EntitiesDescriptor'): + raise MetadataParseError("Expected root element to be , not {}".format(xml.tag)) + entity_desc = xml.find(".//{}[@entityID='{}']".format(cls.tag_name('EntityDescriptor'), entity_id)) + if not entity_desc: + raise MetadataParseError("Can't find EntityDescriptor for entityID {}".format(entity_id)) + + expires_at = None + if "validUntil" in xml.attrib: + expires_at = dateutil.parser.parse(xml.attrib["validUntil"]) + if "cacheDuration" in xml.attrib: + cache_expires = OneLogin_Saml2_Utils.parse_duration(xml.attrib["cacheDuration"]) + if expires_at is None or cache_expires < expires_at: + expires_at = cache_expires + + sso_desc = entity_desc.find(cls.tag_name("IDPSSODescriptor")) + if not sso_desc: + raise MetadataParseError("IDPSSODescriptor missing") + if 'urn:oasis:names:tc:SAML:2.0:protocol' not in sso_desc.get("protocolSupportEnumeration"): + raise MetadataParseError("This IdP does not support SAML 2.0") + + # Now we just need to get the public_key and sso_url + public_key = sso_desc.findtext("./{}//{}".format( + cls.tag_name("KeyDescriptor"), "{http://www.w3.org/2000/09/xmldsig#}X509Certificate" + )) + if not public_key: + raise MetadataParseError("Public Key missing. Expected an ") + public_key = public_key.replace(" ", "") + binding_elements = sso_desc.iterfind("./{}".format(cls.tag_name("SingleSignOnService"))) + sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements} + try: + # The only binding supported by python-saml and python-social-auth is HTTP-Redirect: + sso_url = sso_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + except KeyError: + raise MetadataParseError("Unable to find SSO URL with HTTP-Redirect binding.") + return public_key, sso_url, expires_at + + def _update_data(self, entity_id, public_key, sso_url, expires_at): + """ + Update/Create the SAMLProviderData for the given entity ID. + """ + data_obj = SAMLProviderData.current(entity_id) + fetched_at = datetime.datetime.now() + if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url): + data_obj.expires_at = expires_at + data_obj.fetched_at = fetched_at + data_obj.save() + self.stdout.write("→ Updated existing SAMLProviderData. Nothing has changed.\n") + else: + SAMLProviderData.objects.create( + entity_id=entity_id, + fetched_at=fetched_at, + expires_at=expires_at, + sso_url=sso_url, + public_key=public_key, + ) + self.stdout.write("→ Created new record for SAMLProviderData\n") diff --git a/common/djangoapps/third_party_auth/migrations/0001_initial.py b/common/djangoapps/third_party_auth/migrations/0001_initial.py new file mode 100644 index 0000000000..d4a13a6dc0 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0001_initial.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'OAuth2ProviderConfig' + db.create_table('third_party_auth_oauth2providerconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('backend_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('key', self.gf('django.db.models.fields.TextField')(blank=True)), + ('secret', self.gf('django.db.models.fields.TextField')(blank=True)), + ('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('third_party_auth', ['OAuth2ProviderConfig']) + + # Adding model 'SAMLProviderConfig' + db.create_table('third_party_auth_samlproviderconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('backend_name', self.gf('django.db.models.fields.CharField')(default='tpa-saml', max_length=50)), + ('idp_slug', self.gf('django.db.models.fields.SlugField')(max_length=30)), + ('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('metadata_source', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('attr_user_permanent_id', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_full_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_first_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_last_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_username', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_email', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('third_party_auth', ['SAMLProviderConfig']) + + # Adding model 'SAMLConfiguration' + db.create_table('third_party_auth_samlconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('private_key', self.gf('django.db.models.fields.TextField')()), + ('public_key', self.gf('django.db.models.fields.TextField')()), + ('entity_id', self.gf('django.db.models.fields.CharField')(default='http://saml.example.com', max_length=255)), + ('org_info_str', self.gf('django.db.models.fields.TextField')(default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}')), + ('other_config_str', self.gf('django.db.models.fields.TextField')(default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}')), + )) + db.send_create_signal('third_party_auth', ['SAMLConfiguration']) + + # Adding model 'SAMLProviderData' + db.create_table('third_party_auth_samlproviderdata', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('fetched_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('expires_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('sso_url', self.gf('django.db.models.fields.URLField')(max_length=200)), + ('public_key', self.gf('django.db.models.fields.TextField')()), + )) + db.send_create_signal('third_party_auth', ['SAMLProviderData']) + + + def backwards(self, orm): + # Deleting model 'OAuth2ProviderConfig' + db.delete_table('third_party_auth_oauth2providerconfig') + + # Deleting model 'SAMLProviderConfig' + db.delete_table('third_party_auth_samlproviderconfig') + + # Deleting model 'SAMLConfiguration' + db.delete_table('third_party_auth_samlconfiguration') + + # Deleting model 'SAMLProviderData' + db.delete_table('third_party_auth_samlproviderdata') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/migrations/0002_convert_settings.py b/common/djangoapps/third_party_auth/migrations/0002_convert_settings.py new file mode 100644 index 0000000000..a5c38bca81 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0002_convert_settings.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +from django.conf import settings +import json +from south.v2 import DataMigration + + +class Migration(DataMigration): + + def forwards(self, orm): + """ Convert from the THIRD_PARTY_AUTH setting to OAuth2ProviderConfig """ + tpa = getattr(settings, 'THIRD_PARTY_AUTH_OLD_CONFIG', {}) + if tpa and not any(orm.OAuth2ProviderConfig.objects.all()): + print("Migrating third party auth config to OAuth2ProviderConfig") + providers = ( + # Name, backend, icon, prefix + ('Google', 'google-oauth2', 'fa-google-plus', 'SOCIAL_AUTH_GOOGLE_OAUTH2_'), + ('LinkedIn', 'linkedin-oauth2', 'fa-linkedin', 'SOCIAL_AUTH_LINKEDIN_OAUTH2_'), + ('Facebook', 'facebook', 'fa-facebook', 'SOCIAL_AUTH_FACEBOOK_'), + ) + for name, backend, icon, prefix in providers: + if name in tpa: + conf = tpa[name] + conf = {key.replace(prefix, ''): val for key, val in conf.items()} + key = conf.pop('KEY', '') + secret = conf.pop('SECRET', '') + orm.OAuth2ProviderConfig.objects.create( + enabled=True, + name=name, + backend_name=backend, + icon_class=icon, + key=key, + secret=secret, + other_settings=json.dumps(conf), + changed_by=None, + ) + print( + "Done. Make changes via /admin/third_party_auth/oauth2providerconfig/ " + "from now on. You can remove THIRD_PARTY_AUTH from ~/lms.auth.json" + ) + else: + print("Not migrating third party auth config to OAuth2ProviderConfig.") + + def backwards(self, orm): + """ No backwards migration necessary """ + pass + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] + symmetrical = True diff --git a/common/djangoapps/third_party_auth/migrations/__init__.py b/common/djangoapps/third_party_auth/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py new file mode 100644 index 0000000000..411691b42a --- /dev/null +++ b/common/djangoapps/third_party_auth/models.py @@ -0,0 +1,385 @@ +# -*- coding: utf-8 -*- +""" +Models used to implement SAML SSO support in third_party_auth +(inlcuding Shibboleth support) +""" +from config_models.models import ConfigurationModel, cache +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +import json +import logging +from social.backends.base import BaseAuth +from social.backends.oauth import BaseOAuth2 +from social.backends.saml import SAMLAuth, SAMLIdentityProvider +from social.exceptions import SocialAuthBaseException +from social.utils import module_member + +log = logging.getLogger(__name__) + + +# A dictionary of {name: class} entries for each python-social-auth backend available. +# Because this setting can specify arbitrary code to load and execute, it is set via +# normal Django settings only and cannot be changed at runtime: +def _load_backend_classes(base_class=BaseAuth): + """ Load the list of python-social-auth backend classes from Django settings """ + for class_path in settings.AUTHENTICATION_BACKENDS: + auth_class = module_member(class_path) + if issubclass(auth_class, base_class): + yield auth_class +_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(BaseOAuth2)] +_PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)] + + +def clean_json(value, of_type): + """ Simple helper method to parse and clean JSON """ + if not value.strip(): + return json.dumps(of_type()) + try: + value_python = json.loads(value) + except ValueError as err: + raise ValidationError("Invalid JSON: {}".format(err.message)) + if not isinstance(value_python, of_type): + raise ValidationError("Expected a JSON {}".format(of_type)) + return json.dumps(value_python, indent=4) + + +class AuthNotConfigured(SocialAuthBaseException): + """ Exception when SAMLProviderData or other required info is missing """ + def __init__(self, provider_name): + super(AuthNotConfigured, self).__init__() + self.provider_name = provider_name + + def __str__(self): + return 'Authentication with {} is currently unavailable.'.format( + self.provider_name + ) + + +class ProviderConfig(ConfigurationModel): + """ + Abstract Base Class for configuring a third_party_auth provider + """ + icon_class = models.CharField( + max_length=50, default='fa-sign-in', + help_text=( + 'The Font Awesome (or custom) icon class to use on the login button for this provider. ' + 'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university' + )) + name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)") + 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 + # "enabled" field is inherited from ConfigurationModel + + class Meta(object): # pylint: disable=missing-docstring + abstract = True + + @property + def provider_id(self): + """ Unique string key identifying this provider. Must be URL and css class friendly. """ + assert self.prefix is not None + return "-".join((self.prefix, ) + tuple(getattr(self, field) for field in self.KEY_FIELDS)) + + @property + def backend_class(self): + """ Get the python-social-auth backend class used for this provider """ + return _PSA_BACKENDS[self.backend_name] + + def get_url_params(self): + """ Get a dict of GET parameters to append to login links for this provider """ + return {} + + def is_active_for_pipeline(self, pipeline): + """ Is this provider being used for the specified pipeline? """ + return self.backend_name == pipeline['backend'] + + def match_social_auth(self, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + return self.backend_name == social_auth.provider + + @classmethod + def get_register_form_data(cls, pipeline_kwargs): + """Gets dict of data to display on the register form. + + common.djangoapps.student.views.register_user uses this to populate the + new account creation form with values supplied by the user's chosen + provider, preventing duplicate data entry. + + Args: + pipeline_kwargs: dict of string -> object. Keyword arguments + accumulated by the pipeline thus far. + + Returns: + Dict of string -> string. Keys are names of form fields; values are + values for that field. Where there is no value, the empty string + must be used. + """ + # Details about the user sent back from the provider. + details = pipeline_kwargs.get('details') + + # Get the username separately to take advantage of the de-duping logic + # built into the pipeline. The provider cannot de-dupe because it can't + # check the state of taken usernames in our system. Note that there is + # technically a data race between the creation of this value and the + # creation of the user object, so it is still possible for users to get + # an error on submit. + suggested_username = pipeline_kwargs.get('username') + + return { + 'email': details.get('email', ''), + 'name': details.get('fullname', ''), + 'username': suggested_username, + } + + def get_authentication_backend(self): + """Gets associated Django settings.AUTHENTICATION_BACKEND string.""" + return '{}.{}'.format(self.backend_class.__module__, self.backend_class.__name__) + + +class OAuth2ProviderConfig(ProviderConfig): + """ + Configuration Entry for an OAuth2 based provider. + """ + prefix = 'oa2' + KEY_FIELDS = ('backend_name', ) # Backend name is unique + backend_name = models.CharField( + max_length=50, choices=[(name, name) for name in _PSA_OAUTH2_BACKENDS], blank=False, db_index=True, + help_text=( + "Which python-social-auth OAuth2 provider backend to use. " + "The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting." + # To be precise, it's set by AUTHENTICATION_BACKENDS - which aws.py sets from THIRD_PARTY_AUTH_BACKENDS + ) + ) + key = models.TextField(blank=True, verbose_name="Client ID") + secret = models.TextField(blank=True, verbose_name="Client Secret") + other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.") + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "Provider Configuration (OAuth2)" + verbose_name_plural = verbose_name + + def clean(self): + """ Standardize and validate fields """ + super(OAuth2ProviderConfig, self).clean() + self.other_settings = clean_json(self.other_settings, dict) + + def get_setting(self, name): + """ Get the value of a setting, or raise KeyError """ + if name in ("KEY", "SECRET"): + return getattr(self, name.lower()) + if self.other_settings: + other_settings = json.loads(self.other_settings) + assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)" + return other_settings[name] + raise KeyError + + +class SAMLProviderConfig(ProviderConfig): + """ + Configuration Entry for a SAML/Shibboleth provider. + """ + prefix = 'saml' + KEY_FIELDS = ('idp_slug', ) + backend_name = models.CharField( + max_length=50, default='tpa-saml', choices=[(name, name) for name in _PSA_SAML_BACKENDS], blank=False, + help_text="Which python-social-auth provider backend to use. 'tpa-saml' is the standard edX SAML backend.") + idp_slug = models.SlugField( + max_length=30, db_index=True, + help_text=( + 'A short string uniquely identifying this provider. ' + 'Cannot contain spaces and should be a usable as a CSS class. Examples: "ubc", "mit-staging"' + )) + entity_id = models.CharField( + max_length=255, verbose_name="Entity ID", help_text="Example: https://idp.testshib.org/idp/shibboleth") + metadata_source = models.CharField( + max_length=255, + help_text=( + "URL to this provider's XML metadata. Should be an HTTPS URL. " + "Example: https://www.testshib.org/metadata/testshib-providers.xml" + )) + attr_user_permanent_id = models.CharField( + max_length=128, blank=True, verbose_name="User ID Attribute", + help_text="URN of the SAML attribute that we can use as a unique, persistent user ID. Leave blank for default.") + attr_full_name = models.CharField( + max_length=128, blank=True, verbose_name="Full Name Attribute", + help_text="URN of SAML attribute containing the user's full name. Leave blank for default.") + attr_first_name = models.CharField( + max_length=128, blank=True, verbose_name="First Name Attribute", + help_text="URN of SAML attribute containing the user's first name. Leave blank for default.") + attr_last_name = models.CharField( + max_length=128, blank=True, verbose_name="Last Name Attribute", + help_text="URN of SAML attribute containing the user's last name. Leave blank for default.") + attr_username = models.CharField( + max_length=128, blank=True, verbose_name="Username Hint Attribute", + help_text="URN of SAML attribute to use as a suggested username for this user. Leave blank for default.") + attr_email = models.CharField( + max_length=128, blank=True, verbose_name="Email Attribute", + help_text="URN of SAML attribute containing the user's email address[es]. Leave blank for default.") + other_settings = models.TextField( + verbose_name="Advanced settings", blank=True, + help_text=( + 'For advanced use cases, enter a JSON object with addtional configuration. ' + 'The tpa-saml backend supports only {"requiredEntitlements": ["urn:..."]} ' + 'which can be used to require the presence of a specific eduPersonEntitlement.' + )) + + def clean(self): + """ Standardize and validate fields """ + super(SAMLProviderConfig, self).clean() + self.other_settings = clean_json(self.other_settings, dict) + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "Provider Configuration (SAML IdP)" + verbose_name_plural = "Provider Configuration (SAML IdPs)" + + def get_url_params(self): + """ Get a dict of GET parameters to append to login links for this provider """ + return {'idp': self.idp_slug} + + def is_active_for_pipeline(self, pipeline): + """ Is this provider being used for the specified pipeline? """ + return self.backend_name == pipeline['backend'] and self.idp_slug == pipeline['kwargs']['response']['idp_name'] + + def match_social_auth(self, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + prefix = self.idp_slug + ":" + return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix) + + def get_config(self): + """ + Return a SAMLIdentityProvider instance for use by SAMLAuthBackend. + + Essentially this just returns the values of this object and its + associated 'SAMLProviderData' entry. + """ + if self.other_settings: + conf = json.loads(self.other_settings) + else: + conf = {} + attrs = ( + 'attr_user_permanent_id', 'attr_full_name', 'attr_first_name', + 'attr_last_name', 'attr_username', 'attr_email', 'entity_id') + for field in attrs: + val = getattr(self, field) + if val: + conf[field] = val + # Now get the data fetched automatically from the metadata.xml: + data = SAMLProviderData.current(self.entity_id) + if not data or not data.is_valid(): + log.error("No SAMLProviderData found for %s. Run 'manage.py saml pull' to fix or debug.", self.entity_id) + raise AuthNotConfigured(provider_name=self.name) + conf['x509cert'] = data.public_key + conf['url'] = data.sso_url + return SAMLIdentityProvider(self.idp_slug, **conf) + + +class SAMLConfiguration(ConfigurationModel): + """ + General configuration required for this edX instance to act as a SAML + Service Provider and allow users to authenticate via third party SAML + Identity Providers (IdPs) + """ + private_key = models.TextField( + help_text=( + 'To generate a key pair as two files, run ' + '"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". ' + 'Paste the contents of saml.key here.' + ) + ) + public_key = models.TextField(help_text="Public key certificate.") + entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID") + org_info_str = models.TextField( + verbose_name="Organization Info", + default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}', + help_text="JSON dictionary of 'url', 'displayname', and 'name' for each language", + ) + other_config_str = models.TextField( + default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}', + help_text=( + "JSON object defining advanced settings that are passed on to python-saml. " + "Valid keys that can be set here include: SECURITY_CONFIG, SP_NAMEID_FORMATS, SP_EXTRA" + ), + ) + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "SAML Configuration" + verbose_name_plural = verbose_name + + def clean(self): + """ Standardize and validate fields """ + super(SAMLConfiguration, self).clean() + self.org_info_str = clean_json(self.org_info_str, dict) + self.other_config_str = clean_json(self.other_config_str, dict) + + self.private_key = self.private_key.replace("-----BEGIN PRIVATE KEY-----", "").strip() + self.private_key = self.private_key.replace("-----END PRIVATE KEY-----", "").strip() + self.public_key = self.public_key.replace("-----BEGIN CERTIFICATE-----", "").strip() + self.public_key = self.public_key.replace("-----END CERTIFICATE-----", "").strip() + + def get_setting(self, name): + """ Get the value of a setting, or raise KeyError """ + if name == "ORG_INFO": + return json.loads(self.org_info_str) + if name == "SP_ENTITY_ID": + return self.entity_id + if name == "SP_PUBLIC_CERT": + return self.public_key + if name == "SP_PRIVATE_KEY": + return self.private_key + if name == "TECHNICAL_CONTACT": + return {"givenName": "Technical Support", "emailAddress": settings.TECH_SUPPORT_EMAIL} + if name == "SUPPORT_CONTACT": + return {"givenName": "SAML Support", "emailAddress": settings.TECH_SUPPORT_EMAIL} + other_config = json.loads(self.other_config_str) + return other_config[name] # SECURITY_CONFIG, SP_NAMEID_FORMATS, SP_EXTRA + + +class SAMLProviderData(models.Model): + """ + Data about a SAML IdP that is fetched automatically by 'manage.py saml pull' + + This data is only required during the actual authentication process. + """ + cache_timeout = 600 + fetched_at = models.DateTimeField(db_index=True, null=False) + expires_at = models.DateTimeField(db_index=True, null=True) + + entity_id = models.CharField(max_length=255, db_index=True) # This is the key for lookups in this table + sso_url = models.URLField(verbose_name="SSO URL") + public_key = models.TextField() + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "SAML Provider Data" + verbose_name_plural = verbose_name + ordering = ('-fetched_at', ) + + def is_valid(self): + """ Is this data valid? """ + if self.expires_at and timezone.now() > self.expires_at: + return False + return bool(self.entity_id and self.sso_url and self.public_key) + is_valid.boolean = True + + @classmethod + def cache_key_name(cls, entity_id): + """ Return the name of the key to use to cache the current data """ + return 'configuration/{}/current/{}'.format(cls.__name__, entity_id) + + @classmethod + def current(cls, entity_id): + """ + Return the active data entry, if any, otherwise None + """ + cached = cache.get(cls.cache_key_name(entity_id)) + if cached is not None: + return cached + + try: + current = cls.objects.filter(entity_id=entity_id).order_by('-fetched_at')[0] + except IndexError: + current = None + + cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout) + return current diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index e5c489a6fe..c3d18b7b45 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -209,7 +209,7 @@ class ProviderUserState(object): def get_unlink_form_name(self): """Gets the name used in HTML forms that unlink a provider account.""" - return self.provider.NAME + '_unlink_form' + return self.provider.provider_id + '_unlink_form' def get(request): @@ -239,7 +239,7 @@ def get_authenticated_user(auth_provider, username, uid): user has no social auth associated with the given backend. AssertionError: if the user is not authenticated. """ - match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.BACKEND_CLASS.name, uid=uid) + match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.backend_name, uid=uid) if not match or match.user.username != username: raise User.DoesNotExist @@ -249,12 +249,12 @@ def get_authenticated_user(auth_provider, username, uid): return user -def _get_enabled_provider_by_name(provider_name): - """Gets an enabled provider by its NAME member or throws.""" - enabled_provider = provider.Registry.get(provider_name) +def _get_enabled_provider(provider_id): + """Gets an enabled provider by its provider_id member or throws.""" + enabled_provider = provider.Registry.get(provider_id) if not enabled_provider: - raise ValueError('Provider %s not enabled' % provider_name) + raise ValueError('Provider %s not enabled' % provider_id) return enabled_provider @@ -301,11 +301,11 @@ def get_complete_url(backend_name): return _get_url('social:complete', backend_name) -def get_disconnect_url(provider_name, association_id): +def get_disconnect_url(provider_id, association_id): """Gets URL for the endpoint that starts the disconnect pipeline. Args: - provider_name: string. Name of the provider.BaseProvider child you want + provider_id: string identifier of the models.ProviderConfig child you want to disconnect from. association_id: int. Optional ID of a specific row in the UserSocialAuth table to disconnect (useful if multiple providers use a common backend) @@ -314,21 +314,21 @@ def get_disconnect_url(provider_name, association_id): String. URL that starts the disconnection pipeline. Raises: - ValueError: if no provider is enabled with the given name. + ValueError: if no provider is enabled with the given ID. """ - backend_name = _get_enabled_provider_by_name(provider_name).BACKEND_CLASS.name + backend_name = _get_enabled_provider(provider_id).backend_name if association_id: return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id}) else: return _get_url('social:disconnect', backend_name) -def get_login_url(provider_name, auth_entry, redirect_url=None): +def get_login_url(provider_id, auth_entry, redirect_url=None): """Gets the login URL for the endpoint that kicks off auth with a provider. Args: - provider_name: string. The name of the provider.Provider that has been - enabled. + provider_id: string identifier of the models.ProviderConfig child you want + to disconnect from. auth_entry: string. Query argument specifying the desired entry point for the auth pipeline. Used by the pipeline for later branching. Must be one of _AUTH_ENTRY_CHOICES. @@ -341,13 +341,13 @@ def get_login_url(provider_name, auth_entry, redirect_url=None): String. URL that starts the auth pipeline for a provider. Raises: - ValueError: if no provider is enabled with the given provider_name. + ValueError: if no provider is enabled with the given provider_id. """ assert auth_entry in _AUTH_ENTRY_CHOICES - enabled_provider = _get_enabled_provider_by_name(provider_name) + enabled_provider = _get_enabled_provider(provider_id) return _get_url( 'social:begin', - enabled_provider.BACKEND_CLASS.name, + enabled_provider.backend_name, auth_entry=auth_entry, redirect_url=redirect_url, extra_params=enabled_provider.get_url_params(), diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 155748fa28..415e670900 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -1,303 +1,46 @@ -"""Third-party auth provider definitions. - -Loaded by Django's settings mechanism. Consequently, this module must not -invoke the Django armature. """ - -from social.backends import google, linkedin, facebook -from social.backends.saml import OID_EDU_PERSON_PRINCIPAL_NAME -from .saml import SAMLAuthBackend - -_DEFAULT_ICON_CLASS = 'fa-signin' - - -class BaseProvider(object): - """Abstract base class for third-party auth providers. - - All providers must subclass BaseProvider -- otherwise, they cannot be put - in the provider Registry. - """ - - # Class. The provider's backing social.backends.base.BaseAuth child. - BACKEND_CLASS = None - # String. Name of the FontAwesome glyph to use for sign in buttons (or the - # name of a user-supplied custom glyph that is present at runtime). - ICON_CLASS = _DEFAULT_ICON_CLASS - # String. User-facing name of the provider. Must be unique across all - # enabled providers. Will be presented in the UI. - NAME = None - # Dict of string -> object. Settings that will be merged into Django's - # settings instance. In most cases the value will be None, since real - # values are merged from .json files (foo.auth.json; foo.env.json) onto the - # settings instance during application initialization. - SETTINGS = {} - - @classmethod - def get_authentication_backend(cls): - """Gets associated Django settings.AUTHENTICATION_BACKEND string.""" - return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__) - - @classmethod - def get_email(cls, provider_details): - """Gets user's email address. - - Provider responses can contain arbitrary data. This method can be - overridden to extract an email address from the provider details - extracted by the social_details pipeline step. - - Args: - provider_details: dict of string -> string. Data about the - user passed back by the provider. - - Returns: - String or None. The user's email address, if any. - """ - return provider_details.get('email') - - @classmethod - def get_name(cls, provider_details): - """Gets user's name. - - Provider responses can contain arbitrary data. This method can be - overridden to extract a full name for a user from the provider details - extracted by the social_details pipeline step. - - Args: - provider_details: dict of string -> string. Data about the - user passed back by the provider. - - Returns: - String or None. The user's full name, if any. - """ - return provider_details.get('fullname') - - @classmethod - def get_register_form_data(cls, pipeline_kwargs): - """Gets dict of data to display on the register form. - - common.djangoapps.student.views.register_user uses this to populate the - new account creation form with values supplied by the user's chosen - provider, preventing duplicate data entry. - - Args: - pipeline_kwargs: dict of string -> object. Keyword arguments - accumulated by the pipeline thus far. - - Returns: - Dict of string -> string. Keys are names of form fields; values are - values for that field. Where there is no value, the empty string - must be used. - """ - # Details about the user sent back from the provider. - details = pipeline_kwargs.get('details') - - # Get the username separately to take advantage of the de-duping logic - # built into the pipeline. The provider cannot de-dupe because it can't - # check the state of taken usernames in our system. Note that there is - # technically a data race between the creation of this value and the - # creation of the user object, so it is still possible for users to get - # an error on submit. - suggested_username = pipeline_kwargs.get('username') - - return { - 'email': cls.get_email(details) or '', - 'name': cls.get_name(details) or '', - 'username': suggested_username, - } - - @classmethod - def merge_onto(cls, settings): - """Merge class-level settings onto a django settings module.""" - for key, value in cls.SETTINGS.iteritems(): - setattr(settings, key, value) - - @classmethod - def get_url_params(cls): - """ Get a dict of GET parameters to append to login links for this provider """ - return {} - - @classmethod - def is_active_for_pipeline(cls, pipeline): - """ Is this provider being used for the specified pipeline? """ - return cls.BACKEND_CLASS.name == pipeline['backend'] - - @classmethod - def match_social_auth(cls, social_auth): - """ Is this provider being used for this UserSocialAuth entry? """ - return cls.BACKEND_CLASS.name == social_auth.provider - - -class GoogleOauth2(BaseProvider): - """Provider for Google's Oauth2 auth system.""" - - BACKEND_CLASS = google.GoogleOAuth2 - ICON_CLASS = 'fa-google-plus' - NAME = 'Google' - SETTINGS = { - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None, - 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None, - } - - -class LinkedInOauth2(BaseProvider): - """Provider for LinkedIn's Oauth2 auth system.""" - - BACKEND_CLASS = linkedin.LinkedinOAuth2 - ICON_CLASS = 'fa-linkedin' - NAME = 'LinkedIn' - SETTINGS = { - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None, - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None, - } - - -class FacebookOauth2(BaseProvider): - """Provider for LinkedIn's Oauth2 auth system.""" - - BACKEND_CLASS = facebook.FacebookOAuth2 - ICON_CLASS = 'fa-facebook' - NAME = 'Facebook' - SETTINGS = { - 'SOCIAL_AUTH_FACEBOOK_KEY': None, - 'SOCIAL_AUTH_FACEBOOK_SECRET': None, - } - - -class SAMLProviderMixin(object): - """ Base class for SAML/Shibboleth providers """ - BACKEND_CLASS = SAMLAuthBackend - ICON_CLASS = 'fa-university' - - @classmethod - def get_url_params(cls): - """ Get a dict of GET parameters to append to login links for this provider """ - return {'idp': cls.IDP["id"]} - - @classmethod - def is_active_for_pipeline(cls, pipeline): - """ Is this provider being used for the specified pipeline? """ - if cls.BACKEND_CLASS.name == pipeline['backend']: - idp_name = pipeline['kwargs']['response']['idp_name'] - return cls.IDP["id"] == idp_name - return False - - @classmethod - def match_social_auth(cls, social_auth): - """ Is this provider being used for this UserSocialAuth entry? """ - prefix = cls.IDP["id"] + ":" - return cls.BACKEND_CLASS.name == social_auth.provider and social_auth.uid.startswith(prefix) - - -class TestShibAProvider(SAMLProviderMixin, BaseProvider): - """ Provider for testshib.org public Shibboleth test server. """ - NAME = 'TestShib A' - IDP = { - "id": "testshiba", # Required slug - "entity_id": "https://idp.testshib.org/idp/shibboleth", - "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", - "attr_email": OID_EDU_PERSON_PRINCIPAL_NAME, - "x509cert": """ - MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV - MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD - VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4 - MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI - EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl - c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B - AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C - yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe - 3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT - NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614 - kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH - gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G - A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86 - 9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl - bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo - aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN - BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL - I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo - 93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4 - /SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj - Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr - 8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA== - """ - } - - -class TestShibBProvider(SAMLProviderMixin, BaseProvider): - """ Provider for testshib.org public Shibboleth test server. """ - NAME = 'TestShib B' - IDP = { - "id": "testshibB", # Required slug - "entity_id": "https://idp.testshib.org/idp/shibboleth", - "url": "https://IDP.TESTSHIB.ORG/idp/profile/SAML2/Redirect/SSO", - "attr_email": OID_EDU_PERSON_PRINCIPAL_NAME, - "x509cert": TestShibAProvider.IDP["x509cert"], - } +Third-party auth provider configuration API. +""" +from .models import ( + OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, + _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS +) class Registry(object): - """Singleton registry of third-party auth providers. - - Providers must subclass BaseProvider in order to be usable in the registry. """ + API for querying third-party auth ProviderConfig objects. - _CONFIGURED = False - _ENABLED = {} - + Providers must subclass ProviderConfig in order to be usable in the registry. + """ @classmethod - def _check_configured(cls): - """Ensures registry is configured.""" - if not cls._CONFIGURED: - raise RuntimeError('Registry not configured') - - @classmethod - def _get_all(cls): - """Gets all provider implementations loaded into the Python runtime.""" - # BaseProvider does so have __subclassess__. pylint: disable-msg=no-member - return {klass.NAME: klass for klass in BaseProvider.__subclasses__()} - - @classmethod - def _enable(cls, provider): - """Enables a single provider.""" - if provider.NAME in cls._ENABLED: - raise ValueError('Provider %s already enabled' % provider.NAME) - cls._ENABLED[provider.NAME] = provider - - @classmethod - def configure_once(cls, provider_names): - """Configures providers. - - Args: - provider_names: list of string. The providers to configure. - - Raises: - ValueError: if the registry has already been configured, or if any - of the passed provider_names does not have a corresponding - BaseProvider child implementation. - """ - if cls._CONFIGURED: - raise ValueError('Provider registry already configured') - - # Flip the bit eagerly -- configure() should not be re-callable if one - # _enable call fails. - cls._CONFIGURED = True - for name in provider_names: - all_providers = cls._get_all() - if name not in all_providers: - raise ValueError('No implementation found for provider ' + name) - cls._enable(all_providers.get(name)) + def _enabled_providers(cls): + """ Helper method to iterate over all providers """ + for backend_name in _PSA_OAUTH2_BACKENDS: + provider = OAuth2ProviderConfig.current(backend_name) + if provider.enabled: + yield provider + if SAMLConfiguration.is_enabled(): + idp_slugs = SAMLProviderConfig.key_values('idp_slug', flat=True) + for idp_slug in idp_slugs: + provider = SAMLProviderConfig.current(idp_slug) + if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS: + yield provider @classmethod def enabled(cls): """Returns list of enabled providers.""" - cls._check_configured() - return sorted(cls._ENABLED.values(), key=lambda provider: provider.NAME) + return sorted(cls._enabled_providers(), key=lambda provider: provider.name) @classmethod - def get(cls, provider_name): - """Gets provider named provider_name string if enabled, else None.""" - cls._check_configured() - return cls._ENABLED.get(provider_name) + def get(cls, provider_id): + """Gets provider by provider_id string if enabled, else None.""" + if '-' not in provider_id: # Check format - see models.py:ProviderConfig + raise ValueError("Invalid provider_id. Expect something like oa2-google") + try: + return next(provider for provider in cls._enabled_providers() if provider.provider_id == provider_id) + except StopIteration: + return None @classmethod def get_from_pipeline(cls, running_pipeline): @@ -308,13 +51,9 @@ class Registry(object): authenticate a user. Returns: - A provider class (a subclass of BaseProvider) or None. - - Raises: - RuntimeError: if the registry has not been configured. + An instance of ProviderConfig or None. """ - cls._check_configured() - for enabled in cls._ENABLED.values(): + for enabled in cls._enabled_providers(): if enabled.is_active_for_pipeline(running_pipeline): return enabled @@ -325,25 +64,22 @@ class Registry(object): Example: >>> list(get_enabled_by_backend_name("tpa-saml")) - [TestShibAProvider, TestShibBProvider] + [, ] Args: backend_name: The name of a python-social-auth backend used by one or more providers. Yields: - Provider classes (subclasses of BaseProvider). - - Raises: - RuntimeError: if the registry has not been configured. + Instances of ProviderConfig. """ - cls._check_configured() - for enabled in cls._ENABLED.values(): - if enabled.BACKEND_CLASS.name == backend_name: - yield enabled - - @classmethod - def _reset(cls): - """Returns the registry to an unconfigured state; for tests only.""" - cls._CONFIGURED = False - cls._ENABLED = {} + if backend_name in _PSA_OAUTH2_BACKENDS: + provider = OAuth2ProviderConfig.current(backend_name) + if provider.enabled: + yield provider + elif backend_name in _PSA_SAML_BACKENDS and SAMLConfiguration.is_enabled(): + idp_names = SAMLProviderConfig.key_values('idp_slug', flat=True) + for idp_name in idp_names: + provider = SAMLProviderConfig.current(idp_name) + if provider.backend_name == backend_name and provider.enabled: + yield provider diff --git a/common/djangoapps/third_party_auth/saml.py b/common/djangoapps/third_party_auth/saml.py index 78106f7080..db40104b14 100644 --- a/common/djangoapps/third_party_auth/saml.py +++ b/common/djangoapps/third_party_auth/saml.py @@ -1,8 +1,11 @@ """ Slightly customized python-social-auth backend for SAML 2.0 support """ +import logging +from social.backends.saml import SAMLAuth, OID_EDU_PERSON_ENTITLEMENT +from social.exceptions import AuthForbidden -from social.backends.saml import SAMLIdentityProvider, SAMLAuth +log = logging.getLogger(__name__) class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method @@ -14,8 +17,33 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method def get_idp(self, idp_name): """ Given the name of an IdP, get a SAMLIdentityProvider instance """ - from .provider import Registry # Import here to avoid circular import - for provider in Registry.enabled(): - if issubclass(provider.BACKEND_CLASS, SAMLAuth) and provider.IDP["id"] == idp_name: - return SAMLIdentityProvider(idp_name, **provider.IDP) - raise KeyError("SAML IdP {} not found.".format(idp_name)) + from .models import SAMLProviderConfig + return SAMLProviderConfig.current(idp_name).get_config() + + def setting(self, name, default=None): + """ Get a setting, from SAMLConfiguration """ + if not hasattr(self, '_config'): + from .models import SAMLConfiguration + self._config = SAMLConfiguration.current() # pylint: disable=attribute-defined-outside-init + if not self._config.enabled: + from django.core.exceptions import ImproperlyConfigured + raise ImproperlyConfigured("SAML Authentication is not enabled.") + try: + return self._config.get_setting(name) + except KeyError: + return self.strategy.setting(name, default) + + def _check_entitlements(self, idp, attributes): + """ + Check if we require the presence of any specific eduPersonEntitlement. + + raise AuthForbidden if the user should not be authenticated, or do nothing + to allow the login pipeline to continue. + """ + if "requiredEntitlements" in idp.conf: + entitlements = attributes.get(OID_EDU_PERSON_ENTITLEMENT, []) + for expected in idp.conf['requiredEntitlements']: + if expected not in entitlements: + log.warning( + "SAML user from IdP %s rejected due to missing eduPersonEntitlement %s", idp.name, expected) + raise AuthForbidden(self) diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index e9468b7ce8..a856aefa4f 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -1,51 +1,15 @@ """Settings for the third-party auth module. -Defers configuration of settings so we can inspect the provider registry and -create settings placeholders for only those values actually needed by a given -deployment. Required by Django; consequently, this file must not invoke the -Django armature. - The flow for settings registration is: The base settings file contains a boolean, ENABLE_THIRD_PARTY_AUTH, indicating -whether this module is enabled. Ancillary settings files (aws.py, dev.py) put -options in THIRD_PARTY_SETTINGS. startup.py probes the ENABLE_THIRD_PARTY_AUTH. +whether this module is enabled. startup.py probes the ENABLE_THIRD_PARTY_AUTH. If true, it: a) loads this module. - b) calls apply_settings(), passing in settings.THIRD_PARTY_AUTH. - THIRD_PARTY AUTH is a dict of the form - - 'THIRD_PARTY_AUTH': { - '': { - '': '', - [...] - }, - [...] - } - - If you are using a dev settings file, your settings dict starts at the - level of and is a map of provider name string to - settings dict. If you are using an auth.json file, it should contain a - THIRD_PARTY_AUTH entry as above. - c) apply_settings() builds a list of . These are the - enabled third party auth providers for the deployment. These are enabled - in provider.Registry, the canonical list of enabled providers. - d) then, it sets global, provider-independent settings. - e) then, it sets provider-specific settings. For each enabled provider, we - read its SETTINGS member. These are merged onto the Django settings - object. In most cases these are stubs and the real values are set from - THIRD_PARTY_AUTH. All values that are set from this dict must first be - initialized from SETTINGS. This allows us to validate the dict and - ensure that the values match expected configuration options on the - provider. - f) finally, the (key, value) pairs from the dict file are merged onto the - django settings object. + b) calls apply_settings(), passing in the Django settings """ -from . import provider - - _FIELDS_STORED_IN_SESSION = ['auth_entry', 'next'] _MIDDLEWARE_CLASSES = ( 'third_party_auth.middleware.ExceptionMiddleware', @@ -53,25 +17,7 @@ _MIDDLEWARE_CLASSES = ( _SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' -def _merge_auth_info(django_settings, auth_info): - """Merge auth_info dict onto django_settings module.""" - enabled_provider_names = [] - to_merge = [] - - for provider_name, provider_dict in auth_info.items(): - enabled_provider_names.append(provider_name) - # Merge iff all settings have been intialized. - for key in provider_dict: - if key not in dir(django_settings): - raise ValueError('Auth setting %s not initialized' % key) - to_merge.append(provider_dict) - - for passed_validation in to_merge: - for key, value in passed_validation.iteritems(): - setattr(django_settings, key, value) - - -def _set_global_settings(django_settings): +def apply_settings(django_settings): """Set provider-independent settings.""" # Whitelisted URL query parameters retrained in the pipeline session. @@ -115,6 +61,9 @@ def _set_global_settings(django_settings): 'third_party_auth.pipeline.login_analytics', ) + # Required so that we can use unmodified PSA OAuth2 backends: + django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy' + # We let the user specify their email address during signup. django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email'] @@ -136,30 +85,3 @@ def _set_global_settings(django_settings): 'social.apps.django_app.context_processors.backends', 'social.apps.django_app.context_processors.login_redirect', ) - - -def _set_provider_settings(django_settings, enabled_providers, auth_info): - """Sets provider-specific settings.""" - # Must prepend here so we get called first. - django_settings.AUTHENTICATION_BACKENDS = ( - tuple(enabled_provider.get_authentication_backend() for enabled_provider in enabled_providers) + - django_settings.AUTHENTICATION_BACKENDS) - - # Merge settings from provider classes, and configure all placeholders. - for enabled_provider in enabled_providers: - enabled_provider.merge_onto(django_settings) - - # Merge settings from .auth.json, overwriting placeholders. - _merge_auth_info(django_settings, auth_info) - - -def apply_settings(auth_info, django_settings): - """Applies settings from auth_info dict to django_settings module.""" - if django_settings.FEATURES.get('ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'): - # The Dummy provider is handy for testing and development. - from .dummy import DummyProvider # pylint: disable=unused-variable - provider_names = auth_info.keys() - provider.Registry.configure_once(provider_names) - enabled_providers = provider.Registry.enabled() - _set_global_settings(django_settings) - _set_provider_settings(django_settings, enabled_providers, auth_info) diff --git a/common/djangoapps/third_party_auth/strategy.py b/common/djangoapps/third_party_auth/strategy.py new file mode 100644 index 0000000000..1d5134c6bd --- /dev/null +++ b/common/djangoapps/third_party_auth/strategy.py @@ -0,0 +1,34 @@ +""" +A custom Strategy for python-social-auth that allows us to fetch configuration from +ConfigurationModels rather than django.settings +""" +from .models import OAuth2ProviderConfig +from social.backends.oauth import BaseOAuth2 +from social.strategies.django_strategy import DjangoStrategy + + +class ConfigurationModelStrategy(DjangoStrategy): + """ + A DjangoStrategy customized to load settings from ConfigurationModels + for upstream python-social-auth backends that we cannot otherwise modify. + """ + def setting(self, name, default=None, backend=None): + """ + Load the setting from a ConfigurationModel if possible, or fall back to the normal + Django settings lookup. + + BaseOAuth2 subclasses will call this method for every setting they want to look up. + SAMLAuthBackend subclasses will call this method only after first checking if the + setting 'name' is configured via SAMLProviderConfig. + """ + if isinstance(backend, BaseOAuth2): + provider_config = OAuth2ProviderConfig.current(backend.name) + if not provider_config.enabled: + raise Exception("Can't fetch setting of a disabled backend/provider.") + try: + return provider_config.get_setting(name) + except KeyError: + pass + # At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row. + # It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION': + return super(ConfigurationModelStrategy, self).setting(name, default, backend) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index ea90c8d659..4b431069fd 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -32,15 +32,8 @@ from third_party_auth.tests import testutil class IntegrationTest(testutil.TestCase, test.TestCase): """Abstract base class for provider integration tests.""" - # Configuration. You will need to override these values in your test cases. - - # Class. The third_party_auth.provider.BaseProvider child we are testing. - PROVIDER_CLASS = None - - # Dict of string -> object. Settings that will be merged onto Django's - # settings object before test execution. In most cases, this is - # PROVIDER_CLASS.SETTINGS with test values. - PROVIDER_SETTINGS = {} + # Override setUp and set this: + provider = None # Methods you must override in your children. @@ -94,10 +87,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase): """ self.assertEqual(200, response.status_code) # Check that the correct provider was selected. - self.assertIn('successfully signed in with %s' % self.PROVIDER_CLASS.NAME, response.content) + self.assertIn('successfully signed in with %s' % self.provider.name, response.content) # Expect that each truthy value we've prepopulated the register form # with is actually present. - for prepopulated_form_value in self.PROVIDER_CLASS.get_register_form_data(pipeline_kwargs).values(): + for prepopulated_form_value in self.provider.get_register_form_data(pipeline_kwargs).values(): if prepopulated_form_value: self.assertIn(prepopulated_form_value, response.content) @@ -106,12 +99,15 @@ class IntegrationTest(testutil.TestCase, test.TestCase): def setUp(self): super(IntegrationTest, self).setUp() - self.configure_runtime() - self.backend_name = self.PROVIDER_CLASS.BACKEND_CLASS.name - self.client = test.Client() self.request_factory = test.RequestFactory() - def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None): + @property + def backend_name(self): + """ Shortcut for the backend name """ + return self.provider.backend_name + + # pylint: disable=invalid-name + def assert_account_settings_context_looks_correct(self, context, _user, duplicate=False, linked=None): """Asserts the user's account settings page context is in the expected state. If duplicate is True, we expect context['duplicate_provider'] to contain @@ -120,13 +116,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase): its connected state is correct. """ if duplicate: - self.assertEqual(context['duplicate_provider'], self.PROVIDER_CLASS.BACKEND_CLASS.name) + self.assertEqual(context['duplicate_provider'], self.provider.backend_name) else: self.assertIsNone(context['duplicate_provider']) if linked is not None: expected_provider = [ - provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME + provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name ][0] self.assertIsNotNone(expected_provider) self.assertEqual(expected_provider['connected'], linked) @@ -197,7 +193,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase): def assert_json_failure_response_is_missing_social_auth(self, response): """Asserts failure on /login for missing social auth looks right.""" self.assertEqual(403, response.status_code) - self.assertIn("successfully logged into your %s account, but this account isn't linked" % self.PROVIDER_CLASS.NAME, response.content) + self.assertIn( + "successfully logged into your %s account, but this account isn't linked" % self.provider.name, + response.content + ) def assert_json_failure_response_is_username_collision(self, response): """Asserts the json response indicates a username collision.""" @@ -211,7 +210,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assertEqual(200, response.status_code) payload = json.loads(response.content) self.assertTrue(payload.get('success')) - self.assertEqual(pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name), payload.get('redirect_url')) + self.assertEqual(pipeline.get_complete_url(self.provider.backend_name), payload.get('redirect_url')) def assert_login_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /login not in the pipeline looks correct.""" @@ -219,7 +218,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # The combined login/registration page dynamically generates the login button, # but we can still check that the provider name is passed in the data attribute # for the container element. - self.assertIn(self.PROVIDER_CLASS.NAME, response.content) + self.assertIn(self.provider.name, response.content) def assert_login_response_in_pipeline_looks_correct(self, response): """Asserts a GET of /login in the pipeline looks correct.""" @@ -258,28 +257,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # The combined login/registration page dynamically generates the register button, # but we can still check that the provider name is passed in the data attribute # for the container element. - self.assertIn(self.PROVIDER_CLASS.NAME, response.content) + self.assertIn(self.provider.name, response.content) def assert_social_auth_does_not_exist_for_user(self, user, strategy): """Asserts a user does not have an auth with the expected provider.""" social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name) + user, provider=self.provider.backend_name) self.assertEqual(0, len(social_auths)) def assert_social_auth_exists_for_user(self, user, strategy): """Asserts a user has a social auth with the expected provider.""" social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name) + user, provider=self.provider.backend_name) self.assertEqual(1, len(social_auths)) self.assertEqual(self.backend_name, social_auths[0].provider) - def configure_runtime(self): - """Configures settings details.""" - auth_settings.apply_settings({self.PROVIDER_CLASS.NAME: self.PROVIDER_SETTINGS}, django_settings) - # Force settings to propagate into cached members on - # social.apps.django_app.utils. - reload(social_utils) - def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False): """Creates user, profile, registration, and (usually) social auth. @@ -296,7 +288,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): registration.save() if not skip_social_auth: - social_utils.Storage.user.create_social_auth(user, uid, self.PROVIDER_CLASS.BACKEND_CLASS.name) + social_utils.Storage.user.create_social_auth(user, uid, self.provider.backend_name) return user @@ -370,7 +362,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assertEqual(response.status_code, 302) self.assertEqual( response["Location"], - pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name) + pipeline.get_complete_url(self.provider.backend_name) ) self.assertEqual(response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value, 'true') self.assertIn(django_settings.EDXMKTG_USER_INFO_COOKIE_NAME, response.cookies) @@ -417,7 +409,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Instrument the pipeline to get to the dashboard with the full # expected state. self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access mako_middleware_process_request(strategy.request) @@ -465,7 +457,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Instrument the pipeline to get to the dashboard with the full # expected state. self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access mako_middleware_process_request(strategy.request) @@ -524,7 +516,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assert_social_auth_exists_for_user(user, strategy) self.client.get('/login') - self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access mako_middleware_process_request(strategy.request) @@ -536,7 +528,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): request._messages = fallback.FallbackStorage(request) middleware.ExceptionMiddleware().process_exception( request, - exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.')) + exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.')) self.assert_account_settings_context_looks_correct( account_settings_context(request), user, duplicate=True, linked=True) @@ -561,7 +553,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Synthesize that request and check that it redirects to the correct # provider page. self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) # Next, the provider makes a request against /auth/complete/ # to resume the pipeline. @@ -641,7 +633,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Synthesize that request and check that it redirects to the correct # provider page. self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) # Next, the provider makes a request against /auth/complete/. # pylint: disable=protected-access diff --git a/common/djangoapps/third_party_auth/tests/specs/test_google.py b/common/djangoapps/third_party_auth/tests/specs/test_google.py index 320739b81e..d591c1e594 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_google.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_google.py @@ -7,11 +7,14 @@ from third_party_auth.tests.specs import base class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest): """Integration tests for provider.GoogleOauth2.""" - PROVIDER_CLASS = provider.GoogleOauth2 - PROVIDER_SETTINGS = { - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key', - 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': 'google_oauth2_secret', - } + def setUp(self): + super(GoogleOauth2IntegrationTest, self).setUp() + self.provider = self.configure_google_provider( + enabled=True, + key='google_oauth2_key', + secret='google_oauth2_secret', + ) + TOKEN_RESPONSE_DATA = { 'access_token': 'access_token_value', 'expires_in': 'expires_in_value', diff --git a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py index e51cc2ecc7..c149065115 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py @@ -7,11 +7,14 @@ from third_party_auth.tests.specs import base class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest): """Integration tests for provider.LinkedInOauth2.""" - PROVIDER_CLASS = provider.LinkedInOauth2 - PROVIDER_SETTINGS = { - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_oauth2_key', - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': 'linkedin_oauth2_secret', - } + def setUp(self): + super(LinkedInOauth2IntegrationTest, self).setUp() + self.provider = self.configure_linkedin_provider( + enabled=True, + key='linkedin_oauth2_key', + secret='linkedin_oauth2_secret', + ) + TOKEN_RESPONSE_DATA = { 'access_token': 'access_token_value', 'expires_in': 'expires_in_value', diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline.py b/common/djangoapps/third_party_auth/tests/test_pipeline.py index 462f24e4b2..c4387626ea 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline.py @@ -4,6 +4,7 @@ import random from third_party_auth import pipeline, provider from third_party_auth.tests import testutil +import unittest # Allow tests access to protected methods (or module-protected methods) under @@ -34,9 +35,11 @@ class MakeRandomPasswordTest(testutil.TestCase): self.assertEqual(expected, pipeline.make_random_password(choice_fn=random_instance.choice)) +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') class ProviderUserStateTestCase(testutil.TestCase): """Tests ProviderUserState behavior.""" def test_get_unlink_form_name(self): - state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), 1000) - self.assertEqual(provider.GoogleOauth2.NAME + '_unlink_form', state.get_unlink_form_name()) + google_provider = self.configure_google_provider(enabled=True) + state = pipeline.ProviderUserState(google_provider, object(), 1000) + self.assertEqual(google_provider.provider_id + '_unlink_form', state.get_unlink_form_name()) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index e6181fac61..d21d834c93 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -21,9 +21,7 @@ class TestCase(testutil.TestCase, test.TestCase): def setUp(self): super(TestCase, self).setUp() - self.enabled_provider_name = provider.GoogleOauth2.NAME - provider.Registry.configure_once([self.enabled_provider_name]) - self.enabled_provider = provider.Registry.get(self.enabled_provider_name) + self.enabled_provider = self.configure_google_provider(enabled=True) @unittest.skipUnless( @@ -55,13 +53,13 @@ class GetAuthenticatedUserTestCase(TestCase): def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self): self.assertIsNotNone(self.get_by_username(self.user.username)) social_models.DjangoStorage.user.create_social_auth( - self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name) + self.user, 'uid', 'other_' + self.enabled_provider.backend_name) with self.assertRaises(models.User.DoesNotExist): pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid') def test_returns_user_with_is_authenticated_and_backend_set_if_match(self): - social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name) + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.backend_name) user = pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid') self.assertEqual(self.user, user) @@ -78,58 +76,70 @@ class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase): self.user = social_models.DjangoStorage.user.create_user(username='username', password='password') def test_returns_empty_list_if_no_enabled_providers(self): - provider.Registry.configure_once([]) + self.assertFalse(provider.Registry.enabled()) self.assertEquals([], pipeline.get_provider_user_states(self.user)) def test_state_not_returned_for_disabled_provider(self): - disabled_provider = provider.GoogleOauth2 - enabled_provider = provider.LinkedInOauth2 - provider.Registry.configure_once([enabled_provider.NAME]) - social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.BACKEND_CLASS.name) + disabled_provider = self.configure_google_provider(enabled=False) + enabled_provider = self.configure_facebook_provider(enabled=True) + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.backend_name) states = pipeline.get_provider_user_states(self.user) self.assertEqual(1, len(states)) - self.assertNotIn(disabled_provider, (state.provider for state in states)) + self.assertNotIn(disabled_provider.provider_id, (state.provider.provider_id for state in states)) + self.assertIn(enabled_provider.provider_id, (state.provider.provider_id for state in states)) def test_states_for_enabled_providers_user_has_accounts_associated_with(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) + # Enable two providers - Google and LinkedIn: + google_provider = self.configure_google_provider(enabled=True) + linkedin_provider = self.configure_linkedin_provider(enabled=True) user_social_auth_google = social_models.DjangoStorage.user.create_social_auth( - self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name) + self.user, 'uid', google_provider.backend_name) user_social_auth_linkedin = social_models.DjangoStorage.user.create_social_auth( - self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name) + self.user, 'uid', linkedin_provider.backend_name) states = pipeline.get_provider_user_states(self.user) self.assertEqual(2, len(states)) - google_state = [state for state in states if state.provider == provider.GoogleOauth2][0] - linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0] + google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0] + linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0] self.assertTrue(google_state.has_account) - self.assertEqual(provider.GoogleOauth2, google_state.provider) + self.assertEqual(google_provider.provider_id, google_state.provider.provider_id) + # Also check the row ID. Note this 'id' changes whenever the configuration does: + self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, google_state.user) self.assertEqual(user_social_auth_google.id, google_state.association_id) self.assertTrue(linkedin_state.has_account) - self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) + self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id) + self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, linkedin_state.user) self.assertEqual(user_social_auth_linkedin.id, linkedin_state.association_id) def test_states_for_enabled_providers_user_has_no_account_associated_with(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) + # Enable two providers - Google and LinkedIn: + google_provider = self.configure_google_provider(enabled=True) + linkedin_provider = self.configure_linkedin_provider(enabled=True) + self.assertEqual(len(provider.Registry.enabled()), 2) + states = pipeline.get_provider_user_states(self.user) self.assertEqual([], [x for x in social_models.DjangoStorage.user.objects.all()]) self.assertEqual(2, len(states)) - google_state = [state for state in states if state.provider == provider.GoogleOauth2][0] - linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0] + google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0] + linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0] self.assertFalse(google_state.has_account) - self.assertEqual(provider.GoogleOauth2, google_state.provider) + self.assertEqual(google_provider.provider_id, google_state.provider.provider_id) + # Also check the row ID. Note this 'id' changes whenever the configuration does: + self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, google_state.user) self.assertFalse(linkedin_state.has_account) - self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) + self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id) + self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, linkedin_state.user) @@ -139,7 +149,7 @@ class UrlFormationTestCase(TestCase): """Tests formation of URLs for pipeline hook points.""" def test_complete_url_raises_value_error_if_provider_not_enabled(self): - provider_name = 'not_enabled' + provider_name = 'oa2-not-enabled' self.assertIsNone(provider.Registry.get(provider_name)) @@ -147,13 +157,13 @@ class UrlFormationTestCase(TestCase): pipeline.get_complete_url(provider_name) def test_complete_url_returns_expected_format(self): - complete_url = pipeline.get_complete_url(self.enabled_provider.BACKEND_CLASS.name) + complete_url = pipeline.get_complete_url(self.enabled_provider.backend_name) self.assertTrue(complete_url.startswith('/auth/complete')) - self.assertIn(self.enabled_provider.BACKEND_CLASS.name, complete_url) + self.assertIn(self.enabled_provider.backend_name, complete_url) def test_disconnect_url_raises_value_error_if_provider_not_enabled(self): - provider_name = 'not_enabled' + provider_name = 'oa2-not-enabled' self.assertIsNone(provider.Registry.get(provider_name)) @@ -161,25 +171,40 @@ class UrlFormationTestCase(TestCase): pipeline.get_disconnect_url(provider_name, 1000) def test_disconnect_url_returns_expected_format(self): - disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME, 1000) + disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.provider_id, 1000) disconnect_url = disconnect_url.rstrip('?') self.assertEqual( disconnect_url, '/auth/disconnect/{backend}/{association_id}/'.format( - backend=self.enabled_provider.BACKEND_CLASS.name, association_id=1000) + backend=self.enabled_provider.backend_name, association_id=1000) ) def test_login_url_raises_value_error_if_provider_not_enabled(self): - provider_name = 'not_enabled' + provider_id = 'oa2-not-enabled' - self.assertIsNone(provider.Registry.get(provider_name)) + self.assertIsNone(provider.Registry.get(provider_id)) with self.assertRaises(ValueError): - pipeline.get_login_url(provider_name, pipeline.AUTH_ENTRY_LOGIN) + pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN) def test_login_url_returns_expected_format(self): - login_url = pipeline.get_login_url(self.enabled_provider.NAME, pipeline.AUTH_ENTRY_LOGIN) + login_url = pipeline.get_login_url(self.enabled_provider.provider_id, pipeline.AUTH_ENTRY_LOGIN) self.assertTrue(login_url.startswith('/auth/login')) - self.assertIn(self.enabled_provider.BACKEND_CLASS.name, login_url) + self.assertIn(self.enabled_provider.backend_name, login_url) self.assertTrue(login_url.endswith(pipeline.AUTH_ENTRY_LOGIN)) + + def test_for_value_error_if_provider_id_invalid(self): + provider_id = 'invalid' # Format is normally "{prefix}-{identifier}" + + with self.assertRaises(ValueError): + provider.Registry.get(provider_id) + + with self.assertRaises(ValueError): + pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN) + + with self.assertRaises(ValueError): + pipeline.get_disconnect_url(provider_id, 1000) + + with self.assertRaises(ValueError): + pipeline.get_complete_url(provider_id) diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py index a1de2943bd..bc3f71660a 100644 --- a/common/djangoapps/third_party_auth/tests/test_provider.py +++ b/common/djangoapps/third_party_auth/tests/test_provider.py @@ -1,89 +1,84 @@ """Unit tests for provider.py.""" -from mock import Mock +from mock import Mock, patch from third_party_auth import provider from third_party_auth.tests import testutil +import unittest +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') class RegistryTest(testutil.TestCase): """Tests registry discovery and operation.""" - # Allow access to protected methods (or module-protected methods) under - # test. pylint: disable-msg=protected-access - - def test_calling_configure_once_twice_raises_value_error(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - - with self.assertRaisesRegexp(ValueError, '^.*already configured$'): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - def test_configure_once_adds_gettable_providers(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME)) + facebook_provider = self.configure_facebook_provider(enabled=True) + # pylint: disable=no-member + self.assertEqual(facebook_provider.id, provider.Registry.get(facebook_provider.provider_id).id) - def test_configuring_provider_with_no_implementation_raises_value_error(self): - with self.assertRaisesRegexp(ValueError, '^.*no_implementation$'): - provider.Registry.configure_once(['no_implementation']) + def test_no_providers_by_default(self): + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 0, "By default, no providers are enabled.") - def test_configuring_single_provider_twice_raises_value_error(self): - provider.Registry._enable(provider.GoogleOauth2) + def test_runtime_configuration(self): + self.configure_google_provider(enabled=True) + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 1) + self.assertEqual(enabled_providers[0].name, "Google") + self.assertEqual(enabled_providers[0].secret, "opensesame") - with self.assertRaisesRegexp(ValueError, '^.*already enabled'): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) + self.configure_google_provider(enabled=False) + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 0) - def test_custom_provider_can_be_enabled(self): - name = 'CustomProvider' + self.configure_google_provider(enabled=True, secret="alohomora") + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 1) + self.assertEqual(enabled_providers[0].secret, "alohomora") - with self.assertRaisesRegexp(ValueError, '^No implementation.*$'): - provider.Registry.configure_once([name]) - - class CustomProvider(provider.BaseProvider): - """Custom class to ensure BaseProvider children outside provider can be enabled.""" - - NAME = name - - provider.Registry._reset() - provider.Registry.configure_once([CustomProvider.NAME]) - self.assertEqual([CustomProvider], provider.Registry.enabled()) - - def test_enabled_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.enabled() + def test_cannot_load_arbitrary_backends(self): + """ Test that only backend_names listed in settings.AUTHENTICATION_BACKENDS can be used """ + self.configure_oauth_provider(enabled=True, name="Disallowed", backend_name="disallowed") + self.enable_saml() + self.configure_saml_provider(enabled=True, name="Disallowed", idp_slug="test", backend_name="disallowed") + self.assertEqual(len(provider.Registry.enabled()), 0) def test_enabled_returns_list_of_enabled_providers_sorted_by_name(self): - all_providers = provider.Registry._get_all() - provider.Registry.configure_once(all_providers.keys()) - self.assertEqual( - sorted(all_providers.values(), key=lambda provider: provider.NAME), provider.Registry.enabled()) + provider_names = ["Stack Overflow", "Google", "LinkedIn", "GitHub"] + backend_names = [] + for name in provider_names: + backend_name = name.lower().replace(' ', '') + backend_names.append(backend_name) + self.configure_oauth_provider(enabled=True, name=name, backend_name=backend_name) - def test_get_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.get('anything') + with patch('third_party_auth.provider._PSA_OAUTH2_BACKENDS', backend_names): + self.assertEqual(sorted(provider_names), [prov.name for prov in provider.Registry.enabled()]) def test_get_returns_enabled_provider(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME)) + google_provider = self.configure_google_provider(enabled=True) + # pylint: disable=no-member + self.assertEqual(google_provider.id, provider.Registry.get(google_provider.provider_id).id) def test_get_returns_none_if_provider_not_enabled(self): - provider.Registry.configure_once([]) - self.assertIsNone(provider.Registry.get(provider.LinkedInOauth2.NAME)) + linkedin_provider_id = "oa2-linkedin-oauth2" + # At this point there should be no configuration entries at all so no providers should be enabled + self.assertEqual(provider.Registry.enabled(), []) + self.assertIsNone(provider.Registry.get(linkedin_provider_id)) + # Now explicitly disabled this provider: + self.configure_linkedin_provider(enabled=False) + self.assertIsNone(provider.Registry.get(linkedin_provider_id)) + self.configure_linkedin_provider(enabled=True) + self.assertEqual(provider.Registry.get(linkedin_provider_id).provider_id, linkedin_provider_id) def test_get_from_pipeline_returns_none_if_provider_not_enabled(self): - provider.Registry.configure_once([]) + self.assertEqual(provider.Registry.enabled(), [], "By default, no providers are enabled.") self.assertIsNone(provider.Registry.get_from_pipeline(Mock())) - def test_get_enabled_by_backend_name_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.get_enabled_by_backend_name('').next() - def test_get_enabled_by_backend_name_returns_enabled_provider(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - found = list(provider.Registry.get_enabled_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) - self.assertEqual(found, [provider.GoogleOauth2]) + google_provider = self.configure_google_provider(enabled=True) + found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name)) + self.assertEqual(found, [google_provider]) def test_get_enabled_by_backend_name_returns_none_if_provider_not_enabled(self): - provider.Registry.configure_once([]) - self.assertEqual( - [], - list(provider.Registry.get_enabled_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) - ) + google_provider = self.configure_google_provider(enabled=False) + found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name)) + self.assertEqual(found, []) diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py index 40babdbc1c..1c1229190e 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings.py +++ b/common/djangoapps/third_party_auth/tests/test_settings.py @@ -2,6 +2,7 @@ from third_party_auth import provider, settings from third_party_auth.tests import testutil +import unittest _ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',) @@ -30,56 +31,26 @@ class SettingsUnitTest(testutil.TestCase): self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP) def test_apply_settings_adds_exception_middleware(self): - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) for middleware_name in settings._MIDDLEWARE_CLASSES: self.assertIn(middleware_name, self.settings.MIDDLEWARE_CLASSES) def test_apply_settings_adds_fields_stored_in_session(self): - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) self.assertEqual(settings._FIELDS_STORED_IN_SESSION, self.settings.FIELDS_STORED_IN_SESSION) def test_apply_settings_adds_third_party_auth_to_installed_apps(self): - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) self.assertIn('third_party_auth', self.settings.INSTALLED_APPS) - def test_apply_settings_enables_no_providers_and_completes_when_app_info_empty(self): - settings.apply_settings({}, self.settings) + @unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') + def test_apply_settings_enables_no_providers_by_default(self): + # Providers are only enabled via ConfigurationModels in the database + settings.apply_settings(self.settings) self.assertEqual([], provider.Registry.enabled()) - def test_apply_settings_initializes_stubs_and_merges_settings_from_auth_info(self): - for key in provider.GoogleOauth2.SETTINGS: - self.assertFalse(hasattr(self.settings, key)) - - auth_info = { - provider.GoogleOauth2.NAME: { - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key', - }, - } - settings.apply_settings(auth_info, self.settings) - self.assertEqual('google_oauth2_key', self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY) - self.assertIsNone(self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET) - - def test_apply_settings_prepends_auth_backends(self): - self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS) - settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.LinkedInOauth2.NAME: {}}, self.settings) - self.assertEqual(( - provider.GoogleOauth2.get_authentication_backend(), provider.LinkedInOauth2.get_authentication_backend()) + - _ORIGINAL_AUTHENTICATION_BACKENDS, - self.settings.AUTHENTICATION_BACKENDS) - - def test_apply_settings_raises_value_error_if_provider_contains_uninitialized_setting(self): - bad_setting_name = 'bad_setting' - self.assertNotIn('bad_setting_name', provider.GoogleOauth2.SETTINGS) - auth_info = { - provider.GoogleOauth2.NAME: { - bad_setting_name: None, - }, - } - with self.assertRaisesRegexp(ValueError, '^.*not initialized$'): - settings.apply_settings(auth_info, self.settings) - def test_apply_settings_turns_off_raising_social_exceptions(self): # Guard against submitting a conf change that's convenient in dev but # bad in prod. - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS) diff --git a/common/djangoapps/third_party_auth/tests/test_settings_integration.py b/common/djangoapps/third_party_auth/tests/test_settings_integration.py deleted file mode 100644 index 8992f9fb79..0000000000 --- a/common/djangoapps/third_party_auth/tests/test_settings_integration.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Integration tests for settings.py.""" - -from django.conf import settings - -from third_party_auth import provider -from third_party_auth import settings as auth_settings -from third_party_auth.tests import testutil - - -class SettingsIntegrationTest(testutil.TestCase): - """Integration tests of auth settings pipeline. - - Note that ENABLE_THIRD_PARTY_AUTH is True in lms/envs/test.py and False in - cms/envs/test.py. This implicitly gives us coverage of the full settings - mechanism with both values, so we do not have explicit test methods as they - are superfluous. - """ - - def test_can_enable_google_oauth2(self): - auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings) - self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled()) - self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY) - - def test_can_enable_linkedin_oauth2(self): - auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings) - self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled()) - self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index eb3f84e5e6..f66ea48a3f 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -5,13 +5,15 @@ Used by Django and non-Django tests; must not have Django deps. """ from contextlib import contextmanager -import unittest +from django.conf import settings +import django.test import mock -from third_party_auth import provider +from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH' +AUTH_FEATURE_ENABLED = AUTH_FEATURES_KEY in settings.FEATURES class FakeDjangoSettings(object): @@ -23,22 +25,66 @@ class FakeDjangoSettings(object): setattr(self, key, value) -class TestCase(unittest.TestCase): - """Base class for auth test cases.""" - - # Allow access to protected methods (or module-protected methods) under - # test. - # pylint: disable-msg=protected-access - - def setUp(self): - super(TestCase, self).setUp() - self._original_providers = provider.Registry._get_all() - provider.Registry._reset() +class ThirdPartyAuthTestMixin(object): + """ Helper methods useful for testing third party auth functionality """ def tearDown(self): - provider.Registry._reset() - provider.Registry.configure_once(self._original_providers) - super(TestCase, self).tearDown() + config_cache.clear() + super(ThirdPartyAuthTestMixin, self).tearDown() + + def enable_saml(self, **kwargs): + """ Enable SAML support (via SAMLConfiguration, not for any particular provider) """ + kwargs.setdefault('enabled', True) + SAMLConfiguration(**kwargs).save() + + @staticmethod + def configure_oauth_provider(**kwargs): + """ Update the settings for an OAuth2-based third party auth provider """ + obj = OAuth2ProviderConfig(**kwargs) + obj.save() + return obj + + def configure_saml_provider(self, **kwargs): + """ Update the settings for a SAML-based third party auth provider """ + self.assertTrue(SAMLConfiguration.is_enabled(), "SAML Provider Configuration only works if SAML is enabled.") + obj = SAMLProviderConfig(**kwargs) + obj.save() + return obj + + @classmethod + def configure_google_provider(cls, **kwargs): + """ Update the settings for the Google third party auth provider/backend """ + kwargs.setdefault("name", "Google") + kwargs.setdefault("backend_name", "google-oauth2") + kwargs.setdefault("icon_class", "fa-google-plus") + kwargs.setdefault("key", "test-fake-key.apps.googleusercontent.com") + kwargs.setdefault("secret", "opensesame") + return cls.configure_oauth_provider(**kwargs) + + @classmethod + def configure_facebook_provider(cls, **kwargs): + """ Update the settings for the Facebook third party auth provider/backend """ + kwargs.setdefault("name", "Facebook") + kwargs.setdefault("backend_name", "facebook") + kwargs.setdefault("icon_class", "fa-facebook") + kwargs.setdefault("key", "FB_TEST_APP") + kwargs.setdefault("secret", "opensesame") + return cls.configure_oauth_provider(**kwargs) + + @classmethod + def configure_linkedin_provider(cls, **kwargs): + """ Update the settings for the LinkedIn third party auth provider/backend """ + kwargs.setdefault("name", "LinkedIn") + kwargs.setdefault("backend_name", "linkedin-oauth2") + kwargs.setdefault("icon_class", "fa-linkedin") + kwargs.setdefault("key", "test") + kwargs.setdefault("secret", "test") + return cls.configure_oauth_provider(**kwargs) + + +class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase): + """Base class for auth test cases.""" + pass @contextmanager diff --git a/common/djangoapps/third_party_auth/tests/utils.py b/common/djangoapps/third_party_auth/tests/utils.py index 208930cdf4..cce2edd59b 100644 --- a/common/djangoapps/third_party_auth/tests/utils.py +++ b/common/djangoapps/third_party_auth/tests/utils.py @@ -9,9 +9,11 @@ from social.apps.django_app.default.models import UserSocialAuth from student.tests.factories import UserFactory +from .testutil import ThirdPartyAuthTestMixin + @httpretty.activate -class ThirdPartyOAuthTestMixin(object): +class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin): """ Mixin with tests for third party oauth views. A TestCase that includes this must define the following: @@ -32,6 +34,10 @@ class ThirdPartyOAuthTestMixin(object): if create_user: self.user = UserFactory() UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid) + if self.BACKEND == 'google-oauth2': + self.configure_google_provider(enabled=True) + elif self.BACKEND == 'facebook': + self.configure_facebook_provider(enabled=True) def _setup_provider_response(self, success=False, email=''): """ diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index 8f0c6bc3ba..ef0233f33c 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -3,9 +3,10 @@ Extra views required for SSO """ from django.conf import settings from django.core.urlresolvers import reverse -from django.http import HttpResponse, HttpResponseServerError +from django.http import HttpResponse, HttpResponseServerError, Http404 from django.shortcuts import redirect from social.apps.django_app.utils import load_strategy, load_backend +from .models import SAMLConfiguration def inactive_user_view(request): @@ -24,6 +25,8 @@ def saml_metadata_view(request): Get the Service Provider metadata for this edx-platform instance. You must send this XML to any Shibboleth Identity Provider that you wish to use. """ + if not SAMLConfiguration.is_enabled(): + raise Http404 complete_url = reverse('social:complete', args=("tpa-saml", )) if settings.APPEND_SLASH and not complete_url.endswith('/'): complete_url = complete_url + '/' # Required for consistency diff --git a/common/test/acceptance/pages/lms/login_and_register.py b/common/test/acceptance/pages/lms/login_and_register.py index b61e25c547..283cb5028e 100644 --- a/common/test/acceptance/pages/lms/login_and_register.py +++ b/common/test/acceptance/pages/lms/login_and_register.py @@ -232,7 +232,7 @@ class CombinedLoginAndRegisterPage(PageObject): Only the "Dummy" provider is used for bok choy because it is the only one that doesn't send traffic to external servers. """ - self.q(css="button.{}-Dummy".format(self.current_form)).click() + self.q(css="button.{}-oa2-dummy".format(self.current_form)).click() def password_reset(self, email): """Navigates to, fills in, and submits the password reset form. diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py index c9ab3eb051..efdcd1c00b 100644 --- a/common/test/acceptance/tests/lms/test_account_settings.py +++ b/common/test/acceptance/tests/lms/test_account_settings.py @@ -437,9 +437,10 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest): Currently there is no way to test the whole authentication process because that would require accounts with the providers. """ - for field_id, title, link_title in [ - ['auth-facebook', 'Facebook', 'Link'], - ['auth-google', 'Google', 'Link'], - ]: + providers = ( + ['auth-oa2-facebook', 'Facebook', 'Link'], + ['auth-oa2-google-oauth2', 'Google', 'Link'], + ) + for field_id, title, link_title in providers: self.assertEqual(self.account_settings_page.title_for_field(field_id), title) self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title) diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index ad885e34dc..2d10d78935 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -166,7 +166,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest): # Now unlink the account (To test the account settings view and also to prevent cross-test side effects) account_settings = AccountSettingsPage(self.browser).visit() - field_id = "auth-dummy" + field_id = "auth-oa2-dummy" account_settings.wait_for_field(field_id) self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id)) account_settings.click_on_link_in_link_field(field_id) @@ -305,7 +305,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): # Now unlink the account (To test the account settings view and also to prevent cross-test side effects) account_settings = AccountSettingsPage(self.browser).visit() - field_id = "auth-dummy" + field_id = "auth-oa2-dummy" account_settings.wait_for_field(field_id) self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id)) account_settings.click_on_link_in_link_field(field_id) diff --git a/common/test/db_fixtures/third_party_auth.json b/common/test/db_fixtures/third_party_auth.json new file mode 100644 index 0000000000..3042ebbb66 --- /dev/null +++ b/common/test/db_fixtures/third_party_auth.json @@ -0,0 +1,47 @@ +[ + { + "pk": 1, + "model": "third_party_auth.oauth2providerconfig", + "fields": { + "enabled": true, + "change_date": "2001-02-03T04:05:06Z", + "changed_by": null, + "name": "Google", + "icon_class": "fa-google-plus", + "backend_name": "google-oauth2", + "key": "test", + "secret": "test", + "other_settings": "{}" + } + }, + { + "pk": 2, + "model": "third_party_auth.oauth2providerconfig", + "fields": { + "enabled": true, + "change_date": "2001-02-03T04:05:06Z", + "changed_by": null, + "name": "Facebook", + "icon_class": "fa-facebook", + "backend_name": "facebook", + "key": "test", + "secret": "test", + "other_settings": "{}" + } + }, + { + "pk": 3, + "model": "third_party_auth.oauth2providerconfig", + "fields": { + "enabled": true, + "change_date": "2001-02-03T04:05:06Z", + "changed_by": null, + "name": "Dummy", + "icon_class": "fa-sign-in", + "backend_name": "dummy", + "key": "", + "secret": "", + "other_settings": "{}" + } + } +] diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 80cebeae70..508cd9b19b 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -23,7 +23,7 @@ from openedx.core.djangoapps.user_api.accounts.api import activate_account, crea from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH from student.tests.factories import CourseModeFactory, UserFactory from student_account.views import account_settings_context -from third_party_auth.tests.testutil import simulate_running_pipeline +from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from util.testing import UrlResetMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -204,7 +204,7 @@ class StudentAccountUpdateTest(UrlResetMixin, TestCase): @ddt.ddt -class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase): +class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """ Tests for the student account views that update the user's account information. """ USERNAME = "bob" @@ -214,6 +214,9 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) @mock.patch.dict(settings.FEATURES, {'EMBARGO': True}) def setUp(self): super(StudentAccountLoginAndRegistrationTest, self).setUp('embargo') + # For these tests, two third party auth providers are enabled by default: + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) @ddt.data( ("account_login", "login"), @@ -290,7 +293,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) @ddt.unpack def test_third_party_auth(self, url_name, current_backend, current_provider): params = [ - ('course_id', 'edX/DemoX/Demo_Course'), + ('course_id', 'course-v1:Org+Course+Run'), ('enrollment_action', 'enroll'), ('course_mode', 'honor'), ('email_opt_in', 'true'), @@ -310,12 +313,14 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) # This relies on the THIRD_PARTY_AUTH configuration in the test settings expected_providers = [ { + "id": "oa2-facebook", "name": "Facebook", "iconClass": "fa-facebook", "loginUrl": self._third_party_login_url("facebook", "login", params), "registerUrl": self._third_party_login_url("facebook", "register", params) }, { + "id": "oa2-google-oauth2", "name": "Google", "iconClass": "fa-google-plus", "loginUrl": self._third_party_login_url("google-oauth2", "login", params), @@ -347,11 +352,14 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers): """Verify that third party auth info is rendered correctly in a DOM data attribute. """ + finish_auth_url = None + if current_backend: + finish_auth_url = reverse("social:complete", kwargs={"backend": current_backend}) + "?" auth_info = markupsafe.escape( json.dumps({ "currentProvider": current_provider, "providers": providers, - "finishAuthUrl": "/auth/complete/{}?".format(current_backend) if current_backend else None, + "finishAuthUrl": finish_auth_url, "errorMessage": None, }) ) @@ -382,7 +390,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) }) -class AccountSettingsViewTest(TestCase): +class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase): """ Tests for the account settings view. """ USERNAME = 'student' @@ -406,6 +414,10 @@ class AccountSettingsViewTest(TestCase): self.request = RequestFactory() self.request.user = self.user + # For these tests, two third party auth providers are enabled by default: + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) + # Python-social saves auth failure notifcations in Django messages. # See pipeline.get_duplicate_provider() for details. self.request.COOKIES = {} diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 284472c263..7a10263b01 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -171,15 +171,16 @@ def _third_party_auth_context(request, redirect_to): if third_party_auth.is_enabled(): context["providers"] = [ { - "name": enabled.NAME, - "iconClass": enabled.ICON_CLASS, + "id": enabled.provider_id, + "name": enabled.name, + "iconClass": enabled.icon_class, "loginUrl": pipeline.get_login_url( - enabled.NAME, + enabled.provider_id, pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to, ), "registerUrl": pipeline.get_login_url( - enabled.NAME, + enabled.provider_id, pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to, ), @@ -190,13 +191,14 @@ def _third_party_auth_context(request, redirect_to): running_pipeline = pipeline.get(request) if running_pipeline is not None: current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) - context["currentProvider"] = current_provider.NAME - context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.BACKEND_CLASS.name) + context["currentProvider"] = current_provider.name + context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name) # Check for any error messages we may want to display: for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": - context['errorMessage'] = unicode(msg) + # msg may or may not be translated. Try translating [again] in case we are able to: + context['errorMessage'] = _(msg) # pylint: disable=translation-of-non-string break return context @@ -368,19 +370,20 @@ def account_settings_context(request): auth_states = pipeline.get_provider_user_states(user) context['auth']['providers'] = [{ - 'name': state.provider.NAME, # The name of the provider e.g. Facebook + 'id': state.provider.provider_id, + 'name': state.provider.name, # The name of the provider e.g. Facebook 'connected': state.has_account, # Whether the user's edX account is connected with the provider. # If the user is not connected, they should be directed to this page to authenticate # with the particular provider. 'connect_url': pipeline.get_login_url( - state.provider.NAME, + state.provider.provider_id, pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, # The url the user should be directed to after the auth process has completed. redirect_url=reverse('account_settings'), ), # If the user is connected, sending a POST request to this url removes the connection # information for this provider from their edX account. - 'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME, state.association_id), + 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), } for state in auth_states] return context diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 07dd1474a6..9a4ce09cc5 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -536,29 +536,21 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) ##### Third-party auth options ################################################ -THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) - -# The reduced session expiry time during the third party login pipeline. (Value in seconds) -SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) - -##### SAML configuration for third_party_auth ##### - -if 'SOCIAL_AUTH_TPA_SAML_SP_ENTITY_ID' in ENV_TOKENS: - SOCIAL_AUTH_TPA_SAML_SP_ENTITY_ID = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_ENTITY_ID') - SOCIAL_AUTH_TPA_SAML_SP_NAMEID_FORMAT = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_NAMEID_FORMAT', 'unspecified') - SOCIAL_AUTH_TPA_SAML_SP_EXTRA = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_EXTRA', {}) - SOCIAL_AUTH_TPA_SAML_ORG_INFO = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_ORG_INFO') - SOCIAL_AUTH_TPA_SAML_TECHNICAL_CONTACT = ENV_TOKENS.get( - 'SOCIAL_AUTH_TPA_SAML_TECHNICAL_CONTACT', - {"givenName": "Technical Support", "emailAddress": TECH_SUPPORT_EMAIL} +if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + AUTHENTICATION_BACKENDS = ( + ENV_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ + 'social.backends.google.GoogleOAuth2', + 'social.backends.linkedin.LinkedinOAuth2', + 'social.backends.facebook.FacebookOAuth2', + 'third_party_auth.saml.SAMLAuthBackend', + ]) + list(AUTHENTICATION_BACKENDS) ) - SOCIAL_AUTH_TPA_SAML_SUPPORT_CONTACT = ENV_TOKENS.get( - 'SOCIAL_AUTH_TPA_SAML_SUPPORT_CONTACT', - {"givenName": "Support", "emailAddress": TECH_SUPPORT_EMAIL} - ) - SOCIAL_AUTH_TPA_SAML_SECURITY_CONFIG = ENV_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SECURITY_CONFIG', {}) - SOCIAL_AUTH_TPA_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_PUBLIC_CERT') - SOCIAL_AUTH_TPA_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_TPA_SAML_SP_PRIVATE_KEY') + + # The reduced session expiry time during the third party login pipeline. (Value in seconds) + SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) + + # third_party_auth config moved to ConfigurationModels. This is for data migration only: + THIRD_PARTY_AUTH_OLD_CONFIG = AUTH_TOKENS.get('THIRD_PARTY_AUTH', None) ##### OAUTH2 Provider ############## if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 85a19928cc..719f72ccf9 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -117,17 +117,6 @@ "username": "lms" }, "SECRET_KEY": "", - "THIRD_PARTY_AUTH": { - "Dummy": {}, - "Google": { - "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", - "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test" - }, - "Facebook": { - "SOCIAL_AUTH_FACEBOOK_KEY": "test", - "SOCIAL_AUTH_FACEBOOK_SECRET": "test" - } - }, "DJFS": { "type": "s3fs", "bucket": "test", diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index 8fde6044b5..5be8f73c84 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -79,7 +79,6 @@ "ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_THIRD_PARTY_AUTH": true, - "ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER": true, "ENABLE_COMBINED_LOGIN_REGISTRATION": true, "PREVIEW_LMS_BASE": "localhost:8003", "SUBDOMAIN_BRANDING": false, @@ -119,6 +118,13 @@ "SYSLOG_SERVER": "", "TECH_SUPPORT_EMAIL": "technical@example.com", "THEME_NAME": "", + "THIRD_PARTY_AUTH_BACKENDS": [ + "social.backends.google.GoogleOAuth2", + "social.backends.linkedin.LinkedinOAuth2", + "social.backends.facebook.FacebookOAuth2", + "third_party_auth.dummy.DummyBackend", + "third_party_auth.saml.SAMLAuthBackend" + ], "TIME_ZONE": "America/New_York", "WIKI_ENABLED": true } diff --git a/lms/envs/common.py b/lms/envs/common.py index 7a4253d39f..49b8d54a85 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2385,10 +2385,6 @@ for app_name in OPTIONAL_APPS: continue INSTALLED_APPS += (app_name,) -# Stub for third_party_auth options. -# See common/djangoapps/third_party_auth/settings.py for configuration details. -THIRD_PARTY_AUTH = {} - ### ADVANCED_SECURITY_CONFIG # Empty by default ADVANCED_SECURITY_CONFIG = {} diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 4960594a87..808df44c33 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -170,6 +170,10 @@ FEATURES['STORE_BILLING_INFO'] = True FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] = True FEATURES['ENABLE_COSMETIC_DISPLAY_PRICE'] = True +########################## Third Party Auth ####################### + +if FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and 'third_party_auth.dummy.DummyBackend' not in AUTHENTICATION_BACKENDS: + AUTHENTICATION_BACKENDS = ['third_party_auth.dummy.DummyBackend'] + list(AUTHENTICATION_BACKENDS) ##################################################################### # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index 326572da14..66ee946d1c 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -238,18 +238,13 @@ PASSWORD_COMPLEXITY = {} ######### Third-party auth ########## FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True -THIRD_PARTY_AUTH = { - "Google": { - "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", - "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", - }, - "Facebook": { - "SOCIAL_AUTH_FACEBOOK_KEY": "test", - "SOCIAL_AUTH_FACEBOOK_SECRET": "test", - }, -} - -FEATURES['ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'] = True +AUTHENTICATION_BACKENDS = ( + 'social.backends.google.GoogleOAuth2', + 'social.backends.linkedin.LinkedinOAuth2', + 'social.backends.facebook.FacebookOAuth2', + 'third_party_auth.dummy.DummyBackend', + 'third_party_auth.saml.SAMLAuthBackend', +) + AUTHENTICATION_BACKENDS ################################## OPENID ##################################### FEATURES['AUTH_USE_OPENID'] = True diff --git a/lms/startup.py b/lms/startup.py index 3add027f15..4ca979ce45 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -141,4 +141,4 @@ def enable_third_party_auth(): """ from third_party_auth import settings as auth_settings - auth_settings.apply_settings(settings.THIRD_PARTY_AUTH, settings) + auth_settings.apply_settings(settings) diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js index df1aaa658a..fc7483eaa5 100644 --- a/lms/static/js/spec/student_account/account_settings_factory_spec.js +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -32,12 +32,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers var AUTH_DATA = { 'providers': [ { + 'id': 'oa2-network1', 'name': "Network1", 'connected': true, 'connect_url': 'yetanother1.com/auth/connect', 'disconnect_url': 'yetanother1.com/auth/disconnect' }, { + 'id': 'oa2-network2', 'name': "Network2", 'connected': true, 'connect_url': 'yetanother2.com/auth/connect', diff --git a/lms/static/js/spec/student_account/login_spec.js b/lms/static/js/spec/student_account/login_spec.js index 30ff0f10d3..d61f49be87 100644 --- a/lms/static/js/spec/student_account/login_spec.js +++ b/lms/static/js/spec/student_account/login_spec.js @@ -25,12 +25,14 @@ define([ currentProvider: null, providers: [ { + id: 'oa2-google-oauth2', name: 'Google', iconClass: 'fa-google-plus', loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' }, { + id: 'oa2-facebook', name: 'Facebook', iconClass: 'fa-facebook', loginUrl: '/auth/login/facebook/?auth_entry=account_login', @@ -195,8 +197,8 @@ define([ createLoginView(this); // Verify that Google and Facebook registration buttons are displayed - expect($('.button-Google')).toBeVisible(); - expect($('.button-Facebook')).toBeVisible(); + expect($('.button-oa2-google-oauth2')).toBeVisible(); + expect($('.button-oa2-facebook')).toBeVisible(); }); it('displays a link to the password reset form', function() { diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index 67c5a65f2a..ac9064e376 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -32,12 +32,14 @@ define([ currentProvider: null, providers: [ { + id: 'oa2-google-oauth2', name: 'Google', iconClass: 'fa-google-plus', loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' }, { + id: 'oa2-facebook', name: 'Facebook', iconClass: 'fa-facebook', loginUrl: '/auth/login/facebook/?auth_entry=account_login', @@ -284,8 +286,8 @@ define([ createRegisterView(this); // Verify that Google and Facebook registration buttons are displayed - expect($('.button-Google')).toBeVisible(); - expect($('.button-Facebook')).toBeVisible(); + expect($('.button-oa2-google-oauth2')).toBeVisible(); + expect($('.button-oa2-facebook')).toBeVisible(); }); it('validates registration form fields', function() { diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js index 07daa25174..1daf631906 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -137,7 +137,7 @@ screenReaderTitle: interpolate_text( gettext("Connect your {accountName} account"), {accountName: provider['name']} ), - valueAttribute: 'auth-' + provider.name.toLowerCase(), + valueAttribute: 'auth-' + provider.id, helpMessage: '', connected: provider.connected, connectUrl: provider.connect_url, diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 8c50ae95c7..1eacb688e8 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -532,30 +532,30 @@ margin-right: 0; } - &.button-Google:hover, &.button-Google:focus { + &.button-oa2-google-oauth2:hover, &.button-oa2-google-oauth2:focus { background-color: #dd4b39; border: 1px solid #A5382B; } - &.button-Google:hover { + &.button-oa2-google-oauth2:hover { box-shadow: 0 2px 1px 0 #8D3024; } - &.button-Facebook:hover, &.button-Facebook:focus { + &.button-oa2-facebook:hover, &.button-oa2-facebook:focus { background-color: #3b5998; border: 1px solid #263A62; } - &.button-Facebook:hover { + &.button-oa2-facebook:hover { box-shadow: 0 2px 1px 0 #30487C; } - &.button-LinkedIn:hover , &.button-LinkedIn:focus { + &.button-oa2-linkedin-oauth2:hover , &.button-oa2-linkedin-oauth2:focus { background-color: #0077b5; border: 1px solid #06527D; } - &.button-LinkedIn:hover { + &.button-oa2-linkedin-oauth2:hover { box-shadow: 0 2px 1px 0 #005D8E; } diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index 81d43ee80d..435ed65edf 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -388,7 +388,7 @@ $sm-btn-linkedin: #0077b5; margin-bottom: $baseline; } - &.button-Google { + &.button-oa2-google-oauth2 { color: $sm-btn-google; .icon { @@ -407,7 +407,7 @@ $sm-btn-linkedin: #0077b5; } } - &.button-Facebook { + &.button-oa2-facebook { color: $sm-btn-facebook; .icon { @@ -426,7 +426,7 @@ $sm-btn-linkedin: #0077b5; } } - &.button-LinkedIn { + &.button-oa2-linkedin-oauth2 { color: $sm-btn-linkedin; .icon { diff --git a/lms/templates/login.html b/lms/templates/login.html index 3280ca7696..c6483df2a0 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -221,7 +221,7 @@ from microsite_configuration import microsite % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn). - + % endfor diff --git a/lms/templates/register.html b/lms/templates/register.html index f885769658..c913be8466 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -132,7 +132,7 @@ import calendar % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn). - + % endfor diff --git a/lms/templates/student_account/login.underscore b/lms/templates/student_account/login.underscore index 5420e769c4..8b7ad6e6df 100644 --- a/lms/templates/student_account/login.underscore +++ b/lms/templates/student_account/login.underscore @@ -49,7 +49,7 @@ <% _.each( context.providers, function( provider ) { if ( provider.loginUrl ) { %> - diff --git a/lms/templates/student_account/register.underscore b/lms/templates/student_account/register.underscore index f3e2c5c2a9..1eb0b6feb7 100644 --- a/lms/templates/student_account/register.underscore +++ b/lms/templates/student_account/register.underscore @@ -29,7 +29,7 @@ <% _.each( context.providers, function( provider) { if ( provider.registerUrl ) { %> - diff --git a/lms/templates/student_profile/third_party_auth.html b/lms/templates/student_profile/third_party_auth.html index 28f0cb6fbf..6092ee7034 100644 --- a/lms/templates/student_profile/third_party_auth.html +++ b/lms/templates/student_profile/third_party_auth.html @@ -19,10 +19,10 @@ from third_party_auth import pipeline ${_('Not Linked')} % endif - ${state.provider.NAME} + ${state.provider.name} % if state.has_account: @@ -33,7 +33,7 @@ from third_party_auth import pipeline ${_("Unlink")} % else: - + ## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn). ${_("Link")} diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 6034a9cc2a..d07463517a 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -25,7 +25,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from django_comment_common import models from student.tests.factories import UserFactory -from third_party_auth.tests.testutil import simulate_running_pipeline +from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from third_party_auth.tests.utils import ( ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle ) @@ -800,7 +800,7 @@ class PasswordResetViewTest(ApiTestCase): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class RegistrationViewTest(ApiTestCase): +class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase): """Tests for the registration end-points of the User API. """ maxDiff = None @@ -907,6 +907,7 @@ class RegistrationViewTest(ApiTestCase): def test_register_form_third_party_auth_running(self): no_extra_fields_setting = {} + self.configure_google_provider(enabled=True) with simulate_running_pipeline( "openedx.core.djangoapps.user_api.views.third_party_auth.pipeline", "google-oauth2", email="bob@example.com", diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 9c2613b53a..8c56763e2e 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -32,7 +32,7 @@ git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c -e git+https://github.com/jazkarta/ccx-keys.git@e6b03704b1bb97c1d2f31301ecb4e3a687c536ea#egg=ccx-keys # For SAML Support (To be moved to PyPi installation in base.txt once our changes are merged): -e git+https://github.com/open-craft/python-saml.git@9602b8133056d8c3caa7c3038761147df3d4b257#egg=python-saml --e git+https://github.com/open-craft/python-social-auth.git@17def186d4bb7165f9c37037936997ef39ae2f29#egg=python-social-auth +-e git+https://github.com/open-craft/python-social-auth.git@02ab628b8961b969021de87aeb23551da4e751b7#egg=python-social-auth # Our libraries: -e git+https://github.com/edx/XBlock.git@74fdc5a361f48e5596acf3846ca3790a33a05253#egg=XBlock From cd941eada7a9e4a5320a7033222220ca09db291e Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 12 Jun 2015 20:43:57 -0700 Subject: [PATCH 83/97] New SAML/Shibboleth tests - PR 8518 --- common/djangoapps/student/views.py | 2 +- .../management/commands/saml.py | 4 +- common/djangoapps/third_party_auth/models.py | 21 +- .../third_party_auth/tests/data/saml_key.key | 15 ++ .../third_party_auth/tests/data/saml_key.pub | 17 ++ .../tests/data/saml_key_alt.key | 16 ++ .../tests/data/saml_key_alt.pub | 15 ++ .../tests/data/testshib_metadata.xml | 155 ++++++++++++ .../tests/data/testshib_response.txt | 1 + .../tests/specs/test_testshib.py | 229 ++++++++++++++++++ .../third_party_auth/tests/test_views.py | 64 +++++ .../third_party_auth/tests/testutil.py | 28 +++ lms/djangoapps/student_account/views.py | 2 +- 13 files changed, 560 insertions(+), 9 deletions(-) create mode 100644 common/djangoapps/third_party_auth/tests/data/saml_key.key create mode 100644 common/djangoapps/third_party_auth/tests/data/saml_key.pub create mode 100644 common/djangoapps/third_party_auth/tests/data/saml_key_alt.key create mode 100644 common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub create mode 100644 common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml create mode 100644 common/djangoapps/third_party_auth/tests/data/testshib_response.txt create mode 100644 common/djangoapps/third_party_auth/tests/specs/test_testshib.py create mode 100644 common/djangoapps/third_party_auth/tests/test_views.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 6a8d226421..bb872c1a55 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -368,7 +368,7 @@ def signin_user(request): for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": # msg may or may not be translated. Try translating [again] in case we are able to: - third_party_auth_error = _(msg) # pylint: disable=translation-of-non-string + third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string break context = { diff --git a/common/djangoapps/third_party_auth/management/commands/saml.py b/common/djangoapps/third_party_auth/management/commands/saml.py index 9ef5c4dd27..ca15bcaf4d 100644 --- a/common/djangoapps/third_party_auth/management/commands/saml.py +++ b/common/djangoapps/third_party_auth/management/commands/saml.py @@ -60,7 +60,7 @@ class Command(BaseCommand): self.stdout.write("\n→ Fetching {}\n".format(url)) if not url.lower().startswith('https'): self.stdout.write("→ WARNING: This URL is not secure! It should use HTTPS.\n") - response = requests.get(url, verify=True) # May raise HTTPError or SSLError + response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError response.raise_for_status() # May raise an HTTPError try: @@ -75,7 +75,7 @@ class Command(BaseCommand): public_key, sso_url, expires_at = self._parse_metadata_xml(xml, entity_id) self._update_data(entity_id, public_key, sso_url, expires_at) except Exception as err: # pylint: disable=broad-except - self.stderr.write("→ ERROR: {}\n\n".format(err.message)) + self.stderr.write(u"→ ERROR: {}\n\n".format(err.message)) @classmethod def _parse_metadata_xml(cls, xml, entity_id): diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 411691b42a..eccc269390 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone +from django.utils.translation import ugettext as _ import json import logging from social.backends.base import BaseAuth @@ -53,7 +54,7 @@ class AuthNotConfigured(SocialAuthBaseException): self.provider_name = provider_name def __str__(self): - return 'Authentication with {} is currently unavailable.'.format( + return _('Authentication with {} is currently unavailable.').format( self.provider_name ) @@ -313,10 +314,20 @@ class SAMLConfiguration(ConfigurationModel): self.org_info_str = clean_json(self.org_info_str, dict) self.other_config_str = clean_json(self.other_config_str, dict) - self.private_key = self.private_key.replace("-----BEGIN PRIVATE KEY-----", "").strip() - self.private_key = self.private_key.replace("-----END PRIVATE KEY-----", "").strip() - self.public_key = self.public_key.replace("-----BEGIN CERTIFICATE-----", "").strip() - self.public_key = self.public_key.replace("-----END CERTIFICATE-----", "").strip() + self.private_key = ( + self.private_key + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .strip() + ) + self.public_key = ( + self.public_key + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .strip() + ) def get_setting(self, name): """ Get the value of a setting, or raise KeyError """ diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key.key b/common/djangoapps/third_party_auth/tests/data/saml_key.key new file mode 100644 index 0000000000..a6b7f7fa85 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9 +kJF0RB3h3Q2VJ3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu +9Qvjg6YOtYP6PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQAB +AoGADWBsD/qdQaqe1x9/iOKINhuuPRNKw2n9nzT2iIW4nhzaDHB689VceL79SEE5 +4rMJmQomkBtGZVxBeHgd5/dQxNy3bC9lPN1uoMuzjQs7UMk+lvy0MoHfiJcuIxPX +RdyZTV9LKN8vq+ZpVykVu6pBdDlne4psPZeQ76ynxke/24ECQQD3NX7JeluZ64la +tC6b3VHzA4Hd1qTXDWtEekh2WaR2xuKzcLyOWhqPIWprylBqVc1m+FA/LRRWQ9y6 +vJMiXMk7AkEA1ELWj9DtZzk9BV1JxsDUUP0/IMAiYliVac3YrvQfys8APCY1xr9q +BAGurH4VWXuEnbx1yNXK89HqFI7kDrMtwQJAVTXtVAmHFZEosUk2X6d0He3xj8Py +4eXQObRk0daoaAC6F9weQnsweHGuOyVrfpvAx2OEVaJ2Rh3yMbPai5esDQJAS9Yh +gLqdx26M3bjJ3igQ82q3vkTHRCnwICA6la+FGFnC9LqWJg9HmmzbcqeNiy31YMHv +tzSjUV+jaXrwAkyEQQJAK/SCIVsWRhFe/ssr8hS//V+hZC4kvCv4b3NqzZK1x+Xm +7GaGMV0xEWN7shqVSRBU4O2vn/RWD6/6x3sHkU57qg== +-----END RSA PRIVATE KEY----- diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key.pub b/common/djangoapps/third_party_auth/tests/data/saml_key.pub new file mode 100644 index 0000000000..e93f6dd59b --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key.pub @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICsDCCAhmgAwIBAgIJAJrENr8EPgpcMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEwNTE0WhcNMjUwNjEyMDEwNTE0WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9kJF0RB3h3Q2V +J3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu9Qvjg6YOtYP6 +PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQABo4GnMIGkMB0G +A1UdDgQWBBTjOyPvAuej5q4C80jlFrQmOlszmzB1BgNVHSMEbjBsgBTjOyPvAuej +5q4C80jlFrQmOlszm6FJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt +U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrENr8E +PgpcMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAV5w0SxjUTFWfL3ZG +6sgA0gKf8aV8w3AlihLt9tKCRgrK4sBK9xmfwp/fnbdxkHU58iozI894HqmrRzCi +aRLWmy3W8640E/XCa6P+i8ET7RksgNJ5cD9WtISHkGc2dnW76+2nv8d24JKeIx2w +oJAtspMywzr0SoxDIJr42N6Kvjk= +-----END CERTIFICATE----- diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key_alt.key b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.key new file mode 100644 index 0000000000..d54d58a3b6 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMoR8CP+HlvsPRwi +VCCuWxZOdNjYa4Qre3JEWPkqlUwpci1XGTBqH7DK9b2hmBXMjYoDKOnF5pL7Y453 +3JSJ2+AG7D4AJGSotA3boKF18EDgeMzAWjAhDVhTprGz+/1G+W0R4SSyY5QGyBhL +Z36xF2w5HyeiqN/Iiq3QKGl2CFORAgMBAAECgYEAwH2CAudqSCqstAZHmbI99uva +B09ybD93owxUrVbRTfIVX/eeeS4+7g0JNxGebPWkxxnneXoaAV4UIn0v1RfWKMs3 +QGiBsOSup1DWWwkBfvQ1hNlJfVCqgZH1QVbhPpw9M9gxhLZQaSZoI/qY/8n/54L0 +zU4S6VYBH6hnkgZZmiECQQDpYUS8HgnkMUX/qcDNBJT23qHewHsZOe6uqC+7+YxQ +xKT8iCxybDbZU7hmZ1Av8Ns4iF7EvZ0faFM8Ls76wFX1AkEA3afLUMLHfTx40XwO +oU7GWrYFyLNCc3/7JeWi6ZKzwzQqiGvFderRf/QGQsCtpLQ8VoLz/knF9TkQdSh6 +yuIprQJATfcmxUmruEYVwnFtbZBoS4jYvtfCyAyohkS9naiijaEEFTFQ1/D66eOk +KOG+0iU+t0YnksZdpU5u8B4bG34BuQJAXv6FhTQk+MhM40KupnUzTzcJXY1t4kAs +K36yBjZoMjWOMO83LiUX6iVz9XHMOXVBEraGySlm3IS7R+q0TXUF9QJAQ69wautf +8q1OQiLcg5WTFmSFBEXqAvVwX6FcDSxor9UnI0iHwyKBss3a2IXY9LoTPTjR5SHh +GDq2lXmP+kmbnQ== +-----END PRIVATE KEY----- diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub new file mode 100644 index 0000000000..1221357e6d --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICWDCCAcGgAwIBAgIJAMlM2wrOvplkMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEyMTAwWhcNMjUwNjEyMDEyMTAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQDKEfAj/h5b7D0cIlQgrlsWTnTY2GuEK3tyRFj5KpVMKXItVxkwah+wyvW9oZgV +zI2KAyjpxeaS+2OOd9yUidvgBuw+ACRkqLQN26ChdfBA4HjMwFowIQ1YU6axs/v9 +RvltEeEksmOUBsgYS2d+sRdsOR8noqjfyIqt0ChpdghTkQIDAQABo1AwTjAdBgNV +HQ4EFgQUU0TNPc1yGas/W4HJl/Hgtrmdu6MwHwYDVR0jBBgwFoAUU0TNPc1yGas/ +W4HJl/Hgtrmdu6MwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQCE4BqJ +v2s99DS16NbZtR7tpqXDxiDaCg59VtgcHQwxN4qXcixZi5N4yRvzjYschAQN5tQ6 +bofXdIK3tJY9Ynm0KPO+5l0RCv7CkhNgftTww0bWC91xaHJ/y66AqONuLpaP6s43 +SZYG2D6ric57ZY4kQ6ZlUv854TPzmvapnGG7Hw== +-----END CERTIFICATE----- diff --git a/common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml b/common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml new file mode 100644 index 0000000000..e78b2e1733 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + testshib.org + + TestShib Test IdP + TestShib IdP. Use this as a source of attributes + for your test SP. + https://www.testshib.org/testshibtwo.jpg + + + + + + + + MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV + MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD + VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4 + MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI + EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl + c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B + AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C + yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe + 3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT + NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614 + kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH + gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G + A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86 + 9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl + bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo + aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN + BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL + I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo + 93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4 + /SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj + Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr + 8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA== + + + + + + + + + + + + + + + urn:mace:shibboleth:1.0:nameIdentifier + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + + + + + + + + + + + MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV + MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD + VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4 + MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI + EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl + c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B + AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C + yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe + 3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT + NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614 + kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH + gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G + A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86 + 9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl + bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo + aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN + BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL + I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo + 93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4 + /SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj + Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr + 8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA== + + + + + + + + + + + + + + + + urn:mace:shibboleth:1.0:nameIdentifier + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + TestShib Two Identity Provider + TestShib Two + http://www.testshib.org/testshib-two/ + + + Nate + Klingenstein + ndk@internet2.edu + + + + diff --git a/common/djangoapps/third_party_auth/tests/data/testshib_response.txt b/common/djangoapps/third_party_auth/tests/data/testshib_response.txt new file mode 100644 index 0000000000..74def7401d --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/testshib_response.txt @@ -0,0 +1 @@ +RelayState=testshib&SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBEZXN0aW5hdGlvbj0iaHR0cDovL2V4YW1wbGUubm9uZS9hdXRoL2NvbXBsZXRlL3RwYS1zYW1sLyIgSUQ9Il9hMDdmZDlhMDg0ODM3M2U1NTMyMGRjMzQyNDk0ZWY1ZCIgSW5SZXNwb25zZVRvPSJURVNUSUQiIElzc3VlSW5zdGFudD0iMjAxNS0wNi0xNVQwMDowNzoxNS4xODhaIiBWZXJzaW9uPSIyLjAiPjxzYW1sMjpJc3N1ZXIgeG1sbnM6c2FtbDI9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwczovL2lkcC50ZXN0c2hpYi5vcmcvaWRwL3NoaWJib2xldGg8L3NhbWwyOklzc3Vlcj48c2FtbDJwOlN0YXR1cz48c2FtbDJwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPjwvc2FtbDJwOlN0YXR1cz48c2FtbDI6RW5jcnlwdGVkQXNzZXJ0aW9uIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj48eGVuYzpFbmNyeXB0ZWREYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyIgSWQ9Il9kYzc3ODI3YmY1ZGMzYjZmNGQzNjkzZWUzMTU2YmE1MiIgVHlwZT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjRWxlbWVudCI%2BPHhlbmM6RW5jcnlwdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI2FlczEyOC1jYmMiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyIvPjxkczpLZXlJbmZvIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj48eGVuYzpFbmNyeXB0ZWRLZXkgSWQ9Il85NzhhN2I2NDE5YTMxOGQ4NmUzMzE0Y2Y5YjFjOTEzZiIgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIj48eGVuYzpFbmNyeXB0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjcnNhLW9hZXAtbWdmMXAiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIiB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyIvPjwveGVuYzpFbmNyeXB0aW9uTWV0aG9kPjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YT48ZHM6WDUwOUNlcnRpZmljYXRlPk1JSUNzRENDQWhtZ0F3SUJBZ0lKQUpyRU5yOEVQZ3BjTUEwR0NTcUdTSWIzRFFFQkJRVUFNRVV4Q3pBSkJnTlZCQVlUQWtGVk1STXcKRVFZRFZRUUlFd3BUYjIxbExWTjBZWFJsTVNFd0h3WURWUVFLRXhoSmJuUmxjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3SGhjTgpNVFV3TmpFek1ERXdOVEUwV2hjTk1qVXdOakV5TURFd05URTBXakJGTVFzd0NRWURWUVFHRXdKQlZURVRNQkVHQTFVRUNCTUtVMjl0ClpTMVRkR0YwWlRFaE1COEdBMVVFQ2hNWVNXNTBaWEp1WlhRZ1YybGtaMmwwY3lCUWRIa2dUSFJrTUlHZk1BMEdDU3FHU0liM0RRRUIKQVFVQUE0R05BRENCaVFLQmdRRE0rTmY3SWVSZElJZ1lVa2U2c1Izbjdvc0hWWVh3SDZwYitPdnE4ajNoVW95OGt6VDlrSkYwUkIzaAozUTJWSjNaV2lRdFQ5NGZaWDJZWW9yVmRvR1ZLMk5XempMd2dwSFVzZ2ZlSnE1cENqUDBkMk9RdTlRdmpnNllPdFlQNlBOM2o3ZUs3CnBVY3hRdkljYVk5QVBERjU3dWEvelBzbTNVemJqaFJsSlpRVWV3SURBUUFCbzRHbk1JR2tNQjBHQTFVZERnUVdCQlRqT3lQdkF1ZWoKNXE0QzgwamxGclFtT2xzem16QjFCZ05WSFNNRWJqQnNnQlRqT3lQdkF1ZWo1cTRDODBqbEZyUW1PbHN6bTZGSnBFY3dSVEVMTUFrRwpBMVVFQmhNQ1FWVXhFekFSQmdOVkJBZ1RDbE52YldVdFUzUmhkR1V4SVRBZkJnTlZCQW9UR0VsdWRHVnlibVYwSUZkcFpHZHBkSE1nClVIUjVJRXgwWklJSkFKckVOcjhFUGdwY01Bd0dBMVVkRXdRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFRkJRQURnWUVBVjV3MFN4alUKVEZXZkwzWkc2c2dBMGdLZjhhVjh3M0FsaWhMdDl0S0NSZ3JLNHNCSzl4bWZ3cC9mbmJkeGtIVTU4aW96STg5NEhxbXJSekNpYVJMVwpteTNXODY0MEUvWENhNlAraThFVDdSa3NnTko1Y0Q5V3RJU0hrR2MyZG5XNzYrMm52OGQyNEpLZUl4MndvSkF0c3BNeXd6cjBTb3hECklKcjQyTjZLdmprPTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE%2BPC9kczpLZXlJbmZvPjx4ZW5jOkNpcGhlckRhdGEgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIj48eGVuYzpDaXBoZXJWYWx1ZT5sWEEvSGI2SlIxaW1UM2M1citrQU9taHVieVYvOUpqTUNzdkRJYlBEckxVR1g0aWFVbGl6c2d0dkdzRzdYOVpQWUxhc281U2ZlK1dTbVpKeW9tMGc0UU9HOWd6R3FIVGwybzFGMlJib0ZKS2FzaDZoQ011c2dSRmpJWElSUzdvTWJJTGxmcGhvcUN2c0pGdUpKY1FldU9SeWwyZmlrcUJSclhjNmwyMks2YzA9PC94ZW5jOkNpcGhlclZhbHVlPjwveGVuYzpDaXBoZXJEYXRhPjwveGVuYzpFbmNyeXB0ZWRLZXk%2BPC9kczpLZXlJbmZvPjx4ZW5jOkNpcGhlckRhdGEgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIj48eGVuYzpDaXBoZXJWYWx1ZT5UQWdqL1d3ait2b0ppdHNhUE9IdmFSUHN3WE5oaHhEMmx2Q0t6MXJzQmtYbmJheVhlNmJucE1CMHg5aWpFMVdqOFV4YmtJQmhBMTEwRHhhTjZabkhReUs4amI3U29VU09jQkovUGxrT1NJc21zcS94dTVZTDJrdC9qdTdwRDR6K3BpcUlJbWRSVXdkcEhPdXFSVUxzUUJNU1NCRU5QT2sxQkRjcWZxb091N1g1VFIyOHE2eEwwUlFoV2NqMHBoTW9BMHdZS2ZFU0tacmRDQVRLcXVFcUJTRGJzRThNekh1cndiUkVkcjM5YnpBMGRNTm1zVFBFWmpsdkdqSEJmdVRaS3VPWXBoR3lFV1FVa29PbEtMQ1ROQ3VQYnhaWjFZNi80SGhlTGpQL3pnSU9ieWdLVkdadmNBSWpyTmZXNi9iQkJ0MVJjbndKV3pTZU5XR3hnd2hnZTJDank4RVFTUHJxbWZYK29VSVY3NExmT1NhV2JONXRwK3dtbzgveGlzWFErRysyZzA1ZnBiZEVmcEhacFZhMmFZY24xYWRtYy9uK3p1U0w2LzRTVWtXcTIvRHpRTTBWQVNQN2o0MXNIc0wwZEt2ZkhiQk5BcFB3NzdyTWdXRkwxcjVRajZiUmVjSElkQ3ozamZLcDZtdzBURzFQaUxCc2FXZ2wzYnZVR0lJNmxOWTg1TStFWHpoUnNCY29uVjgwUWFnKzBoTks3OWNteFkvOFByOGZkdXErMmgva3UxUzN2REo0cTlpR0FPaG1MUUFrWEQwWGF5Q1hXa0x1MEFyTmdOalNjaU55UXY2c3RmOUx1NGdUd1lKWUsyV0lWYUEvTzdMbUl2WmxkL0thL2VSaHdJQnV5YnpmemxWSVA0a2VVVmpldDVZMnZUVzA5bG02bWQzV1dmOTYrb21yNGVBQjllWjBmaFRxRkV4UzJkQVFqWi9JV21ZNnNXQXJCSkg4aEhya0IxdVBrQkZYTVFNbSs5T3ZKMWpnWXZtdVJUcHZzc1ZlL1MwWDhIVlVSdUpZVDVBY09YNHhuTWw0QkpXdnlLbzFFUms4bmRZOXJTTkJscGUrSXRUQXY5ZDgveVdJcGlYRVRLWGpKbTlVVWt6NnIyclhDUkN0VDhWa3J2M29jZmY0Z09nUjlTSWR0b21hU2w5RG9sTjVFL0ljTm1DUkhrbTR0U1J4bDNrNzNiMy8rNUxJNDhHc3l2c1VsNlNaZ1dCNmhSdDZaNDNuS2Iza3czOWd1cjk0VStrcURuUndub3lyeDVoc3N1NWRVVFNURkJPajhENGk0SjZwYi9sQ3FvWDVtR3lNODVJWnpxQk1Sa3IzeldPUzg3SVhBQm1uVDZ6aE1NdEpzN3EwRG9WZUhQbExUeUlVRHFMZWlaMjREQzN3Z1BjNmh0STVNeU1EZG5OM0hLczdnT3lhY3ppV280c3I1RGJCc3FqaFd1ZzJIanJEQ2hXZUszY1NuTlcwbkZCT1RJOFZMSFJXK2lhd3ZJWnUwZGdSenR1aEFjQ3lCelB0SHUrSzZlZmgyR0lNT0NhVVJCUzA3eGRETkw2MG9jOTBZZUlmOGV5WDFqV3ZDaDJZSnNxMDRtU3AySUFFQzFQSS9mc3lleGIrU2lMWTNzR1FyNEQ1bGx6ZHh6VEpKa2tYd2c4dHMxeklvSEx2TDdkQ29BRVNmNDJVVERFdWo4bGtadjYrM0JkNkwrM3Z6WFFPb2xwQ1kxeGRodVFOQjhsbG5KWHRNMmdtb1NEcmRwN1JzZWtTM2hkOWkxSkxkYWR1bW9oM0hmd0x6d0c4SkpCRkpmb3k0cGtzM05vMWFyT0lKRVBzZ3ZCMzZZUitScFFvc2hHM01PS0EwTUphcDNJbE5yQlVjdmw4WW5jSGkzbE9CUWpySFcrSURVVHhxZG9jdmw0NTBscmVUdkVoZXYraUFLU0J4alhNclNDQytaOWFIS1BJR2s4UzR2dWtudURIMUpsdTNLSFBwNFA5bnhSMnp3V3ZldktkYjNLdjZ3emhidmlxR0pPY05CeUZzRVJvNkEwSVVNVHQvOFJkQzJkZndyTjNBTHZ4dktrTmE0c3dEcElqYkRkb3RrZWgzT29FTVRETGE4M1R6Ym9ROFdWcDJLbEJiZDlWdXVNRm8xVUtrN2Q0dFh4VXRnWkZ3YlZQUWNzU21TV1dud1QvMFhxdDB0Yk8zQ0lnNkVZOHFXaUxoWU5naGw4eUhXTGIxbUUzVWNoSUhhVDBoWmNXTXVLMmtNbVpNMlJJeWVJY3o4M2NmY2lSWFJUb0g2TzFPenF5ZytBVUZiendibXQxR2d3V1hnMTdZQVJxSFViaDZwdktUMGJFRzdPUDRMZ0U4ajQvdTJoQjFraUFnUXpQSC84RHVCQlQwdkdOeElnKzhNWi9ObUFmUWRxODBlcVNQTDVpczlGbkZVei9GUzYzdEVCb0xYZnk5VzFRUjBsZ3VUY3o5b3l1dkdPdmVDZUt4RE9pZU9ZSktFTTlhVFVzVjRGc2c5M0NzMTQvRkhXQzJpclRKN1J3Ymg4eG1WUzBqUnVFQUdBejdETDNaSkFiRElLUUM5ZWZ2QUYxRHREVjZiVEI5cnNlczdsOWlDS3RnSnRWRE83Nmc5M2tWSGwvdXpYbVhwQ0NveS9XYStvVFErek5WL0lMbXAwVnorRnhJRHROUmcyL0V3eENyN042RW05ZmRibHgrMjYyWkdEVWVKcXcwNjB3L0RDTThiMDJmc2dyQTJ4NUVvcjF4a0tmcXMvaFMvVlo5azlVRHB0ZVFaMWdrSEU5TEN6TzY1azFRRTNENDNRK21ReFM2cGRJL1BpOUUxVkRoN3pMemdpOUVscXFLVHZNYjhPQUxmdSthTStPWlFaUjN4L21UVjQycTNmQlZya2lYRHpNd0pkclhjNW5UcmxLSDJTSUZsV2JaUzNXK0tHUVpoVzJ4dVRzdU9yS1FiVDY2OEVRbEpNTEhuckxLQTFrV0NvalJKV1hqZGFxSGVrWURDWjlFcDZaRnZ4NjJzWlRTdWMrVlNjK3UxSnZjT0w3NTZzNEFFWnNjR3ZnbXNxbUx5MldYOHpLbGw2MEdVUlE0YlBHMHd3YVUxRGF3dnJTejZqTCtyUjVBTTlGdTFlQi9WaUZSVVo4R0prR0VIOTdRWmNKcjV6RFpXNnZkOVBZRnlTd0Zqa21rOWxuZ1NFdFNPZFFITmZTZXRxbXZrcS9HbTB2enlTSWVyM3N5OCszR2IzNjJHaHd5MFNCekp6dEdHM0dQWTE3NUxRK3FWcGJCc1Y0MmgvOExPcnFaUGpVY0RrUk1NMU1LTHBtOEpSeVRGeWM5c0NxZ1lVMzNBbTByRzlkTHgxeVVKaTkwUncxc1pDa0lYUFIxYWFRUlkyNFEyekhVNjNBNHZDQVVaaVBOSHdVZlI4Qzk0cDdDeks5UTlreFpoaGZ6bjRBOFdadVZMSjkvWWI3d1RmNWQvNlNmNVFXWUM2anAvbUVWMDAxRnVBZnUzcUZNNmNuNXpXV2xkR2tjaU5RcUJ3SmZoWk9oUnk1VjBEaW5rMDBjSVlncDFmVnVnWHFkR1grV1ZCQkJ5M29va0toYW05S2RPc1N6aEI4NXZyN3h6R2JsREVXVGhFN0F5U3duRUVVNnBjcXpxR1E3Mk9KLytWS2I4ZVNPVWxzQW1LZnZ0czgzTHBYR2o0dkdRR1UwNVptK2grakdWeEpjMTJSQW5lbUhYK1FiNVhJdGk4ek1CazJkT2I4NUVPRUlvVnduWXpmSmhqQmtpOFhYMUtWaTVWbDE5dmV4OExxQ2pLdW9JeUsrSFpVQWtGMmpqY01WenUybXEvM3JPblJvTUhqVEszbFpGZm53S1E3WUxqd2dlVk45QnBmNm1Zem5Bb1RhVG1kQTUza29ocnMrVExuK0toUEpCRFc3Rml6L0ZDbVhzU2dJQ2tQcHAzVnJnQkY0N1ZDUEtPQi9yR2hPaklKd0V2bjgvZ0o1MU5qSmY3NkI4OUxHKzhLOXZpV2ZCeDRvMGxIczZLRmtCSk5RSTF4TCtVRGREWThURitlNXFtaVg3TTh6QmVsQjJlalZKaW9DRkhUcG9mRlZyT0kzTTlGcUk0Mk9KYVdrQytFTFJCaHR6dmJxaisrMWNOdlArcXVKQkRseTZNY2d3SE5BbzlhOHZIcjNjTmRHdmdjVGNFemx1aHpXOW9wb0dSTERPbHRUT0RqOUNQeUVXU2VablFxV2pHRGZiZkRkWm85bTRTWmUxTjQzNUNZYzJBY2VtR3JDdjAySUhyNmgvR1dZMTRFRlJ6T3crTFQ0Skg2TDBzM2w5V1JlZVlvR2NJc2RxYmhrN1Y0OXF5b1lBKzRlb3IvUi9VRzhZaFFYVzJJckQxaTVveDRGTEtXa0Q1UWtKRWU0VmpyNUVRTTBNTHJzNnE4YW5URklITGV3YXE4V0lnanJLS2FtRWloU2tGK2RnMHdScllTeUpuSzdySUsxWi9GQTlPakFUeGlIN0Y1TWcraGhlbXozYlFrU1FTaGN2T1lVSEdjU2sreHQrMXBuSG1lc1ZZTUlCb2d4S3JkUy9yLy9LM0lxdWR1Wko1bE5oVElXZ3dISkpiTVBHTG9mQUJybTlwZUFCVG5mVUFkcDIxK08wQmluVjdYZVg4RXFmVGhVejhrY2Z0MmgrSW5hajZwd1lDdGJ2MmE4dld5UXFKM0haNnBiZHV3bUJFVzhMa3Y2Qm44Yng5TllZMTdyMDliWDNCbmF6QWpGVW1EN3l6R3pLSnR2c1ZVOUw0RmMyU1B4ZUpQVjluQy91c0ZKc1ZlTEFMMTdqVERyV1k3NXhRdkpDVkJGNFlIS0JHaiszSk5WSWRudElOam5DTWhRak1CZU02V29RRkcyei9IQU9hU1lnaXlJSnlaNzd4MW1UYUtuTkNvSTlzZ3JNZkJzUE9mTXJUbkVGbTR5SThERjFGSTh4VnVONnpBcWp2dGkzNVczRGIzdG9Wa0pYVWk0OStKZzIxRWwxSkNqcjJoVmhBQjN5dE5kV2VnTXlTSFRnY0tRVVdRQmt3WUlEbUphdjY5am9udE9RdWpVbGFIM0lBRExHMUpPSWxuREduN0F2OUhkb0JzdnA3MDdLN20xZmJwOUxZK3NCdkwzcXdNbmpZbGhuZHBmYjdVbGxEajl2SCtuNDE5Z0FWMU9GUkRmVHVkVkRpdEFlQzI5ZWRjSmFGZXBYbmpKeHpvTzNqZHFrVTBiMWRmeEo2T1BCa01XSzJKcDZqTmllVlF6emwzRGJWMnRjcTNpekhQVmRySVZ2eEFqVWl3eENWK1VLTzZmMmlXaG9jQjhsWWE5U2xPOTRxd1Y2SkxSbDlIU3pFbDZtQUdRKzRCaW90aEhleDd2ODlGYnJ3eW00UjkwOGl4cU5odzNCc25wcHkyVzhlQXJtcENxMTRHdjlpM3R4em1mS1c3allIV2xWT0JQZFdoSnQ5NTZWbmliV2pWaVBBME9WOVNRWFZ6L2tpSit1WnZzT0FPY1h0YVRDaTZQL0dDMHJyRmhLc1paQW82ZE5paUF0N1BtVzduQjc5RDU5SzRBd3RNaW5iV016TjRQQzFGcHA5eklQTlEvU0laY1IwN0FjMnJ0ODdoQ0JPNUNIY0xhL2EwZDcxZDNZenEzNFlSWDZRYmdRY2taVytLN0FTbGpUcnFQczBHUXo3eFVRVjY1SnRBUTZvbjBxWGwwQ3drdUFCR0gvVVZ3TXpTalBuSnh5WmhQczN5NFhmdTVOUWhabkQvWWNtenR2Y0tkanBvMExSZzkxZnN3QjQybS8vL282VTd5K1hJNGlXMDMxRFQ0R3ViVWV4NW5mZTByWjdlRTRMaGJkaWJiWVRkTDFESHJuTGhYUVpDaEFRSno5SVV1OWYweEtMY3ovR0lubzRQRC9VK2hKOVkrb3FtelpGM3NhVXpKNTRJY1lGeEVROGo5L25nOTVpWHc0SWM5aXovZmY0Wm9hN2hJMTUzalBMNHFOcS91akphYXd2MXpxRlFhRVJYZUU1UyswZTMwaC9UKzByMmMxMTJkYndZdXljN2UwNi9RVnJpckdSRnQyZnRHUFN2VVRMdTJKVzRBUHVDN1NpNmpYWjQ1QlArenJTNzJteWdQQm9LTSt6N2RYTWMySmhQTnhQcmlGRVJlUmJnQlFFM1RSckpMSVpaR3NPczNJbFdBdGpLRTJaVFd0bjNJQmFQM0loY2sySFJ4MnRXRDJYRnYweTQ1bXhlQnkwMXY0cjAwQjJnMW9JVlYvZkgvajZUQnFLU2VENjBWRGZ3OXMxeXU1VUVhbzRicy9oZmFjWVpZZzg1Y2daS1QrTkZCeHBadG84M3E3bzRBeUNMQTl5dzQ2ZFRzdUlSYnBsMC81MVp0R0hDa29YMkdOa0JRc2pFWTVSNHVoZGRJNnBFSkxuaWNvVnpGS0dsTFErZDJMKy9odnBldHUxMGFTVHhEMmJqWlMrUVlHM0VLZ0VvbDZveThVTHl4bjRMb041bk1zV2d3N0p6NDJWT0V1ZHFENHY0ZVFUdXB0NkRpQzhvRzVzaVdWZER2amZpUHdwN1l5cEFZOE8reFBLOVgzWkU2bVR6aEV6TGxud0Jtc1RuQWpjMTRsYTVha1FBdHJRaXZIY1Vmb1pCcWtQKzFqTmdOZ0lYQlB3WlRBMHh5aEVuYlRMK3JPd3dzcUEycjdMbUlTaFpNa3V0cUQzdC9GbmNBTCtkaTJvQ2pBOVRNdnVwMnRqNm5FaXNwbXpYcmE1WWVnUlVjOE5UNjJxL1dXU25aKy9pb0hUMWFjeldJWG9sN1JhZ0VlQlRlMkFlVVEvam5wd2RwR3gzUldLeGIrajdtN0RuaVpoSmlzZUw5a2JleC9RQUFBV1VxajRBQldtdEptQ1QrUy9KVit6R2FGaU4ybXFzcUd0Mm9qN2w1UzRmMkozcXdxaEpNbXVDRGlteUpzQ0FzOVlNQVFmOXRFRVNSa3pKTy9wOHdEamlFbGRZRUdMNkl2RHJkWHFERzhSTGJkQnczTHJxeUJQOEYwU3lzWmlHYWdXY3BSZGY4NmJKdXgra2gxOXo4eldvSWM4OUVBK2JCVm9ON2M5TEFETEFPbzlYY0pqdllJcFRiWXlJTk1iOVpCRGZlb3d5ZkViZ2Q2bGxiK004MzB5SXFIWVEvaFl0dWpWaDhXazdseG1mMjJzL280eUp3aHNYQlV2OWVWQkFyTDVmbUxPM3NjRmdBejRsR2cvbEN2cThSS0JRaFFYd29CNTM2aDdkRWFnWGxqQk41WGhZcTVmNkZhK0xRRkFIRnlyM2VHT3RZNjJsU3NMUE9Sa2VGQXl6RTY3bk1BZ3lrb1hEUHRrUE9hd2x6bW4zdEVOT3FMMDVRWmEzTkFvZ1RtYVZLYmlUTk1RVVF5M2JQNElYdDE0RlNEa3pweU5pREptcE5yeDJ3cjlVNmJvT08xQTl5eHFMZ1ZHdVNXc1E5Y1ByTm4zV2VZVXJvTjhhZkk4b2w2bFNuODgxaEUrU2I2OU9lZ2UxY09RYUduTUJ4WlpiVThzVDJxd0JsVWFzc0dYWlBOMUU5b3M3UUh5bzJKcmxkU0xrWGpsWWpxV3I2SkJtYmxiRXpRWXg4clA0TDVDUG5RME96WjA0MDV6MklUZXBzSUpHMW90Wng4VmIyWGwvSDNqajNja2F0TUZLU1ZrNTdBQkVIRUp3a3pmbXB4Y3Z0SzBjK081MGpPZUpTOGpJRUhnZUJJSkJTNzRRb0F2VGcvbW5NeTVHVTRGYllHSHZHMDBzYXdBbkNVbW90cm13R2dzcGhkM2orNFNuYm9HZ2Mvd0NzN2FTM0NJcHBBbC8wNTFCbWZhYk8rV2JpRDl4c3dzaElxSjZua3RMSk9mWmM0NjZVZmVpTkVJeG04cU5yQncxTlBrc1lXOWtjK2FtM3diTk1PMVp0N3hIYXU1M01odDVFRExIUjZ6a0wwcGFjY0ZuSndCNXFsS1hSN01Lb3huSmo5VTQyS1FOTFRQditUL2NlNG5oS2dIc2dua00wbU1YelF6Tkx0MDBDdkdGWDRuNnE4Q1JCRnY2SHFSVVdwWGFGdWlrc3Mwa1M3RDc0TThTQ2VnTmRuWit4Z3BLQ0IzOWZWWDEvYmMwUjlDbWN4M050d0hFYXNkZllpUlRIcFI0bGJndG1RUUcrYWNTdVhJWVRaSWFTRzlLWlZVZ3oxeFI4TVhMQXAveDRHbGMzaGNCMURnZ0cybE82RnNBTUhBTDExU3NySk1RUmZsUmxJOU0zNFB0SHRTY2pqSGcwcHRMT3JCOUI4c0NqMEk5bG1aWHU4cW1pMEQzMmg2VC96MnRvOFJMcmExVldZblp5NW9nYmhiQ2E4Sk5JZmNGQmJDTytpUXZlN2xGQ0RXZGJncDZJallGcXR4amdGUmkzVURaMExtQjVqSk9lejA1VWRNT3c3SjFudkhvSDM3RGZFclE5VWZKeUlpRjdGNzR4c2ZMSFIxSXpGenB6YnIyM2QwU3c5OXlCNjlDa1ZtdERCaXo3aWFmaVpXYzBZU2svajkrWDR4NENwU1diOVRMRTY5djI1MStjS0xzZzRPT2ppUHVSYTRnL25oaFB6eGN5bGE4WjNYb2s1ZTdJRy9BbWtCNkEvL29pRFdEKzBzbXJGOUI4VlNnVENiRHRNdFNyUStGUVBzZHBMdG52UndOY0pGcHJEaVVHN25FUTdMQnhoZHhraTZ3dXRuTnY1b2dsUkdheHV4Tk9XYXZaNGUxOTVEaXlVQkN4cjM5MlRjUkxKNExIOE1rTUpnNml2dmlNYllqeTBwSVpLVGJGSjRzOXZ6WlFwVjZVd1FlWEM0TGRWd1E2dlltb05Mb2JCaUlsSE9hUUUxM1hpTEliVTU2aEJVRnJqSHhiNzNRUm1SQ1poeWZpVUNSdUg4YWZjOEpyMFFkMXlJYTJNczd4bU9FczlCWmFwejdTR01OeURXUXlIY0Q5VzgvQ0d1OFlhUldRaWlBQTU4MDRlQWF6MmJnYUNuR0wrNHdZeXpYOXNvWnVGWFZ2eklDbVk3bVF5c1pVMllqUVZoRjdHZWFGTDNKL2ZGTTdqOVdiaThjZG1MNzJENGdLVDVXUmpKTGNVY01HNHNXZTJoNkJncWVybDNJeDFmMzRteGZOSUpFVnlLTTVzVW54a0kvTTFORDU5M2g0UWJGYWVUTjZkc2NLenRrK084OW82Rnhma1dvUFZYRlZJTmF0WFhoSVJHU0cvKzhSQXNNYi81QUZMb0Y5U2x6YytXaWkxQkQ5RGhwZFQvd01ya0lDUWdzVklMbUt4bnhobXZhS3pvcGEyaG9GdXBiL3A2Z2hMQVZpY2RROHZJa3Y4U0hBeGZJc0sxaFZXcHNRMUl0SGw5N3lnSHNtQ0lkR1NJcVNiaGwzdW9CNXhWWWVGV0FObXpYQ3g0OE5oTlc2SnNHZ1c5RXp1NjdlY2Z3VUZlUEZpYXpvbWFPUTFRc3ZjYVhHVGIxWjA0UzdQay9OZmlCWWFJSkoxbGlIMnBaeDhMZGZwZTVuRENtbTlYTVZHbUY2SVdWVjM0QW1uK1B5TlNCcWxzZDZyQlNjWVRISm43ZElUZm9Zd3JvZjZvNVBUR2lwUHNlYXNHbDJoWFpCMWRYc1U0aTJKVDNLenVTeUl1RVU2Y2Z5M242T3duNmNjV0NyUFNLc3Vwb2Z0QithenZpZzd0bUFvRHlFTHN4eUE1dzQrZ0RmQm1BeStwNG02NnVNWjFUWEJiSTZhZ2RzVEpteUtHMm1NNFZrUmo0Z1cvcW0yMDNISno0YlVJN2dlcXNHQktNbGdPR1ZHRDRGZGlPaWxPbkNxeGo5NmFiY0Q5SnVmeVJUYUFmSC9QbGJaaU5mOTVORWtxaXZmNC9CN2lqaEdIZlUwcXFNblJlYVdackFuK2M3RlByc2h6UWIrMC9OUSt1dWRMbGJ6czZHd1IyUG5sNmlNdjRsUFZ3d1UyTWprYzdMUzY5ZzZpSnBZZytsbDM1N0toSWhPUTRpMkNzVXhXWHlVZy9VZCtaQmhSTDFhWWxWQ1NaK1VRZlJodVNCREdkLzNrYjR0azNvUVMzaVgrS09EVFB5RGwxaTUweXhZdjJQZjRPdG1QR1RVYkdUeUJQd1RPUGN4TUR5dDBjWjAzdVdtY0MwblVrMllnWnlLeFFXTTBSaHJVK2pRUVZwL1BWY1NRdGYwREtTSDZzUEh2M1RlbmJMV25sdzNKaUlveEhmRjRJTGZhY255M0xPTGFxcXN1QU95REJmMnQxdlF2UTdkMmtZMGhwdUF4Y091c0I4dXpmdmQyTWtramZwVHFNWGN4TndNbUxWYXRobGVpQUUwRVBFaTFDanZuWVgyQUE5a2pwLy9oRlZaaklvK0ZYRzlQRE5ObVdDTVRPSXQwcmZoRzFxNTFDbG1sQW41Mm1vQ016d0lNOGlLVXk0MDNPdElBQkgrSzhTbHQ4aFpEaHRmRDBoR0xVOXg0TVBaUmhxMmdRd0tCL3Fpd3BnVnNWbDhrNmVxUnZpMjFjeWFJRklIRVQ3L0ljT25zWU1rYnh2azBQdGtIL0VUSHhsQjVqMUJ6TmJhQ0hJZ291bWJwVDdadlEyWjRESFhXNXJQdU55YVN6MEF2bTZ3dHljMDVxVTJVbXRWMXFOL1NOb203SkFkbms5ajg1TFUydUh1bVdqZHNLbVdFNXFLdWprcW16N3pEOE8yVGhuVzc3SWZQRngveG8yWXlaZUU2OXRFVlJCZ0dHUFA3R1NyaTVMaUl2TzZwTkZMak96QnRJTERnMUxFQUo1VUlqNjd4U0VzUjRIRW9CVEM1NHZKSUFoLzMxak0vUE96VFdkZ01YcXhlSlhGenE3ZHRqTWcrLzNSY0hIVm9LRGd3NjlrMmNnYXpwRzIwdVZuMTZkUUJYN0Jpdk12TVc3OStUb0xPcm0ydXhrN0VtUkNTVFUzMlFBbVZ6Z09mZHRKUDF2TWk0SU93aDVSYW51YWh2ekhIWDNHR3pRTkx4a1RiUXFFUjZmaHV0cEVVcEFOWmVMbnA2UzVaMkIwZGtVZ1BSeGc2NUpXY05OS1BSc0NrWkkxK1NTU1haeVMrOWFrUzhtd3c0NXRzdHRaTlZSR003RVh4YjUrU0FkMkwybFpLbnlNRll1M2lSZWcrSnZtMUIxVFZRL3lKejY0MFlLUzNMYitIQ3hoSmhTTGlhYk5Kb1Y3V1VuZTBGekt6bXVDdGRtR3BIWkM5cXh1SGdDcHRuVTJVb2oxNWF6Y2dBWHAyZjF6OHVUVCs2dXpxTm5lOVdzUHlwdE9NdlFhNDJzdmtZNlU5TkcvSi9VUTdRRmUrL2VUNnp6ZGQ2a2lSNzA1ZXBVeTA5MkpIekQrOWJDMmxtYWk4RGo0U3o0MVhUWmEvTHYzdXQwOTk1L3ptb3Z1R2M2VTdXMmFTSG9LcUs4dXRKeEliVmtKb3hYbFFuckVIMlM1YXc3WG1lREtwbzVwYXlLTU56eXhJeGxoTmxsRUVqbVdnODFBajRLbmFsOUR3N09sVVhrWE02eWFqWjNqaUN2Uk9RQVRVbGVid2gzWFppTnJtc1J4b3lWMVg5OWEzaWN1THlpRUJBVkRYQ0kwcTJqYWdCc1h4L3Nrci9oa2lhYkZqSCs3MUVvVWVjRm11RmhvcGxMak1td0tsSWFwTk02NENUaGRpdGdoUklUTFVDb2ZGYUQxOHd6bldlaEZrSFlVUW1JWXRFdzJYb005V2FMN1Fod0ZoeHVhL3FScFRLUTMxOXBNWk1qN2UrMlhaYk93Y0VLYnE2MFhRSllHaDNGMTUxSENYVW9lRHY1Nm42Yk52ckU2YVdkdkFEa3F1RWw3RTNYSmdueTlXOVJCSnMvMEo4QkxjNnlPOWk3V3ZIVVFwN3JRZkhGWklMbEIwSHgrcXhVWG1LWG5KZTczcGhSY0tTNjVIb3d4WjYreHppclFHTXhtci85R05VUEd5TVROR1ArRGlKbW1La3FMT09jd0NFSFNuZnl1NmExN24rM1l3U1g5NitNbkVmdTIwckhPb1pqVVZuT1ptRkNFRFFYZ0s3NWR5b3BzclZlM0pLZHBmYVFOVDllT0dvNk1qZDNiS3UrTVZDR3g4TitrNWE3ajJ3cGxtTVhuTVB6Q1JmcEkzd1pQeTRQc1VhVTEyS2xvaUZFT3poZTJGMk9EYUwxU3lGY2RueWhyRTgvcnZSc3pVV2R3UjZEai9LaVh0OC9vSm5aY2R5bDdjKzZSNi9HenRlSHZqY0oya1BnUk9nTURpUDFlTmVkNzJ0UmtzQjllNXZsczNZRnNrNWlBa1hiNEw5MjRibGx6c1VXQXBCeFRkMXFLS1hYZzhQVFhqanNCYTQ5dnNBNUVvaFZmbUUwMnR2NHNjQkZMaU5VcXdZY0dxZ25KbEFQQ3FaOGdnT1E3VnhWZFZuMVlRZkJPMm1vVGdaU0wrQ0NUeE9qRXlwSkR2RnM5Z01JZ0l3V0hBSFhNOGdQanZucXBsN1d3bXh0a2l3aUdEU1V6bjFscHViTEV2czdxYWxNZ2hieXVuVzRlcU5WdlBmUmlNMlllSGhheUF6dFF5MXFXaTJ6cGg1VWExWjFKQVREaW9FanBFMHNTN0FlZEdPNmovVzFmZDV6R1pibHlIN1M5VDY2ZEU0OHRjaFo0R0YxVmsvZlFYSGRRT0lkb1pabUE1T05yS0FuQWVWOEh2QUFxa29Nd3J4ZGFXN1lYMWNHSitXTUdOR08xU0VLVWczMldmUG1pY1hLTG9JWDBEUW1OYmd3TjNFenVtMm1uNjNNUTN2amVqckVaRnpsUmhKbStDYVMwTGJxVDVIRkRWWVBOb2xOZjl3aEFxMzAvSFZaaWt6UHhWM3lJQmlrdmE1QTFuSmZWcFhZRXFNa2IvaDR0V05iNXN3U2pucUVEOHViOU5Pc3RIcmVZaE9RYnBLelVHU2RjTkF1K1IrVDJzUlJBQzQ2TlZadmZOcVZ5UU90YVh0czhRdzRXdEJCaXpKS0xtLzBYd1oxMXdNZmtqci9nckg0cmllRGEwZTd1S2tQV3pZOXJqc2lpQ2MwL1ozUjBIM1hLNTVTOTB1bjVIVitRbkt1eHlyYk03UGhFUXVnRVE2TlQ3cVhvV1U1b3BSL1p2NmloSEhYMzB1ZStFYW53SmdlUUN4WGpsTlZEUGY2WEpFNXQ4eWxLMFd5andOQWZpdzdiZ2F3MVk5YVU1MU5ZZUc3QXo5RldXSWhHVGpmSXZMMThVY0pQWndib1hqNHZHMkdyc01YRDNMUjFKV2xJZGIwL29uTndvOENZYncrOFlhc3RtOUw0dzlDQUQzTHZRVzBBQ3dkS3hMQ1NycW5UWmw0YStTbHUvNXFTN2RIdzlBSGpMVnFFRGtLWU9qNnFEVHdpTWFzNkR4bEt0RmxXa2wyaWZFaFl0UGVERmg5d2ZOdHFhUjdBRFZtcWQyL3p0aFpzcmlaTVpvdmluMlJSWGptSTZsRGRkMFhScFhNV2hWQkUrN1JKZ0VRZTNzV28yS3d2TUtZMS9PcWVXYkxndUZZSVZTZ0w4NkFTU3g8L3hlbmM6Q2lwaGVyVmFsdWU%2BPC94ZW5jOkNpcGhlckRhdGE%2BPC94ZW5jOkVuY3J5cHRlZERhdGE%2BPC9zYW1sMjpFbmNyeXB0ZWRBc3NlcnRpb24%2BPC9zYW1sMnA6UmVzcG9uc2U%2B \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py new file mode 100644 index 0000000000..689066113f --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -0,0 +1,229 @@ +""" +Third_party_auth integration tests using a mock version of the TestShib provider +""" +from django.core.management import call_command +from django.core.urlresolvers import reverse +import httpretty +from mock import patch +import StringIO +from student.tests.factories import UserFactory +from third_party_auth.tests import testutil +import unittest + +TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth' +TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml' +TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO' + +TPA_TESTSHIB_LOGIN_URL = '/auth/login/tpa-saml/?auth_entry=login&next=%2Fdashboard&idp=testshib' +TPA_TESTSHIB_REGISTER_URL = '/auth/login/tpa-saml/?auth_entry=register&next=%2Fdashboard&idp=testshib' +TPA_TESTSHIB_COMPLETE_URL = '/auth/complete/tpa-saml/' + + +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') +class TestShibIntegrationTest(testutil.SAMLTestCase): + """ + TestShib provider Integration Test, to test SAML functionality + """ + def setUp(self): + super(TestShibIntegrationTest, self).setUp() + self.login_page_url = reverse('signin_user') + self.register_page_url = reverse('register_user') + self.enable_saml( + private_key=self._get_private_key(), + public_key=self._get_public_key(), + entity_id="https://saml.example.none", + ) + # Mock out HTTP requests that may be made to TestShib: + httpretty.enable() + + def metadata_callback(_request, _uri, headers): + """ Return a cached copy of TestShib's metadata by reading it from disk """ + return (200, headers, self._read_data_file('testshib_metadata.xml')) + httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback) + self.addCleanup(httpretty.disable) + self.addCleanup(httpretty.reset) + + # Configure the SAML library to use the same request ID for every request. + # Doing this and freezing the time allows us to play back recorded request/response pairs + uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID') + uid_patch.start() + self.addCleanup(uid_patch.stop) + + def test_login_before_metadata_fetched(self): + self._configure_testshib_provider(fetch_metadata=False) + # The user goes to the login page, and sees a button to login with TestShib: + self._check_login_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL) + # The user should be redirected to back to the login page: + self.assertEqual(try_login_response.status_code, 302) + self.assertEqual(try_login_response['Location'], self.url_prefix + self.login_page_url) + # When loading the login page, the user will see an error message: + response = self.client.get(self.login_page_url) + self.assertEqual(response.status_code, 200) + self.assertIn('Authentication with TestShib is currently unavailable.', response.content) + + # Note: the following patch is only needed until https://github.com/edx/edx-platform/pull/8262 is merged + @patch.dict("django.conf.settings.FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": True}) + def test_register(self): + self._configure_testshib_provider() + self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. + # The user goes to the register page, and sees a button to register with TestShib: + self._check_register_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_REGISTER_URL) + # The user should be redirected to TestShib: + self.assertEqual(try_login_response.status_code, 302) + self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL)) + # Now the user will authenticate with the SAML provider + testshib_response = self._fake_testshib_login_and_return() + # We should be redirected to the register screen since this account is not linked to an edX account: + self.assertEqual(testshib_response.status_code, 302) + self.assertEqual(testshib_response['Location'], self.url_prefix + self.register_page_url) + register_response = self.client.get(self.register_page_url) + # We'd now like to see if the "You've successfully signed into TestShib" message is + # shown, but it's managed by a JavaScript runtime template, and we can't run JS in this + # type of test, so we just check for the variable that triggers that message. + self.assertIn('"currentProvider": "TestShib"', register_response.content) + self.assertIn('"errorMessage": null', register_response.content) + # Now do a crude check that the data (e.g. email) from the provider is displayed in the form: + self.assertIn('"defaultValue": "myself@testshib.org"', register_response.content) + self.assertIn('"defaultValue": "Me Myself And I"', register_response.content) + # Now complete the form: + ajax_register_response = self.client.post( + reverse('user_api_registration'), + { + 'email': 'myself@testshib.org', + 'name': 'Myself', + 'username': 'myself', + 'honor_code': True, + } + ) + self.assertEqual(ajax_register_response.status_code, 200) + # Then the AJAX will finish the third party auth: + continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL) + # And we should be redirected to the dashboard: + self.assertEqual(continue_response.status_code, 302) + self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard')) + + # Now check that we can login again: + self.client.logout() + self._test_return_login() + + def test_login(self): + self._configure_testshib_provider() + self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. + user = UserFactory.create() + # The user goes to the login page, and sees a button to login with TestShib: + self._check_login_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL) + # The user should be redirected to TestShib: + self.assertEqual(try_login_response.status_code, 302) + self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL)) + # Now the user will authenticate with the SAML provider + testshib_response = self._fake_testshib_login_and_return() + # We should be redirected to the login screen since this account is not linked to an edX account: + self.assertEqual(testshib_response.status_code, 302) + self.assertEqual(testshib_response['Location'], self.url_prefix + self.login_page_url) + login_response = self.client.get(self.login_page_url) + # We'd now like to see if the "You've successfully signed into TestShib" message is + # shown, but it's managed by a JavaScript runtime template, and we can't run JS in this + # type of test, so we just check for the variable that triggers that message. + self.assertIn('"currentProvider": "TestShib"', login_response.content) + self.assertIn('"errorMessage": null', login_response.content) + # Now the user enters their username and password. + # The AJAX on the page will log them in: + ajax_login_response = self.client.post( + reverse('user_api_login_session'), + {'email': user.email, 'password': 'test'} + ) + self.assertEqual(ajax_login_response.status_code, 200) + # Then the AJAX will finish the third party auth: + continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL) + # And we should be redirected to the dashboard: + self.assertEqual(continue_response.status_code, 302) + self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard')) + + # Now check that we can login again: + self.client.logout() + self._test_return_login() + + def _test_return_login(self): + """ Test logging in to an account that is already linked. """ + # Make sure we're not logged in: + dashboard_response = self.client.get(reverse('dashboard')) + self.assertEqual(dashboard_response.status_code, 302) + # The user goes to the login page, and sees a button to login with TestShib: + self._check_login_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL) + # The user should be redirected to TestShib: + self.assertEqual(try_login_response.status_code, 302) + self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL)) + # Now the user will authenticate with the SAML provider + login_response = self._fake_testshib_login_and_return() + # There will be one weird redirect required to set the login cookie: + self.assertEqual(login_response.status_code, 302) + self.assertEqual(login_response['Location'], self.url_prefix + TPA_TESTSHIB_COMPLETE_URL) + # And then we should be redirected to the dashboard: + login_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL) + self.assertEqual(login_response.status_code, 302) + self.assertEqual(login_response['Location'], self.url_prefix + reverse('dashboard')) + # Now we are logged in: + dashboard_response = self.client.get(reverse('dashboard')) + self.assertEqual(dashboard_response.status_code, 200) + + def _freeze_time(self, timestamp): + """ Mock the current time for SAML, so we can replay canned requests/responses """ + now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp) + now_patch.start() + self.addCleanup(now_patch.stop) + + def _check_login_page(self): + """ Load the login form and check that it contains a TestShib button """ + response = self.client.get(self.login_page_url) + self.assertEqual(response.status_code, 200) + self.assertIn("TestShib", response.content) + self.assertIn(TPA_TESTSHIB_LOGIN_URL.replace('&', '&'), response.content) + return response + + def _check_register_page(self): + """ Load the login form and check that it contains a TestShib button """ + response = self.client.get(self.register_page_url) + self.assertEqual(response.status_code, 200) + self.assertIn("TestShib", response.content) + self.assertIn(TPA_TESTSHIB_REGISTER_URL.replace('&', '&'), response.content) + return response + + def _configure_testshib_provider(self, **kwargs): + """ Enable and configure the TestShib SAML IdP as a third_party_auth provider """ + fetch_metadata = kwargs.pop('fetch_metadata', True) + kwargs.setdefault('name', 'TestShib') + kwargs.setdefault('enabled', True) + kwargs.setdefault('idp_slug', 'testshib') + kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID) + kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL) + kwargs.setdefault('icon_class', 'fa-university') + kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName + self.configure_saml_provider(**kwargs) + + if fetch_metadata: + stdout = StringIO.StringIO() + stderr = StringIO.StringIO() + self.assertTrue(httpretty.is_enabled()) + call_command('saml', 'pull', stdout=stdout, stderr=stderr) + stdout = stdout.getvalue().decode('utf-8') + stderr = stderr.getvalue().decode('utf-8') + self.assertEqual(stderr, '') + self.assertIn(u'Fetching {}'.format(TESTSHIB_METADATA_URL), stdout) + self.assertIn(u'Created new record for SAMLProviderData', stdout) + + def _fake_testshib_login_and_return(self): + """ Mocked: the user logs in to TestShib and then gets redirected back """ + # The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response: + return self.client.post( + TPA_TESTSHIB_COMPLETE_URL, + content_type='application/x-www-form-urlencoded', + data=self._read_data_file('testshib_response.txt'), + ) diff --git a/common/djangoapps/third_party_auth/tests/test_views.py b/common/djangoapps/third_party_auth/tests/test_views.py new file mode 100644 index 0000000000..11659011a5 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_views.py @@ -0,0 +1,64 @@ +""" +Test the views served by third_party_auth. +""" +# pylint: disable=no-member +import ddt +from lxml import etree +import unittest +from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase + +# Define some XML namespaces: +SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' +XMLDSIG_XML_NS = 'http://www.w3.org/2000/09/xmldsig#' + + +@unittest.skipUnless(AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') +@ddt.ddt +class SAMLMetadataTest(SAMLTestCase): + """ + Test the SAML metadata view + """ + METADATA_URL = '/auth/saml/metadata.xml' + + def test_saml_disabled(self): + """ When SAML is not enabled, the metadata view should return 404 """ + self.enable_saml(enabled=False) + response = self.client.get(self.METADATA_URL) + self.assertEqual(response.status_code, 404) + + @ddt.data('saml_key', 'saml_key_alt') # Test two slightly different key pair export formats + def test_metadata(self, key_name): + self.enable_saml( + private_key=self._get_private_key(key_name), + public_key=self._get_public_key(key_name), + entity_id="https://saml.example.none", + ) + doc = self._fetch_metadata() + # Check the ACS URL: + acs_node = doc.find(".//{}".format(etree.QName(SAML_XML_NS, 'AssertionConsumerService'))) + self.assertIsNotNone(acs_node) + self.assertEqual(acs_node.attrib['Location'], 'http://example.none/auth/complete/tpa-saml/') + + def test_signed_metadata(self): + self.enable_saml( + private_key=self._get_private_key(), + public_key=self._get_public_key(), + entity_id="https://saml.example.none", + other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }', + ) + doc = self._fetch_metadata() + sig_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'SignatureValue'))) + self.assertIsNotNone(sig_node) + + def _fetch_metadata(self): + """ Fetch and parse the metadata XML at self.METADATA_URL """ + response = self.client.get(self.METADATA_URL) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'text/xml') + # The result should be valid XML: + try: + metadata_doc = etree.fromstring(response.content) + except etree.LxmlError: + self.fail('SAML metadata must be valid XML') + self.assertEqual(metadata_doc.tag, etree.QName(SAML_XML_NS, 'EntityDescriptor')) + return metadata_doc diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index f66ea48a3f..5d1a1f38c2 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -8,6 +8,7 @@ from contextlib import contextmanager from django.conf import settings import django.test import mock +import os.path from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache @@ -87,6 +88,33 @@ class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase): pass +class SAMLTestCase(TestCase): + """ + Base class for SAML-related third_party_auth tests + """ + + def setUp(self): + super(SAMLTestCase, self).setUp() + self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain + self.url_prefix = 'http://example.none' + + @classmethod + def _get_public_key(cls, key_name='saml_key'): + """ Get a public key for use in the test. """ + return cls._read_data_file('{}.pub'.format(key_name)) + + @classmethod + def _get_private_key(cls, key_name='saml_key'): + """ Get a private key for use in the test. """ + return cls._read_data_file('{}.key'.format(key_name)) + + @staticmethod + def _read_data_file(filename): + """ Read the contents of a file in the data folder """ + with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f: + return f.read() + + @contextmanager def simulate_running_pipeline(pipeline_target, backend, email=None, fullname=None, username=None): """Simulate that a pipeline is currently running. diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 7a10263b01..1983dd99b7 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -198,7 +198,7 @@ def _third_party_auth_context(request, redirect_to): for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": # msg may or may not be translated. Try translating [again] in case we are able to: - context['errorMessage'] = _(msg) # pylint: disable=translation-of-non-string + context['errorMessage'] = _(unicode(msg)) # pylint: disable=translation-of-non-string break return context From 00226bf3c068924ad2ffbb8af252071e6afe2a78 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 15 Jun 2015 01:54:38 -0700 Subject: [PATCH 84/97] Asynchronous metadata fetching using celery beat - PR 8518 --- .gitignore | 3 + common/djangoapps/third_party_auth/admin.py | 14 +- .../management/commands/saml.py | 139 ++-------------- common/djangoapps/third_party_auth/tasks.py | 157 ++++++++++++++++++ .../tests/specs/test_testshib.py | 15 +- .../third_party_auth/tests/test_views.py | 2 +- lms/envs/aws.py | 8 + pavelib/servers.py | 4 +- 8 files changed, 204 insertions(+), 138 deletions(-) create mode 100644 common/djangoapps/third_party_auth/tasks.py diff --git a/.gitignore b/.gitignore index 861ceec67f..1c2845e175 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,9 @@ logs chromedriver.log ghostdriver.log +### Celery artifacts ### +celerybeat-schedule + ### Unknown artifacts database.sqlite courseware/static/js/mathjax/* diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index d36ca9dd41..8495ef3a2b 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -7,6 +7,7 @@ from django.contrib import admin from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData +from .tasks import fetch_saml_metadata admin.site.register(OAuth2ProviderConfig, KeyedConfigurationModelAdmin) @@ -29,6 +30,17 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): has_data.short_description = u'Metadata Ready' has_data.boolean = True + def save_model(self, request, obj, form, change): + """ + Post save: Queue an asynchronous metadata fetch to update SAMLProviderData. + We only want to do this for manual edits done using the admin interface. + + Note: This only works if the celery worker and the app worker are using the + same 'configuration' cache. + """ + super(SAMLProviderConfigAdmin, self).save_model(request, obj, form, change) + fetch_saml_metadata.apply_async((), countdown=2) + admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin) @@ -54,7 +66,7 @@ admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin) class SAMLProviderDataAdmin(admin.ModelAdmin): - """ Django Admin class for SAMLProviderData """ + """ Django Admin class for SAMLProviderData (Read Only) """ list_display = ('entity_id', 'is_valid', 'fetched_at', 'expires_at', 'sso_url') readonly_fields = ('is_valid', ) diff --git a/common/djangoapps/third_party_auth/management/commands/saml.py b/common/djangoapps/third_party_auth/management/commands/saml.py index ca15bcaf4d..01918157ae 100644 --- a/common/djangoapps/third_party_auth/management/commands/saml.py +++ b/common/djangoapps/third_party_auth/management/commands/saml.py @@ -2,20 +2,10 @@ """ Management commands for third_party_auth """ -import datetime -import dateutil.parser from django.core.management.base import BaseCommand, CommandError -from lxml import etree -import requests -from onelogin.saml2.utils import OneLogin_Saml2_Utils -from third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData - -#pylint: disable=superfluous-parens,no-member - - -class MetadataParseError(Exception): - """ An error occurred while parsing the SAML metadata from an IdP """ - pass +import logging +from third_party_auth.models import SAMLConfiguration +from third_party_auth.tasks import fetch_saml_metadata class Command(BaseCommand): @@ -27,120 +17,21 @@ class Command(BaseCommand): raise CommandError("saml requires one argument: pull") if not SAMLConfiguration.is_enabled(): - self.stdout.write("Warning: SAML support is disabled via SAMLConfiguration.\n") + raise CommandError("SAML support is disabled via SAMLConfiguration.") subcommand = args[0] if subcommand == "pull": - self.cmd_pull() + log_handler = logging.StreamHandler(self.stdout) + log_handler.setLevel(logging.DEBUG) + log = logging.getLogger('third_party_auth.tasks') + log.propagate = False + log.addHandler(log_handler) + num_changed, num_failed, num_total = fetch_saml_metadata() + self.stdout.write( + "\nDone. Fetched {num_total} total. {num_changed} were updated and {num_failed} failed.\n".format( + num_changed=num_changed, num_failed=num_failed, num_total=num_total + ) + ) else: raise CommandError("Unknown argment: {}".format(subcommand)) - - @staticmethod - def tag_name(tag_name): - """ Get the namespaced-qualified name for an XML tag """ - return '{urn:oasis:names:tc:SAML:2.0:metadata}' + tag_name - - def cmd_pull(self): - """ Fetch the metadata for each provider and update the DB """ - # First make a list of all the metadata XML URLs: - url_map = {} - for idp_slug in SAMLProviderConfig.key_values('idp_slug', flat=True): - config = SAMLProviderConfig.current(idp_slug) - if not config.enabled: - continue - url = config.metadata_source - if url not in url_map: - url_map[url] = [] - if config.entity_id not in url_map[url]: - url_map[url].append(config.entity_id) - # Now fetch the metadata: - for url, entity_ids in url_map.items(): - try: - self.stdout.write("\n→ Fetching {}\n".format(url)) - if not url.lower().startswith('https'): - self.stdout.write("→ WARNING: This URL is not secure! It should use HTTPS.\n") - response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError - response.raise_for_status() # May raise an HTTPError - - try: - parser = etree.XMLParser(remove_comments=True) - xml = etree.fromstring(response.text, parser) - except etree.XMLSyntaxError: - raise - # TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that - - for entity_id in entity_ids: - self.stdout.write("→ Processing IdP with entityID {}\n".format(entity_id)) - public_key, sso_url, expires_at = self._parse_metadata_xml(xml, entity_id) - self._update_data(entity_id, public_key, sso_url, expires_at) - except Exception as err: # pylint: disable=broad-except - self.stderr.write(u"→ ERROR: {}\n\n".format(err.message)) - - @classmethod - def _parse_metadata_xml(cls, xml, entity_id): - """ - Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of - (public_key, sso_url, expires_at) for the specified entityID. - - Raises MetadataParseError if anything is wrong. - """ - if xml.tag == cls.tag_name('EntityDescriptor'): - entity_desc = xml - else: - if xml.tag != cls.tag_name('EntitiesDescriptor'): - raise MetadataParseError("Expected root element to be , not {}".format(xml.tag)) - entity_desc = xml.find(".//{}[@entityID='{}']".format(cls.tag_name('EntityDescriptor'), entity_id)) - if not entity_desc: - raise MetadataParseError("Can't find EntityDescriptor for entityID {}".format(entity_id)) - - expires_at = None - if "validUntil" in xml.attrib: - expires_at = dateutil.parser.parse(xml.attrib["validUntil"]) - if "cacheDuration" in xml.attrib: - cache_expires = OneLogin_Saml2_Utils.parse_duration(xml.attrib["cacheDuration"]) - if expires_at is None or cache_expires < expires_at: - expires_at = cache_expires - - sso_desc = entity_desc.find(cls.tag_name("IDPSSODescriptor")) - if not sso_desc: - raise MetadataParseError("IDPSSODescriptor missing") - if 'urn:oasis:names:tc:SAML:2.0:protocol' not in sso_desc.get("protocolSupportEnumeration"): - raise MetadataParseError("This IdP does not support SAML 2.0") - - # Now we just need to get the public_key and sso_url - public_key = sso_desc.findtext("./{}//{}".format( - cls.tag_name("KeyDescriptor"), "{http://www.w3.org/2000/09/xmldsig#}X509Certificate" - )) - if not public_key: - raise MetadataParseError("Public Key missing. Expected an ") - public_key = public_key.replace(" ", "") - binding_elements = sso_desc.iterfind("./{}".format(cls.tag_name("SingleSignOnService"))) - sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements} - try: - # The only binding supported by python-saml and python-social-auth is HTTP-Redirect: - sso_url = sso_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] - except KeyError: - raise MetadataParseError("Unable to find SSO URL with HTTP-Redirect binding.") - return public_key, sso_url, expires_at - - def _update_data(self, entity_id, public_key, sso_url, expires_at): - """ - Update/Create the SAMLProviderData for the given entity ID. - """ - data_obj = SAMLProviderData.current(entity_id) - fetched_at = datetime.datetime.now() - if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url): - data_obj.expires_at = expires_at - data_obj.fetched_at = fetched_at - data_obj.save() - self.stdout.write("→ Updated existing SAMLProviderData. Nothing has changed.\n") - else: - SAMLProviderData.objects.create( - entity_id=entity_id, - fetched_at=fetched_at, - expires_at=expires_at, - sso_url=sso_url, - public_key=public_key, - ) - self.stdout.write("→ Created new record for SAMLProviderData\n") diff --git a/common/djangoapps/third_party_auth/tasks.py b/common/djangoapps/third_party_auth/tasks.py new file mode 100644 index 0000000000..7466e113af --- /dev/null +++ b/common/djangoapps/third_party_auth/tasks.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +Code to manage fetching and storing the metadata of IdPs. +""" +#pylint: disable=no-member +from celery.task import task # pylint: disable=import-error,no-name-in-module +import datetime +import dateutil.parser +import logging +from lxml import etree +import requests +from onelogin.saml2.utils import OneLogin_Saml2_Utils +from third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData + +log = logging.getLogger(__name__) + +SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' # The SAML Metadata XML namespace + + +class MetadataParseError(Exception): + """ An error occurred while parsing the SAML metadata from an IdP """ + pass + + +@task(name='third_party_auth.fetch_saml_metadata') +def fetch_saml_metadata(): + """ + Fetch and store/update the metadata of all IdPs + + This task should be run on a daily basis. + It's OK to run this whether or not SAML is enabled. + + Return value: + tuple(num_changed, num_failed, num_total) + num_changed: Number of providers that are either new or whose metadata has changed + num_failed: Number of providers that could not be updated + num_total: Total number of providers whose metadata was fetched + """ + if not SAMLConfiguration.is_enabled(): + return (0, 0, 0) # Nothing to do until SAML is enabled. + + num_changed, num_failed = 0, 0 + + # First make a list of all the metadata XML URLs: + url_map = {} + for idp_slug in SAMLProviderConfig.key_values('idp_slug', flat=True): + config = SAMLProviderConfig.current(idp_slug) + if not config.enabled: + continue + url = config.metadata_source + if url not in url_map: + url_map[url] = [] + if config.entity_id not in url_map[url]: + url_map[url].append(config.entity_id) + # Now fetch the metadata: + for url, entity_ids in url_map.items(): + try: + log.info("Fetching %s", url) + if not url.lower().startswith('https'): + log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url) + response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError + response.raise_for_status() # May raise an HTTPError + + try: + parser = etree.XMLParser(remove_comments=True) + xml = etree.fromstring(response.text, parser) + except etree.XMLSyntaxError: + raise + # TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that + + for entity_id in entity_ids: + log.info(u"Processing IdP with entityID %s", entity_id) + public_key, sso_url, expires_at = _parse_metadata_xml(xml, entity_id) + changed = _update_data(entity_id, public_key, sso_url, expires_at) + if changed: + log.info(u"→ Created new record for SAMLProviderData") + num_changed += 1 + else: + log.info(u"→ Updated existing SAMLProviderData. Nothing has changed.") + except Exception as err: # pylint: disable=broad-except + log.exception(err.message) + num_failed += 1 + return (num_changed, num_failed, len(url_map)) + + +def _parse_metadata_xml(xml, entity_id): + """ + Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of + (public_key, sso_url, expires_at) for the specified entityID. + + Raises MetadataParseError if anything is wrong. + """ + if xml.tag == etree.QName(SAML_XML_NS, 'EntityDescriptor'): + entity_desc = xml + else: + if xml.tag != etree.QName(SAML_XML_NS, 'EntitiesDescriptor'): + raise MetadataParseError("Expected root element to be , not {}".format(xml.tag)) + entity_desc = xml.find( + ".//{}[@entityID='{}']".format(etree.QName(SAML_XML_NS, 'EntityDescriptor'), entity_id) + ) + if not entity_desc: + raise MetadataParseError("Can't find EntityDescriptor for entityID {}".format(entity_id)) + + expires_at = None + if "validUntil" in xml.attrib: + expires_at = dateutil.parser.parse(xml.attrib["validUntil"]) + if "cacheDuration" in xml.attrib: + cache_expires = OneLogin_Saml2_Utils.parse_duration(xml.attrib["cacheDuration"]) + if expires_at is None or cache_expires < expires_at: + expires_at = cache_expires + + sso_desc = entity_desc.find(etree.QName(SAML_XML_NS, "IDPSSODescriptor")) + if not sso_desc: + raise MetadataParseError("IDPSSODescriptor missing") + if 'urn:oasis:names:tc:SAML:2.0:protocol' not in sso_desc.get("protocolSupportEnumeration"): + raise MetadataParseError("This IdP does not support SAML 2.0") + + # Now we just need to get the public_key and sso_url + public_key = sso_desc.findtext("./{}//{}".format( + etree.QName(SAML_XML_NS, "KeyDescriptor"), "{http://www.w3.org/2000/09/xmldsig#}X509Certificate" + )) + if not public_key: + raise MetadataParseError("Public Key missing. Expected an ") + public_key = public_key.replace(" ", "") + binding_elements = sso_desc.iterfind("./{}".format(etree.QName(SAML_XML_NS, "SingleSignOnService"))) + sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements} + try: + # The only binding supported by python-saml and python-social-auth is HTTP-Redirect: + sso_url = sso_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + except KeyError: + raise MetadataParseError("Unable to find SSO URL with HTTP-Redirect binding.") + return public_key, sso_url, expires_at + + +def _update_data(entity_id, public_key, sso_url, expires_at): + """ + Update/Create the SAMLProviderData for the given entity ID. + Return value: + False if nothing has changed and existing data's "fetched at" timestamp is just updated. + True if a new record was created. (Either this is a new provider or something changed.) + """ + data_obj = SAMLProviderData.current(entity_id) + fetched_at = datetime.datetime.now() + if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url): + data_obj.expires_at = expires_at + data_obj.fetched_at = fetched_at + data_obj.save() + return False + else: + SAMLProviderData.objects.create( + entity_id=entity_id, + fetched_at=fetched_at, + expires_at=expires_at, + sso_url=sso_url, + public_key=public_key, + ) + return True diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index 689066113f..be17cf74a8 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -1,12 +1,11 @@ """ Third_party_auth integration tests using a mock version of the TestShib provider """ -from django.core.management import call_command from django.core.urlresolvers import reverse import httpretty from mock import patch -import StringIO from student.tests.factories import UserFactory +from third_party_auth.tasks import fetch_saml_metadata from third_party_auth.tests import testutil import unittest @@ -209,15 +208,11 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): self.configure_saml_provider(**kwargs) if fetch_metadata: - stdout = StringIO.StringIO() - stderr = StringIO.StringIO() self.assertTrue(httpretty.is_enabled()) - call_command('saml', 'pull', stdout=stdout, stderr=stderr) - stdout = stdout.getvalue().decode('utf-8') - stderr = stderr.getvalue().decode('utf-8') - self.assertEqual(stderr, '') - self.assertIn(u'Fetching {}'.format(TESTSHIB_METADATA_URL), stdout) - self.assertIn(u'Created new record for SAMLProviderData', stdout) + num_changed, num_failed, num_total = fetch_saml_metadata() + self.assertEqual(num_failed, 0) + self.assertEqual(num_changed, 1) + self.assertEqual(num_total, 1) def _fake_testshib_login_and_return(self): """ Mocked: the user logs in to TestShib and then gets redirected back """ diff --git a/common/djangoapps/third_party_auth/tests/test_views.py b/common/djangoapps/third_party_auth/tests/test_views.py index 11659011a5..8e88629801 100644 --- a/common/djangoapps/third_party_auth/tests/test_views.py +++ b/common/djangoapps/third_party_auth/tests/test_views.py @@ -8,7 +8,7 @@ import unittest from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase # Define some XML namespaces: -SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' +from third_party_auth.tasks import SAML_XML_NS XMLDSIG_XML_NS = 'http://www.w3.org/2000/09/xmldsig#' diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 9a4ce09cc5..8582aa7ef9 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -16,6 +16,7 @@ Common traits: # and throws spurious errors. Therefore, we disable invalid-name checking. # pylint: disable=invalid-name +import datetime import json from .common import * @@ -107,6 +108,7 @@ CELERY_QUEUES = { if os.environ.get('QUEUE') == 'high_mem': CELERYD_MAX_TASKS_PER_CHILD = 1 +CELERYBEAT_SCHEDULE = {} # For scheduling tasks, entries can be added to this dict ########################## NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. @@ -552,6 +554,12 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): # third_party_auth config moved to ConfigurationModels. This is for data migration only: THIRD_PARTY_AUTH_OLD_CONFIG = AUTH_TOKENS.get('THIRD_PARTY_AUTH', None) + if ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24) is not None: + CELERYBEAT_SCHEDULE['refresh-saml-metadata'] = { + 'task': 'third_party_auth.fetch_saml_metadata', + 'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)), + } + ##### OAUTH2 Provider ############## if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER'] diff --git a/pavelib/servers.py b/pavelib/servers.py index fd613af549..8076a0e46e 100644 --- a/pavelib/servers.py +++ b/pavelib/servers.py @@ -109,7 +109,7 @@ def celery(options): Runs Celery workers. """ settings = getattr(options, 'settings', 'dev_with_worker') - run_process(django_cmd('lms', settings, 'celery', 'worker', '--loglevel=INFO', '--pythonpath=.')) + run_process(django_cmd('lms', settings, 'celery', 'worker', '--beat', '--loglevel=INFO', '--pythonpath=.')) @task @@ -142,7 +142,7 @@ def run_all_servers(options): run_multi_processes([ django_cmd('lms', settings_lms, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['lms'])), django_cmd('studio', settings_cms, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['studio'])), - django_cmd('lms', worker_settings, 'celery', 'worker', '--loglevel=INFO', '--pythonpath=.') + django_cmd('lms', worker_settings, 'celery', 'worker', '--beat', '--loglevel=INFO', '--pythonpath=.') ]) From 5bf0b1794d3418e03356d4c61519b876da7716f2 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 19 Jun 2015 14:17:58 -0700 Subject: [PATCH 85/97] Bump python-social-auth and python-same to upstream's latest master - PR 8599 --- common/djangoapps/third_party_auth/models.py | 4 ++-- requirements/edx/base.txt | 3 ++- requirements/edx/github.txt | 3 --- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index eccc269390..b159c7d902 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -300,7 +300,7 @@ class SAMLConfiguration(ConfigurationModel): default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}', help_text=( "JSON object defining advanced settings that are passed on to python-saml. " - "Valid keys that can be set here include: SECURITY_CONFIG, SP_NAMEID_FORMATS, SP_EXTRA" + "Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA" ), ) @@ -344,7 +344,7 @@ class SAMLConfiguration(ConfigurationModel): if name == "SUPPORT_CONTACT": return {"givenName": "SAML Support", "emailAddress": settings.TECH_SUPPORT_EMAIL} other_config = json.loads(self.other_config_str) - return other_config[name] # SECURITY_CONFIG, SP_NAMEID_FORMATS, SP_EXTRA + return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings class SAMLProviderData(models.Model): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index e57c1b4d6d..da7fbb055d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -69,7 +69,8 @@ pyparsing==2.0.1 python-memcached==1.48 python-openid==2.2.5 python-dateutil==2.1 -# python-social-auth==0.2.7 was here but is temporarily moved to github.txt +python-social-auth==0.2.11 +python-saml==2.1.3 pytz==2015.2 pysrt==0.4.7 PyYAML==3.10 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 8c56763e2e..1f44442252 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -30,9 +30,6 @@ git+https://github.com/pmitros/pyfs.git@96e1922348bfe6d99201b9512a9ed946c87b7e0b git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c4aee6e76b9abe61cc808 # custom opaque-key implementations for ccx -e git+https://github.com/jazkarta/ccx-keys.git@e6b03704b1bb97c1d2f31301ecb4e3a687c536ea#egg=ccx-keys -# For SAML Support (To be moved to PyPi installation in base.txt once our changes are merged): --e git+https://github.com/open-craft/python-saml.git@9602b8133056d8c3caa7c3038761147df3d4b257#egg=python-saml --e git+https://github.com/open-craft/python-social-auth.git@02ab628b8961b969021de87aeb23551da4e751b7#egg=python-social-auth # Our libraries: -e git+https://github.com/edx/XBlock.git@74fdc5a361f48e5596acf3846ca3790a33a05253#egg=XBlock From 7437bcfe12e1d5ebff627909b41b88bc377aa1b8 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sun, 21 Jun 2015 12:59:12 -0700 Subject: [PATCH 86/97] New provider config options, New Institution Login Menu - PR 8603 --- common/djangoapps/student/views.py | 23 ++- .../migrations/0003_add_config_options.py | 161 ++++++++++++++++++ common/djangoapps/third_party_auth/models.py | 30 +++- .../djangoapps/third_party_auth/pipeline.py | 7 + .../tests/specs/test_testshib.py | 10 +- .../student_account/test/test_views.py | 1 + lms/djangoapps/student_account/views.py | 12 +- lms/envs/common.py | 1 + lms/static/js/spec/main.js | 11 ++ .../js/spec/student_account/access_spec.js | 27 +++ .../student_account/institution_login_spec.js | 80 +++++++++ .../js/student_account/views/AccessView.js | 26 ++- .../js/student_account/views/FormView.js | 4 +- .../views/InstitutionLoginView.js | 30 ++++ .../js/student_account/views/LoginView.js | 4 + .../js/student_account/views/RegisterView.js | 21 ++- lms/static/sass/views/_login-register.scss | 80 ++++++--- .../student_account/access.underscore | 4 + .../institution_login.underscore | 31 ++++ .../institution_register.underscore | 31 ++++ .../student_account/login.underscore | 8 +- .../student_account/login_and_register.html | 2 +- .../student_account/register.underscore | 8 +- 23 files changed, 558 insertions(+), 54 deletions(-) create mode 100644 common/djangoapps/third_party_auth/migrations/0003_add_config_options.py create mode 100644 lms/static/js/spec/student_account/institution_login_spec.js create mode 100644 lms/static/js/student_account/views/InstitutionLoginView.js create mode 100644 lms/templates/student_account/institution_login.underscore create mode 100644 lms/templates/student_account/institution_register.underscore diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index bb872c1a55..1420aa5432 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1498,6 +1498,13 @@ def create_account_with_params(request, params): dog_stats_api.increment("common.student.account_created") + # If the user is registering via 3rd party auth, track which provider they use + third_party_provider = None + running_pipeline = None + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + # Track the user's registration if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'): tracking_context = tracker.get_tracker().resolve_context() @@ -1506,20 +1513,13 @@ def create_account_with_params(request, params): 'username': user.username, }) - # If the user is registering via 3rd party auth, track which provider they use - provider_name = None - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_from_pipeline(running_pipeline) - provider_name = current_provider.name - analytics.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', 'label': params.get('course_id'), - 'provider': provider_name + 'provider': third_party_provider.name if third_party_provider else None }, context={ 'Google Analytics': { @@ -1536,6 +1536,7 @@ def create_account_with_params(request, params): # 2. Random user generation for other forms of testing. # 3. External auth bypassing activation. # 4. Have the platform configured to not require e-mail activation. + # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) # # Note that this feature is only tested as a flag set one way or # the other for *new* systems. we need to be careful about @@ -1544,7 +1545,11 @@ def create_account_with_params(request, params): send_email = ( not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and - not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) + not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and + not ( + third_party_provider and third_party_provider.skip_email_verification and + user.email == running_pipeline['kwargs'].get('details', {}).get('email') + ) ) if send_email: context = { diff --git a/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py b/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py new file mode 100644 index 0000000000..6ff8a3d3a5 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'SAMLProviderConfig.secondary' + db.add_column('third_party_auth_samlproviderconfig', 'secondary', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'SAMLProviderConfig.skip_registration_form' + db.add_column('third_party_auth_samlproviderconfig', 'skip_registration_form', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'SAMLProviderConfig.skip_email_verification' + db.add_column('third_party_auth_samlproviderconfig', 'skip_email_verification', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.secondary' + db.add_column('third_party_auth_oauth2providerconfig', 'secondary', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.skip_registration_form' + db.add_column('third_party_auth_oauth2providerconfig', 'skip_registration_form', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.skip_email_verification' + db.add_column('third_party_auth_oauth2providerconfig', 'skip_email_verification', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SAMLProviderConfig.secondary' + db.delete_column('third_party_auth_samlproviderconfig', 'secondary') + + # Deleting field 'SAMLProviderConfig.skip_registration_form' + db.delete_column('third_party_auth_samlproviderconfig', 'skip_registration_form') + + # Deleting field 'SAMLProviderConfig.skip_email_verification' + db.delete_column('third_party_auth_samlproviderconfig', 'skip_email_verification') + + # Deleting field 'OAuth2ProviderConfig.secondary' + db.delete_column('third_party_auth_oauth2providerconfig', 'secondary') + + # Deleting field 'OAuth2ProviderConfig.skip_registration_form' + db.delete_column('third_party_auth_oauth2providerconfig', 'skip_registration_form') + + # Deleting field 'OAuth2ProviderConfig.skip_email_verification' + db.delete_column('third_party_auth_oauth2providerconfig', 'skip_email_verification') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index b159c7d902..1550c54eba 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -8,7 +8,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _ import json import logging from social.backends.base import BaseAuth @@ -54,7 +54,7 @@ class AuthNotConfigured(SocialAuthBaseException): self.provider_name = provider_name def __str__(self): - return _('Authentication with {} is currently unavailable.').format( + return _('Authentication with {} is currently unavailable.').format( # pylint: disable=no-member self.provider_name ) @@ -68,10 +68,34 @@ class ProviderConfig(ConfigurationModel): help_text=( 'The Font Awesome (or custom) icon class to use on the login button for this provider. ' 'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university' - )) + ), + ) name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)") + secondary = models.BooleanField( + default=False, + help_text=_( + 'Secondary providers are displayed less prominently, ' + 'in a separate list of "Institution" login providers.' + ), + ) + skip_registration_form = models.BooleanField( + default=False, + help_text=_( + "If this option is enabled, users will not be asked to confirm their details " + "(name, email, etc.) during the registration process. Only select this option " + "for trusted providers that are known to provide accurate user information." + ), + ) + skip_email_verification = models.BooleanField( + default=False, + help_text=_( + "If this option is selected, users will not be required to confirm their " + "email, and their account will be activated immediately upon registration." + ), + ) 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 + # "enabled" field is inherited from ConfigurationModel class Meta(object): # pylint: disable=missing-docstring diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index c3d18b7b45..5bc4f069dd 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -503,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia """Redirects to the registration page.""" return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER]) + def should_force_account_creation(): + """ For some third party providers, we auto-create user accounts """ + current_provider = provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs}) + return current_provider and current_provider.skip_email_verification + if not user: if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]: return HttpResponseBadRequest() elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: # User has authenticated with the third party provider but we don't know which edX # account corresponds to them yet, if any. + if should_force_account_creation(): + return dispatch_to_register() return dispatch_to_login() elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]: # User has authenticated with the third party provider and now wants to finish diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index be17cf74a8..aacb945aa6 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -1,6 +1,7 @@ """ Third_party_auth integration tests using a mock version of the TestShib provider """ +from django.contrib.auth.models import User from django.core.urlresolvers import reverse import httpretty from mock import patch @@ -62,8 +63,6 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): self.assertEqual(response.status_code, 200) self.assertIn('Authentication with TestShib is currently unavailable.', response.content) - # Note: the following patch is only needed until https://github.com/edx/edx-platform/pull/8262 is merged - @patch.dict("django.conf.settings.FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": True}) def test_register(self): self._configure_testshib_provider() self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. @@ -107,6 +106,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): # Now check that we can login again: self.client.logout() + self._verify_user_email('myself@testshib.org') self._test_return_login() def test_login(self): @@ -222,3 +222,9 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): content_type='application/x-www-form-urlencoded', 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() diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 508cd9b19b..c11c858c7d 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -359,6 +359,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi json.dumps({ "currentProvider": current_provider, "providers": providers, + "secondaryProviders": [], "finishAuthUrl": finish_auth_url, "errorMessage": None, }) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 1983dd99b7..ffc10c04b4 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -164,13 +164,14 @@ def _third_party_auth_context(request, redirect_to): context = { "currentProvider": None, "providers": [], + "secondaryProviders": [], "finishAuthUrl": None, "errorMessage": None, } if third_party_auth.is_enabled(): - context["providers"] = [ - { + for enabled in third_party_auth.provider.Registry.enabled(): + info = { "id": enabled.provider_id, "name": enabled.name, "iconClass": enabled.icon_class, @@ -185,8 +186,7 @@ def _third_party_auth_context(request, redirect_to): redirect_url=redirect_to, ), } - for enabled in third_party_auth.provider.Registry.enabled() - ] + context["providers" if not enabled.secondary else "secondaryProviders"].append(info) running_pipeline = pipeline.get(request) if running_pipeline is not None: @@ -194,6 +194,10 @@ def _third_party_auth_context(request, redirect_to): context["currentProvider"] = current_provider.name context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name) + if current_provider.skip_registration_form: + # As a reliable way of "skipping" the registration form, we just submit it automatically + context["autoSubmitRegForm"] = True + # Check for any error messages we may want to display: for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": diff --git a/lms/envs/common.py b/lms/envs/common.py index 49b8d54a85..da207b500f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1274,6 +1274,7 @@ student_account_js = [ 'js/student_account/views/RegisterView.js', 'js/student_account/views/PasswordResetView.js', 'js/student_account/views/AccessView.js', + 'js/student_account/views/InstitutionLoginView.js', 'js/student_account/accessApp.js', ] diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 169377d7b7..6420609847 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -84,6 +84,7 @@ 'js/student_account/views/FormView': 'js/student_account/views/FormView', 'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel', 'js/student_account/views/LoginView': 'js/student_account/views/LoginView', + 'js/student_account/views/InstitutionLoginView': 'js/student_account/views/InstitutionLoginView', 'js/student_account/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel', 'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView', 'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel', @@ -410,6 +411,14 @@ 'js/student_account/views/FormView' ] }, + 'js/student_account/views/InstitutionLoginView': { + exports: 'edx.student.account.InstitutionLoginView', + deps: [ + 'jquery', + 'underscore', + 'backbone' + ] + }, 'js/student_account/models/PasswordResetModel': { exports: 'edx.student.account.PasswordResetModel', deps: ['jquery', 'jquery.cookie', 'backbone'] @@ -450,6 +459,7 @@ 'js/student_account/views/LoginView', 'js/student_account/views/PasswordResetView', 'js/student_account/views/RegisterView', + 'js/student_account/views/InstitutionLoginView', 'js/student_account/models/LoginModel', 'js/student_account/models/PasswordResetModel', 'js/student_account/models/RegisterModel', @@ -613,6 +623,7 @@ 'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/finish_auth_spec.js', 'lms/include/js/spec/student_account/login_spec.js', + 'lms/include/js/spec/student_account/institution_login_spec.js', 'lms/include/js/spec/student_account/register_spec.js', 'lms/include/js/spec/student_account/password_reset_spec.js', 'lms/include/js/spec/student_account/enrollment_spec.js', diff --git a/lms/static/js/spec/student_account/access_spec.js b/lms/static/js/spec/student_account/access_spec.js index a82da514e2..590c9df58f 100644 --- a/lms/static/js/spec/student_account/access_spec.js +++ b/lms/static/js/spec/student_account/access_spec.js @@ -58,6 +58,7 @@ define([ thirdPartyAuth: { currentProvider: null, providers: [], + secondaryProviders: [{name: "provider"}], finishAuthUrl: finishAuthUrl }, nextUrl: nextUrl, // undefined for default @@ -97,6 +98,8 @@ define([ TemplateHelpers.installTemplate('templates/student_account/register'); TemplateHelpers.installTemplate('templates/student_account/password_reset'); TemplateHelpers.installTemplate('templates/student_account/form_field'); + TemplateHelpers.installTemplate('templates/student_account/institution_login'); + TemplateHelpers.installTemplate('templates/student_account/institution_register'); // Stub analytics tracking window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']); @@ -135,6 +138,30 @@ define([ assertForms('#login-form', '#register-form'); }); + it('toggles between the login and institution login view', function() { + ajaxSpyAndInitialize(this, 'login'); + + // Simulate clicking on institution login button + $('#login-form .button-secondary-login[data-type="institution_login"]').click(); + assertForms('#institution_login-form', '#login-form'); + + // Simulate selection of the login form + selectForm('login'); + assertForms('#login-form', '#institution_login-form'); + }); + + it('toggles between the register and institution register view', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Simulate clicking on institution login button + $('#register-form .button-secondary-login[data-type="institution_login"]').click(); + assertForms('#institution_login-form', '#register-form'); + + // Simulate selection of the login form + selectForm('register'); + assertForms('#register-form', '#institution_login-form'); + }); + it('displays the reset password form', function() { ajaxSpyAndInitialize(this, 'login'); diff --git a/lms/static/js/spec/student_account/institution_login_spec.js b/lms/static/js/spec/student_account/institution_login_spec.js new file mode 100644 index 0000000000..208c975550 --- /dev/null +++ b/lms/static/js/spec/student_account/institution_login_spec.js @@ -0,0 +1,80 @@ +define([ + 'jquery', + 'underscore', + 'common/js/spec_helpers/template_helpers', + 'js/student_account/views/InstitutionLoginView', +], function($, _, TemplateHelpers, InstitutionLoginView) { + 'use strict'; + describe('edx.student.account.InstitutionLoginView', function() { + + var view = null, + PLATFORM_NAME = 'edX', + THIRD_PARTY_AUTH = { + currentProvider: null, + providers: [], + secondaryProviders: [ + { + id: 'oa2-google-oauth2', + name: 'Google', + iconClass: 'fa-google-plus', + loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', + registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' + }, + { + id: 'oa2-facebook', + name: 'Facebook', + iconClass: 'fa-facebook', + loginUrl: '/auth/login/facebook/?auth_entry=account_login', + registerUrl: '/auth/login/facebook/?auth_entry=account_register' + } + ] + }; + + var createInstLoginView = function(mode) { + // Initialize the login view + view = new InstitutionLoginView({ + mode: mode, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME + }); + view.render(); + }; + + beforeEach(function() { + setFixtures('
'); + TemplateHelpers.installTemplate('templates/student_account/institution_login'); + TemplateHelpers.installTemplate('templates/student_account/institution_register'); + }); + + it('displays a list of providers', function() { + createInstLoginView('login'); + expect($('#institution_login-form').html()).not.toBe(""); + var $google = $('li a:contains("Google")'); + expect($google).toBeVisible(); + expect($google).toHaveAttr( + 'href', '/auth/login/google-oauth2/?auth_entry=account_login' + ); + var $facebook = $('li a:contains("Facebook")'); + expect($facebook).toBeVisible(); + expect($facebook).toHaveAttr( + 'href', '/auth/login/facebook/?auth_entry=account_login' + ); + }); + + it('displays a list of providers', function() { + createInstLoginView('register'); + expect($('#institution_login-form').html()).not.toBe(""); + var $google = $('li a:contains("Google")'); + expect($google).toBeVisible(); + expect($google).toHaveAttr( + 'href', '/auth/login/google-oauth2/?auth_entry=account_register' + ); + var $facebook = $('li a:contains("Facebook")'); + expect($facebook).toBeVisible(); + expect($facebook).toHaveAttr( + 'href', '/auth/login/facebook/?auth_entry=account_register' + ); + }); + + }); +}); diff --git a/lms/static/js/student_account/views/AccessView.js b/lms/static/js/student_account/views/AccessView.js index 9cf1dcae15..4374eed3d2 100644 --- a/lms/static/js/student_account/views/AccessView.js +++ b/lms/static/js/student_account/views/AccessView.js @@ -18,7 +18,8 @@ var edx = edx || {}; subview: { login: {}, register: {}, - passwordHelp: {} + passwordHelp: {}, + institutionLogin: {} }, nextUrl: '/dashboard', @@ -52,7 +53,8 @@ var edx = edx || {}; this.formDescriptions = { login: obj.loginFormDesc, register: obj.registrationFormDesc, - reset: obj.passwordResetFormDesc + reset: obj.passwordResetFormDesc, + institution_login: null }; this.platformName = obj.platformName; @@ -148,6 +150,16 @@ var edx = edx || {}; // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. this.listenTo( this.subview.register, 'auth-complete', this.authComplete ); + }, + + institution_login: function ( unused ) { + this.subview.institutionLogin = new edx.student.account.InstitutionLoginView({ + thirdPartyAuth: this.thirdPartyAuth, + platformName: this.platformName, + mode: this.activeForm + }); + + this.subview.institutionLogin.render(); } }, @@ -180,9 +192,11 @@ var edx = edx || {}; category: 'user-engagement' }); - if ( !this.form.isLoaded( $form ) ) { + // Load the form. Institution login is always refreshed since it changes based on the previous form. + if ( !this.form.isLoaded( $form ) || type == "institution_login") { this.loadForm( type ); } + this.activeForm = type; this.element.hide( $(this.el).find('.submission-success') ); this.element.hide( $(this.el).find('.form-wrapper') ); @@ -190,11 +204,13 @@ var edx = edx || {}; this.element.scrollTop( $anchor ); // Update url without reloading page - History.pushState( null, document.title, '/' + type + queryStr ); + if (type != "institution_login") { + History.pushState( null, document.title, '/' + type + queryStr ); + } analytics.page( 'login_and_registration', type ); // Focus on the form - document.getElementById(type).focus(); + $("#" + type).focus(); }, /** diff --git a/lms/static/js/student_account/views/FormView.js b/lms/static/js/student_account/views/FormView.js index 12f0d51100..989be0bc86 100644 --- a/lms/static/js/student_account/views/FormView.js +++ b/lms/static/js/student_account/views/FormView.js @@ -215,7 +215,9 @@ var edx = edx || {}; submitForm: function( event ) { var data = this.getFormData(); - event.preventDefault(); + if (!_.isUndefined(event)) { + event.preventDefault(); + } this.toggleDisableButton(true); diff --git a/lms/static/js/student_account/views/InstitutionLoginView.js b/lms/static/js/student_account/views/InstitutionLoginView.js new file mode 100644 index 0000000000..524e3a63b3 --- /dev/null +++ b/lms/static/js/student_account/views/InstitutionLoginView.js @@ -0,0 +1,30 @@ +var edx = edx || {}; + +(function($, _, Backbone) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.InstitutionLoginView = Backbone.View.extend({ + el: '#institution_login-form', + + initialize: function( data ) { + var tpl = data.mode == "register" ? '#institution_register-tpl' : '#institution_login-tpl'; + this.tpl = $(tpl).html(); + this.providers = data.thirdPartyAuth.secondaryProviders || []; + this.platformName = data.platformName; + }, + + render: function() { + $(this.el).html( _.template( this.tpl, { + // We pass the context object to the template so that + // we can perform variable interpolation using sprintf + providers: this.providers, + platformName: this.platformName + })); + + return this; + } + }); +})(jQuery, _, Backbone); diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js index 79eb44d1ce..d54c65fddb 100644 --- a/lms/static/js/student_account/views/LoginView.js +++ b/lms/static/js/student_account/views/LoginView.js @@ -25,6 +25,9 @@ var edx = edx || {}; preRender: function( data ) { this.providers = data.thirdPartyAuth.providers || []; + this.hasSecondaryProviders = ( + data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length + ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; @@ -45,6 +48,7 @@ var edx = edx || {}; currentProvider: this.currentProvider, errorMessage: this.errorMessage, providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName } })); diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 177bfe51da..294704521b 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -22,9 +22,13 @@ var edx = edx || {}; preRender: function( data ) { this.providers = data.thirdPartyAuth.providers || []; + this.hasSecondaryProviders = ( + data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length + ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; + this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm; this.listenTo( this.model, 'sync', this.saveSuccess ); }, @@ -41,12 +45,19 @@ var edx = edx || {}; currentProvider: this.currentProvider, errorMessage: this.errorMessage, providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName } })); this.postRender(); + if (this.autoSubmit) { + $(this.el).hide(); + $('#register-honor_code').prop('checked', true); + this.submitForm(); + } + return this; }, @@ -63,6 +74,7 @@ var edx = edx || {}; }, saveError: function( error ) { + $(this.el).show(); // Show in case the form was hidden for auto-submission this.errors = _.flatten( _.map( JSON.parse(error.responseText), @@ -76,6 +88,13 @@ var edx = edx || {}; ); this.setErrors(); this.toggleDisableButton(false); - } + }, + + postFormSubmission: function() { + if (_.compact(this.errors).length) { + // The form did not get submitted due to validation errors. + $(this.el).show(); // Show in case the form was hidden for auto-submission + } + }, }); })(jQuery, _, gettext); diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index 435ed65edf..aad4cab6f3 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -14,6 +14,7 @@ $sm-btn-linkedin: #0077b5; background: $white; min-height: 100%; width: 100%; + $third-party-button-height: ($baseline*1.75); h2 { @extend %t-title5; @@ -22,6 +23,10 @@ $sm-btn-linkedin: #0077b5; font-family: $sans-serif; } + .instructions { + @extend %t-copy-base; + } + /* Temp. fix until applied globally */ > { @include box-sizing(border-box); @@ -67,10 +72,11 @@ $sm-btn-linkedin: #0077b5; } } - form { + form, + .wrapper-other-login { border: 1px solid $gray-l4; - border-radius: 5px; - padding: 0px 25px 20px 25px; + border-radius: ($baseline/4); + padding: 0 ($baseline*1.25) $baseline ($baseline*1.25); } .section-title { @@ -106,16 +112,20 @@ $sm-btn-linkedin: #0077b5; } } - .nav-btn { + %nav-btn-base { @extend %btn-secondary-blue-outline; width: 100%; height: ($baseline*2); text-transform: none; text-shadow: none; - font-weight: 600; letter-spacing: normal; } + .nav-btn { + @extend %nav-btn-base; + @extend %t-strong; + } + .form-type, .toggle-form { @include box-sizing(border-box); @@ -348,29 +358,31 @@ $sm-btn-linkedin: #0077b5; .login-provider { @extend %btn-secondary-grey-outline; - width: 130px; - padding: 0 0 0 ($baseline*2); - height: 34px; - text-align: left; + @extend %t-action4; + + @include padding(0, 0, 0, $baseline*2); + @include text-align(left); + + position: relative; + margin-right: ($baseline/4); + margin-bottom: $baseline; + border-color: $lightGrey1; + width: $baseline*6.5; + height: $third-party-button-height; text-shadow: none; text-transform: none; - position: relative; - font-size: 0.8em; - border-color: $lightGrey1; - - &:nth-of-type(odd) { - margin-right: 13px; - } .icon { - color: white; + @include left(0); + position: absolute; top: -1px; - left: 0; width: 30px; - height: 34px; - line-height: 34px; + bottom: -1px; + background: $m-blue-d3; + line-height: $third-party-button-height; text-align: center; + color: $white; } &:hover, @@ -378,16 +390,12 @@ $sm-btn-linkedin: #0077b5; background-image: none; .icon { - height: 32px; - line-height: 32px; top: 0; + bottom: 0; + line-height: ($third-party-button-height - 2px); } } - &:last-child { - margin-bottom: $baseline; - } - &.button-oa2-google-oauth2 { color: $sm-btn-google; @@ -447,6 +455,19 @@ $sm-btn-linkedin: #0077b5; } + .button-secondary-login { + @extend %nav-btn-base; + @extend %t-action4; + @extend %t-regular; + border-color: $lightGrey1; + padding: 0; + height: $third-party-button-height; + + &:hover { + border-color: $m-blue-d3; + } + } + /** Error Container - from _account.scss **/ .status { @include box-sizing(border-box); @@ -503,6 +524,13 @@ $sm-btn-linkedin: #0077b5; } } + .institution-list { + + .institution { + @extend %t-copy-base; + } + } + @include media( max-width 330px) { .form-type { width: 98%; diff --git a/lms/templates/student_account/access.underscore b/lms/templates/student_account/access.underscore index 2eee3a2a3d..fff58d5cbf 100644 --- a/lms/templates/student_account/access.underscore +++ b/lms/templates/student_account/access.underscore @@ -9,3 +9,7 @@
+ +
+ +
diff --git a/lms/templates/student_account/institution_login.underscore b/lms/templates/student_account/institution_login.underscore new file mode 100644 index 0000000000..88861616e2 --- /dev/null +++ b/lms/templates/student_account/institution_login.underscore @@ -0,0 +1,31 @@ + diff --git a/lms/templates/student_account/institution_register.underscore b/lms/templates/student_account/institution_register.underscore new file mode 100644 index 0000000000..ba97dd6e7e --- /dev/null +++ b/lms/templates/student_account/institution_register.underscore @@ -0,0 +1,31 @@ + diff --git a/lms/templates/student_account/login.underscore b/lms/templates/student_account/login.underscore index 8b7ad6e6df..58447bdba8 100644 --- a/lms/templates/student_account/login.underscore +++ b/lms/templates/student_account/login.underscore @@ -39,7 +39,7 @@ - <% if ( context.providers.length > 0 && !context.currentProvider ) { %> + <% if ( context.providers.length > 0 && !context.currentProvider || context.hasSecondaryProviders ) { %> - <% } else if ( context.providers.length > 0 ) { %> + <% } else if ( context.providers.length > 0 || context.hasSecondaryProviders ) { %>