diff --git a/.gitignore b/.gitignore
index 72de96e0c4..e92d49a0f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,49 +1,73 @@
-*.pyc
-*~
-*.scssc
-*.swp
-*.orig
-*.DS_Store
-*.mo
-:2e_*
-:2e#
-.AppleDouble
-database.sqlite
+# .gitignore for edx-platform.
+# There's a lot here, please try to keep it organized.
+
+### Files private to developers
+
requirements/private.txt
lms/envs/private.py
cms/envs/private.py
-courseware/static/js/mathjax/*
-flushdb.sh
-build
+
+### Python artifacts
+*.pyc
+
+### Editor and IDE artifacts
+*~
+*.swp
+*.orig
+/nbproject
+.idea/
+.redcar/
+
+### OS X artifacts
+*.DS_Store
+.AppleDouble
+:2e_*
+:2e#
+
+### Internationalization artifacts
+*.mo
+conf/locale/en/LC_MESSAGES/*.po
+!messages.po
+
+### Testing artifacts
+.testids/
+.noseids
+nosetests.xml
.coverage
coverage.xml
cover/
-log/
+cover_html/
reports/
-/src/
-\#*\#
+
+### Installation artifacts
*.egg-info
Gemfile.lock
-.env/
-conf/locale/en/LC_MESSAGES/*.po
-!messages.po
+.pip_download_cache/
+.prereqs_cache
+.vagrant/
+node_modules
+
+### Static assets pipeline artifacts
+*.scssc
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
-cover_html/
-.idea/
-.redcar/
+
+### Logging artifacts
+log/
+logs
chromedriver.log
-/nbproject
ghostdriver.log
-node_modules
-.pip_download_cache/
-.prereqs_cache
+
+### Unknown artifacts
+database.sqlite
+courseware/static/js/mathjax/*
+flushdb.sh
+build
+/src/
+\#*\#
+.env/
+lms/lib/comment_client/python
autodeploy.properties
.ws_migrations_complete
-.vagrant/
-logs
-.testids/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 443e787482..bd28c47a74 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,8 +5,13 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
+LMS: Add PaidCourseRegistration mode, where payment is required before course registration.
+
LMS: Add split testing functionality for internal use.
+CMS: Add edit_course_tabs management command, providing a primitive
+editing capability for a course's list of tabs.
+
Studio and LMS: add ability to lock assets (cannot be viewed unless registered for class).
LMS: Improved accessibility of parts of forum navigation sidebar.
@@ -311,3 +316,6 @@ Common: Updated CodeJail.
Common: Allow setting of authentication session cookie name.
LMS: Option to email students when enroll/un-enroll them.
+
+Blades: Added WAI-ARIA markup to the video player controls. These are now fully
+accessible by screen readers.
diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature
index e01bcf8fed..2e73c11c0c 100644
--- a/cms/djangoapps/contentstore/features/upload.feature
+++ b/cms/djangoapps/contentstore/features/upload.feature
@@ -5,17 +5,16 @@ Feature: CMS.Upload Files
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can upload files
- Given I have opened a new course in Studio
- And I go to the files and uploads page
+ Given I am at the files and upload page of a Studio course
When I upload the file "test"
Then I should see the file "test" was uploaded
And The url for the file "test" is valid
+ # Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can upload multiple files
- Given I have opened a new course in studio
- And I go to the files and uploads page
- When I upload the files "test","test2"
+ Given I am at the files and upload page of a Studio course
+ When I upload the files "test,test2"
Then I should see the file "test" was uploaded
And I should see the file "test2" was uploaded
And The url for the file "test2" is valid
@@ -24,8 +23,7 @@ Feature: CMS.Upload Files
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can update files
- Given I have opened a new course in studio
- And I go to the files and uploads page
+ Given I am at the files and upload page of a Studio course
When I upload the file "test"
And I upload the file "test"
Then I should see only one "test"
@@ -33,8 +31,7 @@ Feature: CMS.Upload Files
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can delete uploaded files
- Given I have opened a new course in studio
- And I go to the files and uploads page
+ Given I am at the files and upload page of a Studio course
When I upload the file "test"
And I delete the file "test"
Then I should not see the file "test" was uploaded
@@ -43,16 +40,14 @@ Feature: CMS.Upload Files
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can download files
- Given I have opened a new course in studio
- And I go to the files and uploads page
+ Given I am at the files and upload page of a Studio course
When I upload the file "test"
Then I can download the correct "test" file
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can download updated files
- Given I have opened a new course in studio
- And I go to the files and uploads page
+ Given I am at the files and upload page of a Studio course
When I upload the file "test"
And I modify "test"
And I reload the page
@@ -62,57 +57,59 @@ Feature: CMS.Upload Files
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can lock assets through asset index
- Given I have opened a new course in studio
- And I go to the files and uploads page
- When I upload the file "test"
- And I lock "test"
- Then "test" is locked
+ Given I am at the files and upload page of a Studio course
+ When I upload an asset
+ And I lock the asset
+ Then the asset is locked
And I see a "saving" notification
And I reload the page
- Then "test" is locked
+ Then the asset is locked
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can unlock assets through asset index
- Given I have opened a course with a locked asset "test"
- And I unlock "test"
- Then "test" is unlocked
+ Given I have created a course with a locked asset
+ When I unlock the asset
+ Then the asset is unlocked
And I see a "saving" notification
And I reload the page
- Then "test" is unlocked
+ Then the asset is unlocked
# Uploading isn't working on safari with sauce labs
- # TODO: work with Jay
-# @skip_safari
-# Scenario: Locked assets can't be viewed if logged in as unregistered user
-# Given I have opened a course with a locked asset "locked.html"
-# Then the asset "locked.html" can be clicked from the asset index
-# And the user "bob" exists
-# And "bob" logs in
-# Then the asset "locked.html" is protected
+ @skip_safari
+ Scenario: Locked assets can't be viewed if logged in as an unregistered user
+ Given I have created a course with a locked asset
+ And the user "bob" exists
+ When "bob" logs in
+ Then the asset is protected
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Locked assets can be viewed if logged in as a registered user
+ Given I have created a course with a locked asset
+ And the user "bob" exists
+ And the user "bob" is enrolled in the course
+ When "bob" logs in
+ Then the asset is viewable
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Locked assets can't be viewed if logged out
- Given I have opened a course with a locked asset "locked.html"
- # Note that logging out doesn't really matter at the moment-
- # the asset will be protected because the user sent to middleware is the anonymous user.
- # Need to work with Jay.
- And I log out
- Then the asset "locked.html" is protected
+ Given I have created a course with a locked asset
+ When I log out
+ Then the asset is protected
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Locked assets can be viewed with is_staff account
- Given I have opened a course with a locked asset "locked.html"
+ Given I have created a course with a locked asset
And the user "staff" exists as a course is_staff
- And "staff" logs in
- Then the asset "locked.html" can be clicked from the asset index
+ When "staff" logs in
+ Then the asset is viewable
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Unlocked assets can be viewed by anyone
- Given I have opened a course with a unlocked asset "unlocked.html"
- Then the asset "unlocked.html" can be clicked from the asset index
- And I log out
- Then the asset "unlocked.html" is viewable
+ Given I have created a course with a unlocked asset
+ When I log out
+ Then the asset is viewable
diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py
index b94ccd114a..25e33f7e5e 100644
--- a/cms/djangoapps/contentstore/features/upload.py
+++ b/cms/djangoapps/contentstore/features/upload.py
@@ -2,14 +2,17 @@
#pylint: disable=W0621
from lettuce import world, step
+from lettuce.django import django_url
from django.conf import settings
import requests
import string
import random
import os
+from django.contrib.auth.models import User
+from student.models import CourseEnrollment
+from splinter.request_handler.status_code import HttpResponseError
from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611
-
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
ASSET_NAMES_CSS = 'td.name-col > span.title > a.filename'
@@ -26,7 +29,10 @@ def go_to_uploads(_step):
def upload_file(_step, file_name):
upload_css = 'a.upload-button'
world.css_click(upload_css)
- #uploading the file itself
+
+ _write_test_file(file_name, "test file")
+
+ # uploading the file itself
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
world.browser.execute_script("$('input.file-input').css('display', 'block')")
world.browser.attach_file('file', os.path.abspath(path))
@@ -34,19 +40,20 @@ def upload_file(_step, file_name):
world.css_click(close_css)
-@step(u'I upload the files (".*")$')
+@step(u'I upload the files "([^"]*)"$')
def upload_files(_step, files_string):
- # Turn files_string to a list of file names
+ # files_string should be comma separated with no spaces.
files = files_string.split(",")
- files = map(lambda x: string.strip(x, ' "\''), files)
-
upload_css = 'a.upload-button'
world.css_click(upload_css)
- #uploading the files
- for f in files:
- path = os.path.join(TEST_ROOT, 'uploads/', f)
+
+ # uploading the files
+ for filename in files:
+ _write_test_file(filename, "test file")
+ path = os.path.join(TEST_ROOT, 'uploads/', filename)
world.browser.execute_script("$('input.file-input').css('display', 'block')")
world.browser.attach_file('file', os.path.abspath(path))
+
close_css = 'a.close-button'
world.css_click(close_css)
@@ -104,13 +111,13 @@ def check_download(_step, file_name):
r = get_file(file_name)
downloaded_text = r.text
assert cur_text == downloaded_text
- #resetting the file back to its original state
+ # resetting the file back to its original state
_write_test_file(file_name, "This is an arbitrary file for testing uploads")
def _write_test_file(file_name, text):
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
- #resetting the file back to its original state
+ # resetting the file back to its original state
with open(os.path.abspath(path), 'w') as cur_file:
cur_file.write(text)
@@ -121,68 +128,68 @@ def modify_upload(_step, file_name):
_write_test_file(file_name, new_text)
-@step(u'I (lock|unlock) "([^"]*)"$')
-def lock_unlock_file(_step, _lock_state, file_name):
- index = get_index(file_name)
- assert index != -1
+@step(u'I upload an asset$')
+def upload_an_asset(step):
+ step.given('I upload the file "asset.html"')
+
+
+@step(u'I (lock|unlock) the asset$')
+def lock_unlock_file(_step, _lock_state):
+ index = get_index('asset.html')
+ assert index != -1, 'Expected to find an asset but could not.'
+
+ # Warning: this is a misnomer, it really only toggles the
+ # lock state. TODO: fix it.
lock_css = "input.lock-checkbox"
world.css_find(lock_css)[index].click()
-@step(u'Then "([^"]*)" is (locked|unlocked)$')
-def verify_lock_unlock_file(_step, file_name, lock_state):
- index = get_index(file_name)
- assert index != -1
+@step(u'the user "([^"]*)" is enrolled in the course$')
+def user_foo_is_enrolled_in_the_course(step, name):
+ world.create_user(name, 'test')
+ user = User.objects.get(username=name)
+
+ course_id = world.scenario_dict['COURSE'].location.course_id
+ CourseEnrollment.enroll(user, course_id)
+
+
+@step(u'Then the asset is (locked|unlocked)$')
+def verify_lock_unlock_file(_step, lock_state):
+ index = get_index('asset.html')
+ assert index != -1, 'Expected to find an asset but could not.'
lock_css = "input.lock-checkbox"
checked = world.css_find(lock_css)[index]._element.get_attribute('checked')
assert_equal(lock_state == "locked", bool(checked))
-@step(u'I have opened a course with a (locked|unlocked) asset "([^"]*)"$')
-def open_course_with_locked(step, lock_state, file_name):
+@step(u'I am at the files and upload page of a Studio course')
+def at_upload_page(step):
step.given('I have opened a new course in studio')
step.given('I go to the files and uploads page')
- _write_test_file(file_name, "test file")
- step.given('I upload the file "' + file_name + '"')
+
+
+@step(u'I have created a course with a (locked|unlocked) asset$')
+def open_course_with_locked(step, lock_state):
+ step.given('I am at the files and upload page of a Studio course')
+ step.given('I upload the file "asset.html"')
+
if lock_state == "locked":
- step.given('I lock "' + file_name + '"')
+ step.given('I lock the asset')
step.given('I reload the page')
-@step(u'Then the asset "([^"]*)" is (viewable|protected)$')
-def view_asset(_step, file_name, status):
- url = '/c4x/MITx/999/asset/' + file_name
+@step(u'Then the asset is (viewable|protected)$')
+def view_asset(_step, status):
+ url = django_url('/c4x/MITx/999/asset/asset.html')
if status == 'viewable':
- world.visit(url)
- _verify_body_text()
+ expected_text = 'test file'
else:
- error_thrown = False
- try:
- world.visit(url)
- except Exception as e:
- assert e.status_code == 403
- error_thrown = True
- assert error_thrown
+ expected_text = 'Unauthorized'
-
-@step(u'Then the asset "([^"]*)" can be clicked from the asset index$')
-def click_asset_from_index(step, file_name):
- # This is not ideal, but I'm having trouble with the middleware not having
- # the same user in the request when I hit the URL directly.
- course_link_css = 'a.course-link'
- world.css_click(course_link_css)
- step.given("I go to the files and uploads page")
- index = get_index(file_name)
- assert index != -1
- world.css_click('a.filename', index=index)
- _verify_body_text()
-
-
-def _verify_body_text():
- def verify_text(driver):
- return world.css_text('body') == 'test file'
-
- world.wait_for(verify_text)
+ # Note that world.visit would trigger a 403 error instead of displaying "Unauthorized"
+ # Instead, we can drop back into the selenium driver get command.
+ world.browser.driver.get(url)
+ assert_equal(world.css_text('body'),expected_text)
@step('I see a confirmation that the file was deleted$')
diff --git a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py
new file mode 100644
index 0000000000..d9c73e42fa
--- /dev/null
+++ b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py
@@ -0,0 +1,88 @@
+###
+### Script for editing the course's tabs
+###
+
+#
+# Run it this way:
+# ./manage.py cms --settings dev edit_course_tabs --course Stanford/CS99/2013_spring
+# Or via rake:
+# rake django-admin[edit_course_tabs,cms,dev,"--course Stanford/CS99/2013_spring --delete 4"]
+#
+from optparse import make_option
+from django.core.management.base import BaseCommand, CommandError
+from .prompt import query_yes_no
+
+from courseware.courses import get_course_by_id
+
+from contentstore.views import tabs
+
+
+def print_course(course):
+ "Prints out the course id and a numbered list of tabs."
+ print course.id
+ for index, item in enumerate(course.tabs):
+ print index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"'
+
+
+# course.tabs looks like this
+# [{u'type': u'courseware'}, {u'type': u'course_info', u'name': u'Course Info'}, {u'type': u'textbooks'},
+# {u'type': u'discussion', u'name': u'Discussion'}, {u'type': u'wiki', u'name': u'Wiki'},
+# {u'type': u'progress', u'name': u'Progress'}]
+
+
+class Command(BaseCommand):
+ help = """See and edit a course's tabs list.
+Only supports insertion and deletion. Move and
+rename etc. can be done with a delete
+followed by an insert.
+The tabs are numbered starting with 1.
+Tabs 1 and 2 cannot be changed, and tabs of type
+static_tab cannot be edited (use Studio for those).
+"""
+ # Making these option objects separately, so can refer to their .help below
+ course_option = make_option('--course',
+ action='store',
+ dest='course',
+ default=False,
+ help='--course required, e.g. Stanford/CS99/2013_spring')
+ delete_option = make_option('--delete',
+ action='store_true',
+ dest='delete',
+ default=False,
+ help='--delete ')
+ insert_option = make_option('--insert',
+ action='store_true',
+ dest='insert',
+ default=False,
+ help='--insert , e.g. 2 "course_info" "Course Info"')
+
+ option_list = BaseCommand.option_list + (course_option, delete_option, insert_option)
+
+ def handle(self, *args, **options):
+ if not options['course']:
+ raise CommandError(Command.course_option.help)
+
+ course = get_course_by_id(options['course'])
+
+ print 'Warning: this command directly edits the list of course tabs in mongo.'
+ print 'Tabs before any changes:'
+ print_course(course)
+
+ try:
+ if options['delete']:
+ if len(args) != 1:
+ raise CommandError(Command.delete_option.help)
+ num = int(args[0])
+ if query_yes_no('Deleting tab {0} Confirm?'.format(num), default='no'):
+ tabs.primitive_delete(course, num - 1) # -1 for 0-based indexing
+ elif options['insert']:
+ if len(args) != 3:
+ raise CommandError(Command.insert_option.help)
+ num = int(args[0])
+ tab_type = args[1]
+ name = args[2]
+ if query_yes_no('Inserting tab {0} "{1}" "{2}" Confirm?'.format(num, tab_type, name), default='no'):
+ tabs.primitive_insert(course, num - 1, tab_type, name) # -1 as above
+ except ValueError as e:
+ # Cute: translate to CommandError so the CLI error prints nicely.
+ raise CommandError(e)
diff --git a/cms/djangoapps/contentstore/tests/test_tabs.py b/cms/djangoapps/contentstore/tests/test_tabs.py
new file mode 100644
index 0000000000..f1cf8ddfa5
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_tabs.py
@@ -0,0 +1,41 @@
+""" Tests for tab functions (just primitive). """
+
+from contentstore.views import tabs
+from django.test import TestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+from courseware.courses import get_course_by_id
+
+
+class PrimitiveTabEdit(TestCase):
+ """Tests for the primitive tab edit data manipulations"""
+
+ def test_delete(self):
+ """Test primitive tab deletion."""
+ course = CourseFactory.create(org='edX', course='999')
+ with self.assertRaises(ValueError):
+ tabs.primitive_delete(course, 0)
+ with self.assertRaises(ValueError):
+ tabs.primitive_delete(course, 1)
+ with self.assertRaises(IndexError):
+ tabs.primitive_delete(course, 6)
+ tabs.primitive_delete(course, 2)
+ self.assertFalse({u'type': u'textbooks'} in course.tabs)
+ # Check that discussion has shifted down
+ self.assertEquals(course.tabs[2], {'type': 'discussion', 'name': 'Discussion'})
+
+ def test_insert(self):
+ """Test primitive tab insertion."""
+ course = CourseFactory.create(org='edX', course='999')
+ tabs.primitive_insert(course, 2, 'atype', 'aname')
+ self.assertEquals(course.tabs[2], {'type': 'atype', 'name': 'aname'})
+ with self.assertRaises(ValueError):
+ tabs.primitive_insert(course, 0, 'atype', 'aname')
+ with self.assertRaises(ValueError):
+ tabs.primitive_insert(course, 3, 'static_tab', 'aname')
+
+ def test_save(self):
+ """Test course saving."""
+ course = CourseFactory.create(org='edX', course='999')
+ tabs.primitive_insert(course, 3, 'atype', 'aname')
+ course2 = get_course_by_id(course.id)
+ self.assertEquals(course2.tabs[3], {'type': 'atype', 'name': 'aname'})
diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py
index f38685edfc..f897fa1378 100644
--- a/cms/djangoapps/contentstore/views/tabs.py
+++ b/cms/djangoapps/contentstore/views/tabs.py
@@ -9,13 +9,14 @@ from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response
-
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.django import modulestore
+
from ..utils import get_course_for_item, get_modulestore
from .access import get_location_and_verify_access
+
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages']
@@ -84,6 +85,7 @@ def reorder_static_tabs(request):
# MongoKeyValueStore before we update the mongo datastore.
course.save()
modulestore('direct').update_metadata(course.location, own_metadata(course))
+ # TODO: above two lines are used for the primitive-save case. Maybe factor them out?
return HttpResponse()
@@ -136,3 +138,43 @@ def static_pages(request, org, course, coursename):
return render_to_response('static-pages.html', {
'context_course': course,
})
+
+
+# "primitive" tab edit functions driven by the command line.
+# These should be replaced/deleted by a more capable GUI someday.
+# Note that the command line UI identifies the tabs with 1-based
+# indexing, but this implementation code is standard 0-based.
+
+def validate_args(num, tab_type):
+ "Throws for the disallowed cases."
+ if num <= 1:
+ raise ValueError('Tabs 1 and 2 cannot be edited')
+ if tab_type == 'static_tab':
+ raise ValueError('Tabs of type static_tab cannot be edited here (use Studio)')
+
+
+def primitive_delete(course, num):
+ "Deletes the given tab number (0 based)."
+ tabs = course.tabs
+ validate_args(num, tabs[num].get('type', ''))
+ del tabs[num]
+ # Note for future implementations: if you delete a static_tab, then Chris Dodge
+ # points out that there's other stuff to delete beyond this element.
+ # This code happens to not delete static_tab so it doesn't come up.
+ primitive_save(course)
+
+
+def primitive_insert(course, num, tab_type, name):
+ "Inserts a new tab at the given number (0 based)."
+ validate_args(num, tab_type)
+ new_tab = {u'type': unicode(tab_type), u'name': unicode(name)}
+ tabs = course.tabs
+ tabs.insert(num, new_tab)
+ primitive_save(course)
+
+
+def primitive_save(course):
+ "Saves the course back to modulestore."
+ # This code copied from reorder_static_tabs above
+ course.save()
+ modulestore('direct').update_metadata(course.location, own_metadata(course))
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 7f2559eece..13faf5520e 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -31,6 +31,7 @@ from path import path
from lms.xblock.mixin import LmsBlockMixin
from cms.xmodule_namespace import CmsBlockMixin
from xmodule.modulestore.inheritance import InheritanceMixin
+from xmodule.x_module import XModuleMixin
############################ FEATURE CONFIGURATION #############################
@@ -168,7 +169,7 @@ MIDDLEWARE_CLASSES = (
# This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
-XBLOCK_MIXINS = (LmsBlockMixin, CmsBlockMixin, InheritanceMixin)
+XBLOCK_MIXINS = (LmsBlockMixin, CmsBlockMixin, InheritanceMixin, XModuleMixin)
############################ SIGNAL HANDLERS ################################
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 364bee2441..f3eee45ea7 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -20,6 +20,16 @@ from warnings import filterwarnings
# Nose Test Runner
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
+_system = 'cms'
+_report_dir = REPO_ROOT / 'reports' / _system
+_report_dir.makedirs_p()
+
+NOSE_ARGS = [
+ '--tests', PROJECT_ROOT / 'djangoapps', COMMON_ROOT / 'djangoapps',
+ '--id-file', REPO_ROOT / '.testids' / _system / 'noseids',
+ '--xunit-file', _report_dir / 'nosetests.xml',
+]
+
TEST_ROOT = path('test_root')
# Want static files in the same dir for running on jenkins.
diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py
index 4d0b7eeb2b..5872955780 100644
--- a/common/djangoapps/external_auth/views.py
+++ b/common/djangoapps/external_auth/views.py
@@ -12,7 +12,7 @@ from external_auth.models import ExternalAuthMap
from external_auth.djangostore import DjangoOpenIDStore
from django.conf import settings
-from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login, logout
+from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.core.validators import validate_email
@@ -45,9 +45,6 @@ from openid.extensions import ax, sreg
from ratelimitbackend.exceptions import RateLimitException
import student.views
-# Required for Pearson
-from courseware.views import get_module_for_descriptor, jump_to
-from courseware.model_data import FieldDataCache
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location
@@ -238,6 +235,7 @@ def _flatten_to_ascii(txt):
else:
return unicode(unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore'))
+
@ensure_csrf_cookie
def _signup(request, eamap):
"""
@@ -896,12 +894,17 @@ def test_center_login(request):
''' Log in students taking exams via Pearson
Takes a POST request that contains the following keys:
- - code - a security code provided by Pearson
+ - code - a security code provided by Pearson
- clientCandidateID
- registrationID
- exitURL - the url that we redirect to once we're done
- vueExamSeriesCode - a code that indicates the exam that we're using
'''
+ # Imports from lms/djangoapps/courseware -- these should not be
+ # in a common djangoapps.
+ from courseware.views import get_module_for_descriptor, jump_to
+ from courseware.model_data import FieldDataCache
+
# errors are returned by navigating to the error_url, adding a query parameter named "code"
# which contains the error code describing the exceptional condition.
def makeErrorURL(error_url, error_code):
diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py
index fc01d25d66..22222d30a4 100644
--- a/common/djangoapps/terrain/course_helpers.py
+++ b/common/djangoapps/terrain/course_helpers.py
@@ -2,17 +2,10 @@
# pylint: disable=W0621
from lettuce import world
-from .factories import *
-from django.conf import settings
-from django.http import HttpRequest
from django.contrib.auth.models import User
-from django.contrib.auth import authenticate, login
-from django.contrib.auth.middleware import AuthenticationMiddleware
-from django.contrib.sessions.middleware import SessionMiddleware
from student.models import CourseEnrollment
from xmodule.modulestore.django import editable_modulestore
from xmodule.contentstore.django import contentstore
-from urllib import quote_plus
@world.absorb
@@ -22,7 +15,7 @@ def create_user(uname, password):
if len(User.objects.filter(username=uname)) > 0:
return
- portal_user = UserFactory.build(username=uname, email=uname + '@edx.org')
+ portal_user = world.UserFactory.build(username=uname, email=uname + '@edx.org')
portal_user.set_password(password)
portal_user.save()
@@ -30,7 +23,7 @@ def create_user(uname, password):
registration.register(portal_user)
registration.activate()
- user_profile = world.UserProfileFactory(user=portal_user)
+ world.UserProfileFactory(user=portal_user)
@world.absorb
diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index 6a24bf8f27..c99234e385 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -1,5 +1,47 @@
from setuptools import setup, find_packages
+XMODULES = [
+ "abtest = xmodule.abtest_module:ABTestDescriptor",
+ "book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
+ "chapter = xmodule.seq_module:SequenceDescriptor",
+ "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
+ "conditional = xmodule.conditional_module:ConditionalDescriptor",
+ "course = xmodule.course_module:CourseDescriptor",
+ "customtag = xmodule.template_module:CustomTagDescriptor",
+ "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
+ "html = xmodule.html_module:HtmlDescriptor",
+ "image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
+ "error = xmodule.error_module:ErrorDescriptor",
+ "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
+ "poll_question = xmodule.poll_module:PollDescriptor",
+ "problem = xmodule.capa_module:CapaDescriptor",
+ "problemset = xmodule.seq_module:SequenceDescriptor",
+ "randomize = xmodule.randomize_module:RandomizeDescriptor",
+ "section = xmodule.backcompat_module:SemanticSectionDescriptor",
+ "sequential = xmodule.seq_module:SequenceDescriptor",
+ "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
+ "timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
+ "vertical = xmodule.vertical_module:VerticalDescriptor",
+ "video = xmodule.video_module:VideoDescriptor",
+ "videoalpha = xmodule.video_module:VideoDescriptor",
+ "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
+ "videosequence = xmodule.seq_module:SequenceDescriptor",
+ "discussion = xmodule.discussion_module:DiscussionDescriptor",
+ "course_info = xmodule.html_module:CourseInfoDescriptor",
+ "static_tab = xmodule.html_module:StaticTabDescriptor",
+ "custom_tag_template = xmodule.raw_module:RawDescriptor",
+ "about = xmodule.html_module:AboutDescriptor",
+ "wrapper = xmodule.wrapper_module:WrapperDescriptor",
+ "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
+ "annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
+ "foldit = xmodule.foldit_module:FolditDescriptor",
+ "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor",
+ "hidden = xmodule.hidden_module:HiddenDescriptor",
+ "raw = xmodule.raw_module:RawDescriptor",
+ "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
+ "lti = xmodule.lti_module:LTIModuleDescriptor",
+]
+
setup(
name="XModule",
version="0.1",
@@ -11,55 +53,16 @@ setup(
'path.py',
],
package_data={
- 'xmodule': ['js/module/*']
+ 'xmodule': ['js/module/*'],
},
# See http://guide.python-distribute.org/creation.html#entry-points
# for a description of entry_points
entry_points={
- 'xmodule.v1': [
- "abtest = xmodule.abtest_module:ABTestDescriptor",
- "book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
- "chapter = xmodule.seq_module:SequenceDescriptor",
- "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
- "conditional = xmodule.conditional_module:ConditionalDescriptor",
- "course = xmodule.course_module:CourseDescriptor",
- "customtag = xmodule.template_module:CustomTagDescriptor",
- "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
- "html = xmodule.html_module:HtmlDescriptor",
- "image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
- "error = xmodule.error_module:ErrorDescriptor",
- "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
- "poll_question = xmodule.poll_module:PollDescriptor",
- "problem = xmodule.capa_module:CapaDescriptor",
- "problemset = xmodule.seq_module:SequenceDescriptor",
- "randomize = xmodule.randomize_module:RandomizeDescriptor",
- "section = xmodule.backcompat_module:SemanticSectionDescriptor",
- "sequential = xmodule.seq_module:SequenceDescriptor",
- "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
- "timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
- "vertical = xmodule.vertical_module:VerticalDescriptor",
- "video = xmodule.video_module:VideoDescriptor",
- "videoalpha = xmodule.video_module:VideoDescriptor",
- "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
- "videosequence = xmodule.seq_module:SequenceDescriptor",
- "discussion = xmodule.discussion_module:DiscussionDescriptor",
- "course_info = xmodule.html_module:CourseInfoDescriptor",
- "static_tab = xmodule.html_module:StaticTabDescriptor",
- "custom_tag_template = xmodule.raw_module:RawDescriptor",
- "about = xmodule.html_module:AboutDescriptor",
- "wrapper = xmodule.wrapper_module:WrapperDescriptor",
- "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
- "annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
- "foldit = xmodule.foldit_module:FolditDescriptor",
- "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor",
- "hidden = xmodule.hidden_module:HiddenDescriptor",
- "raw = xmodule.raw_module:RawDescriptor",
- "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
- "lti = xmodule.lti_module:LTIModuleDescriptor"
- ],
+ 'xblock.v1': XMODULES,
+ 'xmodule.v1': XMODULES,
'console_scripts': [
'xmodule_assets = xmodule.static_content:main',
- ]
- }
+ ],
+ },
)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 5b1a57f20b..0bc79a4a1c 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -306,7 +306,7 @@ class CombinedOpenEndedFields(object):
)
peer_grade_finished_submissions_when_none_pending = Boolean(
display_name='Allow "overgrading" of peer submissions',
- help=("Allow students to peer grade submissions that already have the requisite number of graders, "
+ help=("EXPERIMENTAL FEATURE. Allow students to peer grade submissions that already have the requisite number of graders, "
"but ONLY WHEN all submissions they are eligible to grade already have enough graders. "
"This is intended for use when settings for `Required Peer Grading` > `Peer Graders per Response`"),
default=False,
diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss
index 1a1ca14fbd..c087d18098 100644
--- a/common/lib/xmodule/xmodule/css/video/display.scss
+++ b/common/lib/xmodule/xmodule/css/video/display.scss
@@ -136,7 +136,7 @@ div.video {
&:focus, &:hover {
background-color: lighten($pink, 10%);
- outline: none;
+ outline: 0;
}
}
}
@@ -162,9 +162,16 @@ div.video {
text-indent: -9999px;
width: 14px;
background: url('../images/vcr.png') 15px 15px no-repeat;
- outline: 0;
&:focus {
+ position: relative;
+ z-index: 10000;
+ outline: #fff dotted thin;
+ outline-offset: -2px;
+ background: #333;
+ }
+
+ &:hover {
outline: 0;
}
@@ -176,7 +183,7 @@ div.video {
&.play {
background-position: 17px -114px;
- &:hover, &:focus {
+ &:hover {
background-color: #444;
}
}
@@ -184,7 +191,7 @@ div.video {
&.pause {
background-position: 16px -50px;
- &:hover, &:focus {
+ &:hover {
background-color: #444;
}
}
@@ -203,6 +210,19 @@ div.video {
div.secondary-controls {
float: right;
+ div.speeds>a, div.volume>a, a.add-fullscreen, a.quality_control,
+ a.hide-subtitles {
+ // overflow is used to bypass Firefox CSS :focus outline bug
+ // http://johndoesdesign.com/blog/2012/css/firefox-and-its-css-focus-outline-bug/
+ &:focus {
+ position: relative;
+ z-index: 10000;
+ outline: #fff dotted thin;
+ outline-offset: -2px;
+ overflow: auto;
+ }
+ }
+
div.speeds {
float: left;
position: relative;
@@ -250,10 +270,15 @@ div.video {
}
}
- outline: 0;
-
- &:focus {
+ &:hover {
outline: 0;
+ opacity: 1.0;
+ background-color: #444;
+ }
+
+ &:active {
+ opacity: 1.0;
+ background-color: #444;
}
h3 {
@@ -280,11 +305,6 @@ div.video {
line-height: 46px;
color: #fff;
}
-
- &:hover, &:active, &:focus {
- opacity: 1.0;
- background-color: #444;
- }
}
// fix for now
@@ -320,6 +340,7 @@ div.video {
&:hover {
background-color: #666;
color: #aaa;
+ outline-offset: -4px;
}
}
@@ -371,9 +392,12 @@ div.video {
@include transition(none);
-webkit-font-smoothing: antialiased;
width: 30px;
-
- &:hover, &:active, &:focus {
+
+ &:hover, &:active {
background-color: #444;
+ color: #fff;
+ text-decoration: none;
+ outline: 0;
}
}
@@ -433,14 +457,16 @@ div.video {
text-indent: -9999px;
@include transition(none);
width: 30px;
-
- &:hover, &:active, &:focus {
+
+ &:hover, &:active {
background-color: #444;
color: #fff;
text-decoration: none;
+ outline: 0;
}
}
+
a.quality_control {
background: url(../images/hd.png) center no-repeat;
border-right: 1px solid #000;
@@ -455,16 +481,18 @@ div.video {
@include transition(none);
width: 30px;
- &:hover, &:focus {
+ &:hover {
background-color: #444;
color: #fff;
text-decoration: none;
+ outline: 0;
}
&.active {
background-color: #F44;
color: #0ff;
text-decoration: none;
+ outline: 0;
}
}
@@ -483,10 +511,11 @@ div.video {
-webkit-font-smoothing: antialiased;
width: 30px;
- &:hover, &:focus {
+ &:hover {
background-color: #444;
color: #fff;
text-decoration: none;
+ outline: 0;
}
&.off {
@@ -530,8 +559,7 @@ div.video {
margin-bottom: 8px;
padding: 0;
line-height: lh();
- outline-width: 0px;
- outline-style: none;
+ outline: 0;
&.current {
color: #333;
@@ -539,8 +567,8 @@ div.video {
}
&.focused {
- outline-width: 1px;
- outline-style: dotted;
+ outline: #000 dotted thin;
+ outline-offset: -1px;
}
&:hover {
diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py
index 5c13b90ccf..7fc3747f44 100644
--- a/common/lib/xmodule/xmodule/error_module.py
+++ b/common/lib/xmodule/xmodule/error_module.py
@@ -105,10 +105,10 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
})
return system.construct_xblock_from_class(
cls,
- field_data,
# The error module doesn't use scoped data, and thus doesn't need
# real scope keys
- ScopeIds('error', None, location, location)
+ ScopeIds('error', None, location, location),
+ field_data,
)
def get_context(self):
diff --git a/common/lib/xmodule/xmodule/js/fixtures/video.html b/common/lib/xmodule/xmodule/js/fixtures/video.html
index 410b5869f0..f607430ba0 100644
--- a/common/lib/xmodule/xmodule/js/fixtures/video.html
+++ b/common/lib/xmodule/xmodule/js/fixtures/video.html
@@ -26,26 +26,26 @@
', '', clean_html))
clean_html = re.sub("\n"," ", clean_html)
@@ -351,119 +408,116 @@ class OpenEndedChild(object):
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
return correctness
- def upload_image_to_s3(self, image_data):
+ def upload_file_to_s3(self, file_data):
"""
- Uploads an image to S3
- Image_data: InMemoryUploadedFileObject that responds to read() and seek()
- @return:Success and a URL corresponding to the uploaded object
+ Uploads a file to S3.
+ file_data: InMemoryUploadedFileObject that responds to read() and seek().
+ @return: A URL corresponding to the uploaded object.
"""
- success = False
- s3_public_url = ""
- image_ok = False
- try:
- image_data.seek(0)
- image_ok = open_ended_image_submission.run_image_tests(image_data)
- except Exception:
- log.exception("Could not create image and check it.")
- if image_ok:
- image_key = image_data.name + datetime.now(UTC).strftime(
- xqueue_interface.dateformat
- )
+ file_key = file_data.name + datetime.now(UTC).strftime(
+ xqueue_interface.dateformat
+ )
- try:
- image_data.seek(0)
- success, s3_public_url = open_ended_image_submission.upload_to_s3(
- image_data, image_key, self.s3_interface
- )
- except Exception:
- log.exception("Could not upload image to S3.")
+ file_data.seek(0)
+ s3_public_url = upload_to_s3(
+ file_data, file_key, self.s3_interface
+ )
- return success, image_ok, s3_public_url
+ return s3_public_url
- def check_for_image_and_upload(self, data):
+ def check_for_file_and_upload(self, data):
"""
- Checks to see if an image was passed back in the AJAX query. If so, it will upload it to S3
- @param data: AJAX data
- @return: Success, whether or not a file was in the data dictionary,
- and the html corresponding to the uploaded image
+ Checks to see if a file was passed back by the student. If so, it will be uploaded to S3.
+ @param data: AJAX post dictionary containing keys student_file and valid_files_attached.
+ @return: has_file_to_upload, whether or not a file was in the data dictionary,
+ and image_tag, the html needed to create a link to the uploaded file.
"""
has_file_to_upload = False
- uploaded_to_s3 = False
image_tag = ""
- image_ok = False
- if 'can_upload_files' in data:
- if data['can_upload_files'] in ['true', '1']:
+
+ # Ensure that a valid file was uploaded.
+ if ('valid_files_attached' in data
+ and data['valid_files_attached'] in ['true', '1', True]
+ and data['student_file'] is not None
+ and len(data['student_file']) > 0):
has_file_to_upload = True
student_file = data['student_file'][0]
- uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(student_file)
- if uploaded_to_s3:
- image_tag = self.generate_image_tag_from_url(s3_public_url, student_file.name)
- return has_file_to_upload, uploaded_to_s3, image_ok, image_tag
+ # Upload the file to S3 and generate html to embed a link.
+ s3_public_url = self.upload_file_to_s3(student_file)
+ image_tag = self.generate_file_link_html_from_url(s3_public_url, student_file.name)
- def generate_image_tag_from_url(self, s3_public_url, image_name):
+ return has_file_to_upload, image_tag
+
+ def generate_file_link_html_from_url(self, s3_public_url, file_name):
"""
- Makes an image tag from a given URL
- @param s3_public_url: URL of the image
- @param image_name: Name of the image
- @return: Boolean success, updated AJAX data
+ Create an html link to a given URL.
+ @param s3_public_url: URL of the file.
+ @param file_name: Name of the file.
+ @return: Boolean success, updated AJAX data.
"""
- image_template = """
+ image_link = """
{1}
- """.format(s3_public_url, image_name)
- return image_template
+ """.format(s3_public_url, file_name)
+ return image_link
- def append_image_to_student_answer(self, data):
+ def append_file_link_to_student_answer(self, data):
"""
- Adds an image to a student answer after uploading it to S3
- @param data: AJAx data
- @return: Boolean success, updated AJAX data
+ Adds a file to a student answer after uploading it to S3.
+ @param data: AJAX data containing keys student_answer, valid_files_attached, and student_file.
+ @return: Boolean success, and updated AJAX data dictionary.
"""
- overall_success = False
+
+ error_message = ""
+
if not self.accept_file_upload:
# If the question does not accept file uploads, do not do anything
- return True, data
+ return True, error_message, data
- has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(data)
- if uploaded_to_s3 and has_file_to_upload and image_ok:
+ try:
+ # Try to upload the file to S3.
+ has_file_to_upload, image_tag = self.check_for_file_and_upload(data)
data['student_answer'] += image_tag
- overall_success = True
- elif has_file_to_upload and not uploaded_to_s3 and image_ok:
+ success = True
+ if not has_file_to_upload:
+ # If there is no file to upload, probably the student has embedded the link in the answer text
+ success, data['student_answer'] = self.check_for_url_in_text(data['student_answer'])
+
+ # If success is False, we have not found a link, and no file was attached.
+ # Show error to student.
+ if success is False:
+ error_message = FILE_NOT_FOUND_IN_RESPONSE_MESSAGE
+
+ except Exception:
# In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely
- # a config issue (development vs deployment). For now, just treat this as a "success"
- log.exception("Student AJAX post to combined open ended xmodule indicated that it contained an image, "
- "but the image was not able to be uploaded to S3. This could indicate a config"
- "issue with this deployment, but it could also indicate a problem with S3 or with the"
- "student image itself.")
- overall_success = True
- elif not has_file_to_upload:
- # If there is no file to upload, probably the student has embedded the link in the answer text
- success, data['student_answer'] = self.check_for_url_in_text(data['student_answer'])
- overall_success = success
+ # a config issue (development vs deployment).
+ log.exception("Student AJAX post to combined open ended xmodule indicated that it contained a file, "
+ "but the image was not able to be uploaded to S3. This could indicate a configuration "
+ "issue with this deployment and the S3_INTERFACE setting.")
+ success = False
+ error_message = ERROR_SAVING_FILE_MESSAGE
- # log.debug("Has file: {0} Uploaded: {1} Image Ok: {2}".format(has_file_to_upload, uploaded_to_s3, image_ok))
-
- return overall_success, data
+ return success, error_message, data
def check_for_url_in_text(self, string):
"""
- Checks for urls in a string
- @param string: Arbitrary string
- @return: Boolean success, the edited string
+ Checks for urls in a string.
+ @param string: Arbitrary string.
+ @return: Boolean success, and the edited string.
"""
- success = False
- links = re.findall(r'(https?://\S+)', string)
- if len(links) > 0:
- for link in links:
- success = open_ended_image_submission.run_url_tests(link)
- if not success:
- string = re.sub(link, '', string)
- else:
- string = re.sub(link, self.generate_image_tag_from_url(link, link), string)
- success = True
+ has_link = False
- return success, string
+ # Find all links in the string.
+ links = re.findall(r'(https?://\S+)', string)
+ if len(links)>0:
+ has_link = True
+
+ # Autolink by wrapping links in anchor tags.
+ for link in links:
+ string = re.sub(link, self.generate_file_link_html_from_url(link, link), string)
+
+ return has_link, string
def get_eta(self):
if self.controller_qs:
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
index cc830f88c8..6c0d1bbf08 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
@@ -179,14 +179,11 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
error_message = ""
# add new history element with answer and empty score and hint.
- success, data = self.append_image_to_student_answer(data)
+ success, error_message, data = self.append_file_link_to_student_answer(data)
if success:
data['student_answer'] = SelfAssessmentModule.sanitize_html(data['student_answer'])
self.new_history_entry(data['student_answer'])
self.change_state(self.ASSESSING)
- else:
- # This is a student_facing_error
- error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
return {
'success': success,
diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py
index 088b2c32b6..99caec0840 100644
--- a/common/lib/xmodule/xmodule/tests/__init__.py
+++ b/common/lib/xmodule/xmodule/tests/__init__.py
@@ -11,15 +11,11 @@ import json
import os
import unittest
-import fs
-import fs.osfs
-import numpy
from mock import Mock
from path import path
-import calc
from xblock.field_data import DictFieldData
-from xmodule.x_module import ModuleSystem, XModuleDescriptor, DescriptorSystem
+from xmodule.x_module import ModuleSystem, XModuleDescriptor, XModuleMixin
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.mako_module import MakoDescriptorSystem
@@ -81,7 +77,7 @@ def get_test_descriptor_system():
resources_fs=Mock(),
error_tracker=Mock(),
render_template=lambda template, context: repr(context),
- mixins=(InheritanceMixin,),
+ mixins=(InheritanceMixin, XModuleMixin),
)
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index 5d11a4924f..65fc2bb608 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -12,7 +12,7 @@ import logging
import unittest
from lxml import etree
-from mock import Mock, MagicMock, ANY
+from mock import Mock, MagicMock, ANY, patch
from pytz import UTC
from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild
@@ -26,7 +26,7 @@ from xmodule.progress import Progress
from xmodule.tests.test_util_open_ended import (
MockQueryDict, DummyModulestore, TEST_STATE_SA_IN,
MOCK_INSTANCE_STATE, TEST_STATE_SA, TEST_STATE_AI, TEST_STATE_AI2, TEST_STATE_AI2_INVALID,
- TEST_STATE_SINGLE, TEST_STATE_PE_SINGLE
+ TEST_STATE_SINGLE, TEST_STATE_PE_SINGLE, MockUploadedFile
)
from xblock.field_data import DictFieldData
@@ -374,7 +374,7 @@ class OpenEndedModuleTest(unittest.TestCase):
# Submit a student response to the question.
test_module.handle_ajax(
"save_answer",
- {"student_answer": submitted_response, "can_upload_files": False, "student_file": None},
+ {"student_answer": submitted_response},
get_test_system()
)
# Submitting an answer should clear the stored answer.
@@ -753,7 +753,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
#Simulate a student saving an answer
html = module.handle_ajax("get_html", {})
- module.handle_ajax("save_answer", {"student_answer": self.answer, "can_upload_files": False, "student_file": None})
+ module.handle_ajax("save_answer", {"student_answer": self.answer})
html = module.handle_ajax("get_html", {})
#Mock a student submitting an assessment
@@ -902,3 +902,78 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore):
#Try to reset, should fail because only 1 attempt is allowed
reset_data = json.loads(module.handle_ajax("reset", {}))
self.assertEqual(reset_data['success'], False)
+
+class OpenEndedModuleXmlImageUploadTest(unittest.TestCase, DummyModulestore):
+ """
+ Test if student is able to upload images properly.
+ """
+ problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestionImageUpload"])
+ answer_text = "Hello, this is my amazing answer."
+ file_text = "Hello, this is my amazing file."
+ file_name = "Student file 1"
+ answer_link = "http://www.edx.org"
+ autolink_tag = " nodes"""
+ tag = 'course'
+
+
+class SequenceFactory(XmlImportFactory):
+ """Factory for nodes"""
+ tag = 'sequential'
+
+
+class ProblemFactory(XmlImportFactory):
+ """Factory for nodes"""
+ tag = 'problem'
+ text = '
+ %endif
%endif
diff --git a/lms/templates/peer_grading/peer_grading_problem.html b/lms/templates/peer_grading/peer_grading_problem.html
index be357bf054..bc0bbdfa34 100644
--- a/lms/templates/peer_grading/peer_grading_problem.html
+++ b/lms/templates/peer_grading/peer_grading_problem.html
@@ -55,8 +55,8 @@
${_("This is a deletion.")}${_("[This is a comment.]")}
+ Undo Change Reset Changes
- Undo Change
@@ -65,8 +65,9 @@
% endif
+
- ${_("This submission has explicit, offensive, or (I suspect) plagiarized content: ")}
+ ${_("This submission has explicit, offensive, or (I suspect) plagiarized content. ")}