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}
@@ -22,11 +22,11 @@
% if has_options_value:
% if all([c == 'correct' for c in option['choice'], status]):
-
+ Status: Correct
% elif all([c == 'partially-correct' for c in option['choice'], status]):
-
+ Status: Partially Correct
% elif all([c == 'incorrect' for c in option['choice'], status]):
-
+ Status: Incorrect
% endif
% endif
@@ -53,11 +53,11 @@
% endif
% if status == 'unsubmitted':
-
+ Status: Unanswered
% elif status == 'incomplete':
-
+ Status: Incorrect
% elif status == 'incorrect' and not has_options_value:
-
+ Status: Incorrect
% endif
diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html
index 17c84114e5..34709c3e5e 100644
--- a/common/lib/capa/capa/templates/chemicalequationinput.html
+++ b/common/lib/capa/capa/templates/chemicalequationinput.html
@@ -11,13 +11,13 @@
% endif
-
-
+
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':
diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html
index c9cc3fd28d..17f7efcec4 100644
--- a/common/lib/capa/capa/templates/choicegroup.html
+++ b/common/lib/capa/capa/templates/choicegroup.html
@@ -3,12 +3,12 @@
% if input_type == 'checkbox' or not value:
% if status == 'unsubmitted' or show_correctness == 'never':
- % elif status == 'correct':
-
+ % elif status == 'correct':
+ Status: correct
% elif status == 'incorrect':
-
+ Status: incorrect
% elif status == 'incomplete':
-
+ Status: incomplete
% endif
% endif
@@ -18,7 +18,7 @@
% for choice_id, choice_description in choices:
+ /> ${choice_description}
+
+ % if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
+ <%
+ if status == 'correct':
+ correctness = 'correct'
+ elif status == 'incorrect':
+ correctness = 'incorrect'
+ else:
+ correctness = None
+ %>
+ % if correctness and not show_correctness=='never':
+ Status: ${correctness}
+ % endif
+ % endif
+
% endfor
diff --git a/common/lib/capa/capa/templates/codeinput.html b/common/lib/capa/capa/templates/codeinput.html
index eb8cad0d70..08ad4ff062 100644
--- a/common/lib/capa/capa/templates/codeinput.html
+++ b/common/lib/capa/capa/templates/codeinput.html
@@ -1,5 +1,5 @@
-