diff --git a/.gitignore b/.gitignore index 05e76c4caa..69bc47afdd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ .AppleDouble database.sqlite requirements/private.txt +lms/envs/private.py +cms/envs/private.py courseware/static/js/mathjax/* flushdb.sh build @@ -27,6 +29,7 @@ conf/locale/en/LC_MESSAGES/*.po !messages.po lms/static/sass/*.css lms/static/sass/application.scss +lms/static/sass/course.scss cms/static/sass/*.css lms/lib/comment_client/python nosetests.xml diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index 6ed8c1619b..bde350d8a3 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -52,7 +52,7 @@ Feature: Problem Editor Scenario: User cannot type out of range values in an integer number field Given I have created a Blank Common Problem And I edit and select Settings - Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "1" + Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" Scenario: Settings changes are not saved on Cancel Given I have created a Blank Common Problem diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 236cf501fc..80ccb6cc7a 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -26,11 +26,9 @@ Feature: Create Section And I save a new section release date Then the section release date is updated - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip Scenario: Delete section Given I have opened a new course in Studio And I have added a new section - When I press the "section" delete icon - And I confirm the alert + When I will confirm all alerts + And I press the "section" delete icon Then the section does not exist diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 9a896d8ebe..4a628ff72b 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -69,8 +69,8 @@ def i_see_complete_section_name_with_quote_in_editor(step): @step('the section does not exist$') def section_does_not_exist(step): - css = 'span.section-name-span' - assert world.browser.is_element_not_present_by_css(css) + css = 'h3[data-name="My Section"]' + assert world.is_css_not_present(css) @step('I see a release date for my section$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index c9f5b43dfb..e746f3629a 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,61 +1,59 @@ Feature: Overview Toggle Section - In order to quickly view the details of a course's section or to scan the inventory of sections + In order to quickly view the details of a course's section or to scan the inventory of sections As a course author I want to toggle the visibility of each section's subsection details in the overview listing - Scenario: The default layout for the overview page is to show sections in expanded view - Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: The default layout for the overview page is to show sections in expanded view + Given I have a course with multiple sections + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expand /collapse for a course with no sections - Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + Scenario: Expand /collapse for a course with no sections + Given I have a course with no sections + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link - Scenario: Collapse link appears after creating first section of a course - Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Collapse link appears after creating first section of a course + Given I have a course with no sections + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip - Scenario: Collapse link is not removed after last section of a course is deleted - Given I have a course with 1 section - And I navigate to the course overview page - When I press the "section" delete icon - And I confirm the alert - Then I see the "Collapse All Sections" link + Scenario: Collapse link is not removed after last section of a course is deleted + Given I have a course with 1 section + And I navigate to the course overview page + When I will confirm all alerts + And I press the "section" delete icon + Then I see the "Collapse All Sections" link - Scenario: Collapsing all sections when all sections are expanded - Given I navigate to the courseware page of a course with multiple sections - And all sections are expanded - When I click the "Collapse All Sections" link - Then I see the "Expand All Sections" link - And all sections are collapsed + Scenario: Collapsing all sections when all sections are expanded + Given I navigate to the courseware page of a course with multiple sections + And all sections are expanded + When I click the "Collapse All Sections" link + Then I see the "Expand All Sections" link + And all sections are collapsed - Scenario: Collapsing all sections when 1 or more sections are already collapsed - Given I navigate to the courseware page of a course with multiple sections - And all sections are expanded - When I collapse the first section - And I click the "Collapse All Sections" link - Then I see the "Expand All Sections" link - And all sections are collapsed + Scenario: Collapsing all sections when 1 or more sections are already collapsed + Given I navigate to the courseware page of a course with multiple sections + And all sections are expanded + When I collapse the first section + And I click the "Collapse All Sections" link + Then I see the "Expand All Sections" link + And all sections are collapsed - Scenario: Expanding all sections when all sections are collapsed - Given I navigate to the courseware page of a course with multiple sections - And I click the "Collapse All Sections" link - When I click the "Expand All Sections" link - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Expanding all sections when all sections are collapsed + Given I navigate to the courseware page of a course with multiple sections + And I click the "Collapse All Sections" link + When I click the "Expand All Sections" link + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expanding all sections when 1 or more sections are already expanded - Given I navigate to the courseware page of a course with multiple sections - And I click the "Collapse All Sections" link - When I expand the first section - And I click the "Expand All Sections" link - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Expanding all sections when 1 or more sections are already expanded + Given I navigate to the courseware page of a course with multiple sections + And I click the "Collapse All Sections" link + When I expand the first section + And I click the "Expand All Sections" link + Then I see the "Collapse All Sections" link + And all sections are expanded diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 8bb12467ff..a11467e3f9 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -32,12 +32,10 @@ Feature: Create Subsection And I reload the page Then I see the correct dates - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip Scenario: Delete a subsection Given I have opened a new course section in Studio And I have added a new subsection And I see my subsection on the Courseware page - When I press the "subsection" delete icon - And I confirm the alert + When I will confirm all alerts + And I press the "subsection" delete icon Then the subsection does not exist diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 07771c9d61..0129732d30 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -8,3 +8,8 @@ Feature: Video Component Scenario: Creating a video takes a single click Given I have clicked the new unit button Then creating a video takes a single click + + Scenario: Captions are shown correctly + Given I have created a Video component + And I have hidden captions + Then when I view the video it does not show the captions diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 7cbe8a2258..fd8624999e 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -16,3 +16,13 @@ def video_takes_a_single_click(step): assert(not world.is_css_present('.xmodule_VideoModule')) world.css_click("a[data-location='i4x://edx/templates/video/default']") assert(world.is_css_present('.xmodule_VideoModule')) + + +@step('I have hidden captions') +def set_show_captions_false(step): + world.css_click('a.hide-subtitles') + + +@step('when I view the video it does not show the captions') +def does_not_show_captions(step): + assert world.css_find('.video')[0].has_class('closed') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index aebfb91126..232b68ecc8 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -37,6 +37,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from contentstore.views.component import ADVANCED_COMPONENT_TYPES from django_comment_common.utils import are_permissions_roles_seeded +from xmodule.exceptions import InvalidVersionError +import datetime +from pytz import UTC TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') @@ -120,6 +123,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_advanced_components_require_two_clicks(self): self.check_components_on_page(['videoalpha'], ['Video Alpha']) + def test_malformed_edit_unit_request(self): + store = modulestore('direct') + import_from_xml(store, 'common/test/data/', ['simple']) + + # just pick one vertical + descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] + location = descriptor.location._replace(name='.' + descriptor.location.name) + + resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()})) + self.assertEqual(resp.status_code, 400) + def check_edit_unit(self, test_course_name): import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) @@ -404,6 +418,32 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) + def test_illegal_draft_crud_ops(self): + draft_store = modulestore('draft') + direct_store = modulestore('direct') + + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + location = Location('i4x://MITx/999/chapter/neuvo') + self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', + location) + direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) + self.assertRaises(InvalidVersionError, draft_store.clone_item, location, + location) + + self.assertRaises(InvalidVersionError, draft_store.update_item, location, + 'chapter data') + + # taking advantage of update_children and other functions never checking that the ids are valid + self.assertRaises(InvalidVersionError, draft_store.update_children, location, + ['i4x://MITx/999/problem/doesntexist']) + + self.assertRaises(InvalidVersionError, draft_store.update_metadata, location, + {'due': datetime.datetime.now(UTC)}) + + self.assertRaises(InvalidVersionError, draft_store.unpublish, location) + + def test_bad_contentstore_request(self): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) @@ -486,6 +526,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # check for custom_tags self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') + # check for about content + self.verify_content_existence(module_store, root_dir, location, 'about', 'about', '.html') + # check for graiding_policy.json filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') self.assertTrue(filesystem.exists('grading_policy.json')) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index c4b0f4bb51..fec82db1bb 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -24,6 +24,30 @@ class LMSLinksTestCase(TestCase): with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about") + @override_settings(MKTG_URLS={'ROOT': 'http://www.dummy'}) + def about_page_marketing_site_remove_http_test(self): + """ Get URL for about page, marketing root present, remove http://. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'https://www.dummy'}) + def about_page_marketing_site_remove_https_test(self): + """ Get URL for about page, marketing root present, remove https://. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'www.dummyhttps://x'}) + def about_page_marketing_site_https__edge_test(self): + """ Get URL for about page, only remove https:// at the beginning of the string. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummyhttps://x/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={}) + def about_page_marketing_urls_not_set_test(self): + """ Error case. ENABLE_MKTG_SITE is True, but there is either no MKTG_URLS, or no MKTG_URLS Root property. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), None) + @override_settings(LMS_BASE=None) def about_page_no_lms_base_test(self): """ No LMS_BASE, nor is ENABLE_MKTG_SITE True """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 703e9a266a..6f766ff7f5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,8 +4,11 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from django.core.urlresolvers import reverse import copy +import logging +import re +from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES -DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] +log = logging.getLogger(__name__) #In order to instantiate an open ended tab automatically, need to have this data OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"} @@ -108,9 +111,20 @@ def get_lms_link_for_about_page(location): Returns the url to the course about page from the location tuple. """ if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): - # Root will be "www.edx.org". The complete URL will still not be exactly correct, - # but redirects exist from www.edx.org to get to the drupal course about page URL. - about_base = settings.MKTG_URLS.get('ROOT') + if not hasattr(settings, 'MKTG_URLS'): + log.exception("ENABLE_MKTG_SITE is True, but MKTG_URLS is not defined.") + about_base = None + else: + marketing_urls = settings.MKTG_URLS + if marketing_urls.get('ROOT', None) is None: + log.exception('There is no ROOT defined in MKTG_URLS') + about_base = None + else: + # Root will be "https://www.edx.org". The complete URL will still not be exactly correct, + # but redirects exist from www.edx.org to get to the Drupal course about page URL. + about_base = marketing_urls.get('ROOT') + # Strip off https:// (or http://) to be consistent with the formatting of LMS_BASE. + about_base = re.sub(r"^https?://", "", about_base) elif settings.LMS_BASE is not None: about_base = settings.LMS_BASE else: @@ -214,7 +228,7 @@ def add_extra_panel_tab(tab_type, course): course_tabs = copy.copy(course.tabs) changed = False #Check to see if open ended panel is defined in the course - + tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel not in course_tabs: #Add panel to the tabs if it is not defined diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 8120e08107..039deb2740 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from django.conf import settings - +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from mitxmako.shortcuts import render_to_response from xmodule.modulestore import Location @@ -50,11 +50,18 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @login_required def edit_subsection(request, location): # check that we have permissions to edit this item - course = get_course_for_item(location) + try: + course = get_course_for_item(location) + except InvalidLocationError: + return HttpResponseBadRequest() + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location, depth=1) + try: + item = modulestore().get_item(location, depth=1) + except ItemNotFoundError: + return HttpResponseBadRequest() lms_link = get_lms_link_for_item(location, course_id=course.location.course_id) preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True) @@ -113,11 +120,18 @@ def edit_unit(request, location): id: A Location URL """ - course = get_course_for_item(location) + try: + course = get_course_for_item(location) + except InvalidLocationError: + return HttpResponseBadRequest() + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location, depth=1) + try: + item = modulestore().get_item(location, depth=1) + except ItemNotFoundError: + return HttpResponseBadRequest() lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 63c7d533cd..35b15fe6ba 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -95,13 +95,15 @@ SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') # this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can # happen with some browsers (e.g. Firefox) if ENV_TOKENS.get('SESSION_COOKIE_NAME', None): - SESSION_COOKIE_NAME = ENV_TOKENS.get('SESSION_COOKIE_NAME') + # 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')) #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) ADMINS = ENV_TOKENS.get('ADMINS', ADMINS) SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) +MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) #Timezone overrides TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) diff --git a/cms/envs/common.py b/cms/envs/common.py index 04d5888750..22e69fa08a 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -335,3 +335,14 @@ INSTALLED_APPS = ( ################# EDX MARKETING SITE ################################## EDXMKTG_COOKIE_NAME = 'edxloggedin' +MKTG_URLS = {} +MKTG_URL_LINK_MAP = { + 'ABOUT': 'about_edx', + 'CONTACT': 'contact', + 'FAQ': 'help_edx', + 'COURSES': 'courses', + 'ROOT': 'root', + 'TOS': 'tos', + 'HONOR': 'honor', + 'PRIVACY': 'privacy_edx', +} diff --git a/cms/envs/dev.py b/cms/envs/dev.py index e63968d338..eea236f0e2 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -165,3 +165,11 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True # segment-io key for dev SEGMENT_IO_KEY = 'mty8edrrsg' + + +##################################################################### +# Lastly, see if the developer has any local overrides. +try: + from .private import * +except ImportError: + pass diff --git a/common/djangoapps/mitxmako/shortcuts.py b/common/djangoapps/mitxmako/shortcuts.py index 0766564027..7f7c3f9ebe 100644 --- a/common/djangoapps/mitxmako/shortcuts.py +++ b/common/djangoapps/mitxmako/shortcuts.py @@ -41,7 +41,9 @@ def marketing_link(name): return settings.MKTG_URLS.get('ROOT') + settings.MKTG_URLS.get(name) # only link to the old pages when the marketing site isn't on elif not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE') and name in link_map: - return reverse(link_map[name]) + # don't try to reverse disabled marketing links + if link_map[name] is not None: + return reverse(link_map[name]) else: log.warning("Cannot find corresponding link for name: {name}".format(name=name)) return '#' diff --git a/common/djangoapps/mitxmako/tests.py b/common/djangoapps/mitxmako/tests.py index 21866eb9b5..f419daa681 100644 --- a/common/djangoapps/mitxmako/tests.py +++ b/common/djangoapps/mitxmako/tests.py @@ -4,13 +4,15 @@ from django.core.urlresolvers import reverse from django.conf import settings from mitxmako.shortcuts import marketing_link from mock import patch - +from nose.plugins.skip import SkipTest class ShortcutsTests(TestCase): """ Test the mitxmako shortcuts file """ - + # TODO: fix this test. It is causing intermittent test failures on + # subsequent tests due to the way urls are loaded + raise SkipTest() @override_settings(MKTG_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'}) @override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'}) def test_marketing_link(self): diff --git a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py new file mode 100644 index 0000000000..8ce1d0cda1 --- /dev/null +++ b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +import 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 'CourseEnrollmentAllowed.auto_enroll' + db.add_column('student_courseenrollmentallowed', 'auto_enroll', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CourseEnrollmentAllowed.auto_enroll' + db.delete_column('student_courseenrollmentallowed', 'auto_enroll') + + + 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'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 56b1293c2d..ab68b05f4b 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -662,6 +662,7 @@ class CourseEnrollmentAllowed(models.Model): """ email = models.CharField(max_length=255, db_index=True) course_id = models.CharField(max_length=255, db_index=True) + auto_enroll = models.BooleanField(default=0) created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 463ad33316..87e9f8c804 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -32,7 +32,7 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, - get_testcenter_registration) + get_testcenter_registration, CourseEnrollmentAllowed) from certificates.models import CertificateStatuses, certificate_status_for_student @@ -264,7 +264,6 @@ def dashboard(request): if not user.is_active: message = render_to_string('registration/activate_account_notice.html', {'email': user.email}) - # Global staff can see what courses errored on their dashboard staff_access = False errored_courses = {} @@ -355,7 +354,7 @@ def change_enrollment(request): course = course_from_id(course_id) except ItemNotFoundError: log.warning("User {0} tried to enroll in non-existent course {1}" - .format(user.username, course_id)) + .format(user.username, course_id)) return HttpResponseBadRequest("Course id is invalid") if not has_access(user, course, 'enroll'): @@ -363,9 +362,9 @@ def change_enrollment(request): org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)]) + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) try: enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) @@ -382,9 +381,9 @@ def change_enrollment(request): org, course_num, run = course_id.split("/") statsd.increment("common.student.unenrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)]) + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) return HttpResponse() except CourseEnrollment.DoesNotExist: @@ -454,7 +453,6 @@ def login_user(request, error=""): 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, @@ -515,8 +513,8 @@ def _do_create_account(post_vars): Note: this function is also used for creating test users. """ user = User(username=post_vars['username'], - email=post_vars['email'], - is_active=False) + email=post_vars['email'], + is_active=False) user.set_password(post_vars['password']) registration = Registration() # TODO: Rearrange so that if part of the process fails, the whole process fails. @@ -698,7 +696,6 @@ def create_account(request, post_override=None): 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, @@ -708,7 +705,6 @@ def create_account(request, post_override=None): return response - def exam_registration_info(user, course): """ Returns a Registration object if the user is currently registered for a current exam of the course. Returns None if the user is not registered, or if there is no @@ -849,7 +845,6 @@ def create_exam_registration(request, post_override=None): response_data['non_field_errors'] = form.non_field_errors() return HttpResponse(json.dumps(response_data), mimetype="application/json") - # only do the following if there is accommodation text to send, # and a destination to which to send it. # TODO: still need to create the accommodation email templates @@ -872,7 +867,6 @@ def create_exam_registration(request, post_override=None): # response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ] # return HttpResponse(json.dumps(response_data), mimetype="application/json") - js = {'success': True} return HttpResponse(json.dumps(js), mimetype="application/json") @@ -916,6 +910,16 @@ def activate_account(request, key): if not r[0].user.is_active: r[0].activate() already_active = False + + #Enroll student in any pending courses he/she may have if auto_enroll flag is set + student = User.objects.filter(id=r[0].user_id) + if student: + ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email) + for cea in ceas: + if cea.auto_enroll: + course_id = cea.course_id + enrollment, created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id) + resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active}) return resp if len(r) == 0: @@ -1194,6 +1198,10 @@ def accept_name_change(request): def _get_news(top=None): "Return the n top news items on settings.RSS_URL" + # Don't return anything if we're in a themed site + if settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + return None + feed_data = cache.get("students_index_rss_feed_data") if feed_data is None: if hasattr(settings, 'RSS_URL'): diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 1d9e59cd72..6e512982b7 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -159,3 +159,33 @@ def registered_edx_user(step, uname): @step(u'All dialogs should be closed$') def dialogs_are_closed(step): assert world.dialogs_closed() + + +@step('I will confirm all alerts') +def i_confirm_all_alerts(step): + """ + Please note: This method must be called RIGHT BEFORE an expected alert + Window variables are page local and thus all changes are removed upon navigating to a new page + In addition, this method changes the functionality of ONLY future alerts + """ + world.browser.execute_script('window.confirm = function(){return true;} ; window.alert = function(){return;}') + + +@step('I will cancel all alerts') +def i_cancel_all_alerts(step): + """ + Please note: This method must be called RIGHT BEFORE an expected alert + Window variables are page local and thus all changes are removed upon navigating to a new page + In addition, this method changes the functionality of ONLY future alerts + """ + world.browser.execute_script('window.confirm = function(){return false;} ; window.alert = function(){return;}') + + +@step('I will answer all prompts with "([^"]*)"') +def i_answer_prompts_with(step, prompt): + """ + Please note: This method must be called RIGHT BEFORE an expected alert + Window variables are page local and thus all changes are removed upon navigating to a new page + In addition, this method changes the functionality of ONLY future alerts + """ + world.browser.execute_script('window.prompt = function(){return %s;}') % prompt diff --git a/common/djangoapps/util/tests/test_zendesk.py b/common/djangoapps/util/tests/test_submit_feedback.py similarity index 69% rename from common/djangoapps/util/tests/test_zendesk.py rename to common/djangoapps/util/tests/test_submit_feedback.py index 51d06a92ed..b66d3d642b 100644 --- a/common/djangoapps/util/tests/test_zendesk.py +++ b/common/djangoapps/util/tests/test_submit_feedback.py @@ -15,8 +15,9 @@ import mock @mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True}) @override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy") +@mock.patch("util.views.dog_stats_api") @mock.patch("util.views._ZendeskApi", autospec=True) -class SubmitFeedbackViaZendeskTest(TestCase): +class SubmitFeedbackTest(TestCase): def setUp(self): """Set up data for the test case""" self._request_factory = RequestFactory() @@ -26,18 +27,19 @@ class SubmitFeedbackViaZendeskTest(TestCase): username="test", profile__name="Test User" ) - # This contains a tag to ensure that tags are submitted correctly + # This contains issue_type and course_id to ensure that tags are submitted correctly self._anon_fields = { "email": "test@edx.org", "name": "Test User", "subject": "a subject", "details": "some details", - "tag": "a tag" + "issue_type": "test_issue", + "course_id": "test_course" } - # This does not contain a tag to ensure that tag is optional + # This does not contain issue_type nor course_id to ensure that they are optional self._auth_fields = {"subject": "a subject", "details": "some details"} - def _test_request(self, user, fields): + def _build_and_run_request(self, user, fields): """ Generate a request and invoke the view, returning the response. @@ -48,12 +50,14 @@ class SubmitFeedbackViaZendeskTest(TestCase): "/submit_feedback", data=fields, HTTP_REFERER="test_referer", - HTTP_USER_AGENT="test_user_agent" + HTTP_USER_AGENT="test_user_agent", + REMOTE_ADDR="1.2.3.4", + SERVER_NAME="test_server" ) req.user = user - return views.submit_feedback_via_zendesk(req) + return views.submit_feedback(req) - def _assert_bad_request(self, response, field, zendesk_mock_class): + def _assert_bad_request(self, response, field, zendesk_mock_class, datadog_mock): """ Assert that the given `response` contains correct failure data. @@ -67,8 +71,9 @@ class SubmitFeedbackViaZendeskTest(TestCase): self.assertTrue("error" in resp_json) # There should be absolutely no interaction with Zendesk self.assertFalse(zendesk_mock_class.return_value.mock_calls) + self.assertFalse(datadog_mock.mock_calls) - def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class): + def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class, datadog_mock): """ Invoke the view with a request missing a field and assert correctness. @@ -79,10 +84,10 @@ class SubmitFeedbackViaZendeskTest(TestCase): have been invoked. """ filtered_fields = {k: v for (k, v) in fields.items() if k != omit_field} - resp = self._test_request(user, filtered_fields) - self._assert_bad_request(resp, omit_field, zendesk_mock_class) + resp = self._build_and_run_request(user, filtered_fields) + self._assert_bad_request(resp, omit_field, zendesk_mock_class, datadog_mock) - def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class): + def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class, datadog_mock): """ Invoke the view with an empty field and assert correctness. @@ -94,8 +99,8 @@ class SubmitFeedbackViaZendeskTest(TestCase): """ altered_fields = fields.copy() altered_fields[empty_field] = "" - resp = self._test_request(user, altered_fields) - self._assert_bad_request(resp, empty_field, zendesk_mock_class) + resp = self._build_and_run_request(user, altered_fields) + self._assert_bad_request(resp, empty_field, zendesk_mock_class, datadog_mock) def _test_success(self, user, fields): """ @@ -105,30 +110,46 @@ class SubmitFeedbackViaZendeskTest(TestCase): `fields` in the POST body. The response should have a 200 (success) status code. """ - resp = self._test_request(user, fields) + resp = self._build_and_run_request(user, fields) self.assertEqual(resp.status_code, 200) - def test_bad_request_anon_user_no_name(self, zendesk_mock_class): + def _assert_datadog_called(self, datadog_mock, with_tags): + expected_datadog_calls = [ + mock.call.increment( + views.DATADOG_FEEDBACK_METRIC, + tags=(["course_id:test_course", "issue_type:test_issue"] if with_tags else []) + ) + ] + self.assertEqual(datadog_mock.mock_calls, expected_datadog_calls) + + def test_bad_request_anon_user_no_name(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `name`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class, datadog_mock) - def test_bad_request_anon_user_no_email(self, zendesk_mock_class): + def test_bad_request_anon_user_no_email(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `email`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class, datadog_mock) - def test_bad_request_anon_user_no_subject(self, zendesk_mock_class): + def test_bad_request_anon_user_invalid_email(self, zendesk_mock_class, datadog_mock): + """Test a request from an anonymous user specifying an invalid `email`.""" + fields = self._anon_fields.copy() + fields["email"] = "This is not a valid email address!" + resp = self._build_and_run_request(self._anon_user, fields) + self._assert_bad_request(resp, "email", zendesk_mock_class, datadog_mock) + + def test_bad_request_anon_user_no_subject(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `subject`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class, datadog_mock) - def test_bad_request_anon_user_no_details(self, zendesk_mock_class): + def test_bad_request_anon_user_no_details(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `details`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class, datadog_mock) - def test_valid_request_anon_user(self, zendesk_mock_class): + def test_valid_request_anon_user(self, zendesk_mock_class, datadog_mock): """ Test a valid request from an anonymous user. @@ -138,14 +159,14 @@ class SubmitFeedbackViaZendeskTest(TestCase): zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.create_ticket.return_value = 42 self._test_success(self._anon_user, self._anon_fields) - expected_calls = [ + expected_zendesk_calls = [ mock.call.create_ticket( { "ticket": { "requester": {"name": "Test User", "email": "test@edx.org"}, "subject": "a subject", "comment": {"body": "some details"}, - "tags": ["a tag"] + "tags": ["test_course", "test_issue", "LMS"] } } ), @@ -157,26 +178,29 @@ class SubmitFeedbackViaZendeskTest(TestCase): "public": False, "body": "Additional information:\n\n" - "HTTP_USER_AGENT: test_user_agent\n" - "HTTP_REFERER: test_referer" + "Client IP: 1.2.3.4\n" + "Host: test_server\n" + "Page: test_referer\n" + "Browser: test_user_agent" } } } ) ] - self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls) + self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) + self._assert_datadog_called(datadog_mock, with_tags=True) - def test_bad_request_auth_user_no_subject(self, zendesk_mock_class): + def test_bad_request_auth_user_no_subject(self, zendesk_mock_class, datadog_mock): """Test a request from an authenticated user not specifying `subject`.""" - self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class) - self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class) + self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class, datadog_mock) - def test_bad_request_auth_user_no_details(self, zendesk_mock_class): + def test_bad_request_auth_user_no_details(self, zendesk_mock_class, datadog_mock): """Test a request from an authenticated user not specifying `details`.""" - self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class) - self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class) + self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class, datadog_mock) - def test_valid_request_auth_user(self, zendesk_mock_class): + def test_valid_request_auth_user(self, zendesk_mock_class, datadog_mock): """ Test a valid request from an authenticated user. @@ -186,14 +210,14 @@ class SubmitFeedbackViaZendeskTest(TestCase): zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.create_ticket.return_value = 42 self._test_success(self._auth_user, self._auth_fields) - expected_calls = [ + expected_zendesk_calls = [ mock.call.create_ticket( { "ticket": { "requester": {"name": "Test User", "email": "test@edx.org"}, "subject": "a subject", "comment": {"body": "some details"}, - "tags": [] + "tags": ["LMS"] } } ), @@ -206,27 +230,31 @@ class SubmitFeedbackViaZendeskTest(TestCase): "body": "Additional information:\n\n" "username: test\n" - "HTTP_USER_AGENT: test_user_agent\n" - "HTTP_REFERER: test_referer" + "Client IP: 1.2.3.4\n" + "Host: test_server\n" + "Page: test_referer\n" + "Browser: test_user_agent" } } } ) ] - self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls) + self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) + self._assert_datadog_called(datadog_mock, with_tags=False) - def test_get_request(self, zendesk_mock_class): + def test_get_request(self, zendesk_mock_class, datadog_mock): """Test that a GET results in a 405 even with all required fields""" req = self._request_factory.get("/submit_feedback", data=self._anon_fields) req.user = self._anon_user - resp = views.submit_feedback_via_zendesk(req) + resp = views.submit_feedback(req) self.assertEqual(resp.status_code, 405) self.assertIn("Allow", resp) self.assertEqual(resp["Allow"], "POST") # There should be absolutely no interaction with Zendesk self.assertFalse(zendesk_mock_class.mock_calls) + self.assertFalse(datadog_mock.mock_calls) - def test_zendesk_error_on_create(self, zendesk_mock_class): + def test_zendesk_error_on_create(self, zendesk_mock_class, datadog_mock): """ Test Zendesk returning an error on ticket creation. @@ -235,11 +263,12 @@ class SubmitFeedbackViaZendeskTest(TestCase): err = ZendeskError(msg="", error_code=404) zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.create_ticket.side_effect = err - resp = self._test_request(self._anon_user, self._anon_fields) + resp = self._build_and_run_request(self._anon_user, self._anon_fields) self.assertEqual(resp.status_code, 500) self.assertFalse(resp.content) + self._assert_datadog_called(datadog_mock, with_tags=True) - def test_zendesk_error_on_update(self, zendesk_mock_class): + def test_zendesk_error_on_update(self, zendesk_mock_class, datadog_mock): """ Test for Zendesk returning an error on ticket update. @@ -250,20 +279,21 @@ class SubmitFeedbackViaZendeskTest(TestCase): err = ZendeskError(msg="", error_code=500) zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.update_ticket.side_effect = err - resp = self._test_request(self._anon_user, self._anon_fields) + resp = self._build_and_run_request(self._anon_user, self._anon_fields) self.assertEqual(resp.status_code, 200) + self._assert_datadog_called(datadog_mock, with_tags=True) @mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False}) - def test_not_enabled(self, zendesk_mock_class): + def test_not_enabled(self, zendesk_mock_class, datadog_mock): """ Test for Zendesk submission not enabled in `settings`. We should raise Http404. """ with self.assertRaises(Http404): - self._test_request(self._anon_user, self._anon_fields) + self._build_and_run_request(self._anon_user, self._anon_fields) - def test_zendesk_not_configured(self, zendesk_mock_class): + def test_zendesk_not_configured(self, zendesk_mock_class, datadog_mock): """ Test for Zendesk not fully configured in `settings`. @@ -273,7 +303,7 @@ class SubmitFeedbackViaZendeskTest(TestCase): def test_case(missing_config): with mock.patch(missing_config, None): with self.assertRaises(Exception): - self._test_request(self._anon_user, self._anon_fields) + self._build_and_run_request(self._anon_user, self._anon_fields) test_case("django.conf.settings.ZENDESK_URL") test_case("django.conf.settings.ZENDESK_USER") diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index d0aa0dc680..aa592d25e8 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -12,6 +12,7 @@ from django.core.validators import ValidationError, validate_email from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie +from dogapi import dog_stats_api from mitxmako.shortcuts import render_to_response, render_to_string from urllib import urlencode import zendesk @@ -73,11 +74,64 @@ class _ZendeskApi(object): self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update) -def submit_feedback_via_zendesk(request): +def _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info): """ Create a new user-requested Zendesk ticket. - If Zendesk submission is not enabled, any request will raise `Http404`. + Once created, the ticket will be updated with a private comment containing + additional information from the browser and server, such as HTTP headers + and user state. Returns a boolean value indicating whether ticket creation + was successful, regardless of whether the private comment update succeeded. + """ + zendesk_api = _ZendeskApi() + + additional_info_string = ( + "Additional information:\n\n" + + "\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None) + ) + + # Tag all issues with LMS to distinguish channel in Zendesk; requested by student support team + zendesk_tags = list(tags.values()) + ["LMS"] + new_ticket = { + "ticket": { + "requester": {"name": realname, "email": email}, + "subject": subject, + "comment": {"body": details}, + "tags": zendesk_tags + } + } + try: + ticket_id = zendesk_api.create_ticket(new_ticket) + except zendesk.ZendeskError as err: + log.error("Error creating Zendesk ticket: %s", str(err)) + return False + + # Additional information is provided as a private update so the information + # is not visible to the user. + ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}} + try: + zendesk_api.update_ticket(ticket_id, ticket_update) + except zendesk.ZendeskError as err: + log.error("Error updating Zendesk ticket: %s", str(err)) + # The update is not strictly necessary, so do not indicate failure to the user + pass + + return True + + +DATADOG_FEEDBACK_METRIC = "lms_feedback_submissions" + + +def _record_feedback_in_datadog(tags): + datadog_tags = ["{k}:{v}".format(k=k, v=v) for k, v in tags.items()] + dog_stats_api.increment(DATADOG_FEEDBACK_METRIC, tags=datadog_tags) + + +def submit_feedback(request): + """ + Create a new user-requested ticket, currently implemented with Zendesk. + + If feedback submission is not enabled, any request will raise `Http404`. If any configuration parameter (`ZENDESK_URL`, `ZENDESK_USER`, or `ZENDESK_API_KEY`) is missing, any request will raise an `Exception`. The request must be a POST request specifying `subject` and `details`. @@ -85,12 +139,9 @@ def submit_feedback_via_zendesk(request): `email`. If the user is authenticated, the `name` and `email` will be populated from the user's information. If any required parameter is missing, a 400 error will be returned indicating which field is missing and - providing an error message. If Zendesk returns any error on ticket - creation, a 500 error will be returned with no body. Once created, the - ticket will be updated with a private comment containing additional - information from the browser and server, such as HTTP headers and user - state. Whether or not the update succeeds, if the user's ticket is - successfully created, an empty successful response (200) will be returned. + providing an error message. If Zendesk ticket creation fails, 500 error + will be returned with no body; if ticket creation succeeds, an empty + successful response (200) will be returned. """ if not settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False): raise Http404() @@ -124,9 +175,9 @@ def submit_feedback_via_zendesk(request): subject = request.POST["subject"] details = request.POST["details"] - tags = [] - if "tag" in request.POST: - tags = [request.POST["tag"]] + tags = dict( + [(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if tag in request.POST] + ) if request.user.is_authenticated(): realname = request.user.profile.name @@ -140,41 +191,18 @@ def submit_feedback_via_zendesk(request): except ValidationError: return build_error_response(400, "email", required_field_errs["email"]) - for header in ["HTTP_REFERER", "HTTP_USER_AGENT"]: - additional_info[header] = request.META.get(header) + for header, pretty in [ + ("HTTP_REFERER", "Page"), + ("HTTP_USER_AGENT", "Browser"), + ("REMOTE_ADDR", "Client IP"), + ("SERVER_NAME", "Host") + ]: + additional_info[pretty] = request.META.get(header) - zendesk_api = _ZendeskApi() + success = _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info) + _record_feedback_in_datadog(tags) - additional_info_string = ( - "Additional information:\n\n" + - "\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None) - ) - - new_ticket = { - "ticket": { - "requester": {"name": realname, "email": email}, - "subject": subject, - "comment": {"body": details}, - "tags": tags - } - } - try: - ticket_id = zendesk_api.create_ticket(new_ticket) - except zendesk.ZendeskError as err: - log.error("Error creating Zendesk ticket: %s", str(err)) - return HttpResponse(status=500) - - # Additional information is provided as a private update so the information - # is not visible to the user. - ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}} - try: - zendesk_api.update_ticket(ticket_id, ticket_update) - except zendesk.ZendeskError as err: - log.error("Error updating Zendesk ticket: %s", str(err)) - # The update is not strictly necessary, so do not indicate failure to the user - pass - - return HttpResponse() + return HttpResponse(status=(200 if success else 500)) def info(request): diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index e0172bb13b..145a7c2cad 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -14,7 +14,7 @@
${comment}
${comment_prompt}
- +
${tag_prompt}